항해 플러스 6기 프레임워크 사전 스터디에서 개별적으로 진행하는 API를 개발하고 있다.
실행 환경
SpringBoot 3.3.x
Java 21
Member가 로그인(SignIn)할 때 Spring Security를 사용해서 인증을 해보기로 했다.
우선 Spring Security가 간단하게 무엇인지 알아보고, 아키텍처로 동작 과정을 살펴본 후로그인 인증 구조도 아키텍처로 동작 과정 살펴보자.- 그다음에는 구현을 해보고, 시행착오 및 해결에 대해 써보자.
우선 Spring Security로 로그인을 어떤 패키지에 구현할지를 고민했다.
Member가 로그인하니까 member 패키지에 넣을까? 아니면 Spring Security를 사용하니까 인증 관점에서 auth 패키지에 넣을까?
전자로 해보고, 후자로 바꿔보고 고민하다가 후자로 선택했다.
member 패키지에서 인증을 하는 것보다, auth패키지에서 member를 인증하는 게 더 맞는 흐름 같았다.
(member 패키지에서 인증하는 로직을 넣으면 member만의 기능이 아니니 뭔가 비즈니스 로직상 전체적인 논점을 흐리는 느낌이랄까..)
JWT
클라이언트가 로그인한 후에 JWT를 보내기 위해 설정과 구현을 해보자.
로그인 이후 JWT를 활용하여 인증된 요청을 클라이언트가 서버에 보내면, 서버는 JWT를 통해 사용자를 식별하고 권한을 부여할 수 있다.
JWT 라이브러리 추가
build.gradle에 dependencies를 추가하자.
...
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
...
JWT entity 생성
클라이언트에 JWT를 보내기 위한 entity를 생성하자.
@Getter
@Setter
public class JwtToken {
private String grantType;
private String accessToken;
private String refreshToken;
@Builder
public JwtToken(String grantType,
String accessToken,
String refreshToken) {
this.grantType = grantType;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
JwtToken의 필드 중 grantType은 JWT에 대한 인증 타입이고,
단순하고 직관적이며 널리 사용되는 "Bearer" 토큰 인증 방식을 사용한다.
이 인증 방식은 Access Token을 HTTP 요청의 Authorization 헤더에 포함하여 `Authorization: Bearer {ACCESS_TOKEN}`처럼 전송한다.
Bearer 인증에 대해서는 다른 글에서 자세하게 다뤄보자.
JWT 암호키 설정
cmd에 아래 명령어를 입력하여 랜덤으로 암호키를 생성한다. 이때 HS256 알고리즘을 사용하기 위해 32로 설정한다.
이 암호키는 토큰의 암호화/복호화에 사용될 것이다.
openssl rand -hex 32
지금은 랜덤 하게 사용하지만, 서비스에는 `{key} | base64` 처럼 명령어를 입력해서 직접 인코딩 된 문자열을 사용하면 될 것 같다.
생성된 암호키를 아래와 같이 application.yml에 설정한다.
...
jwt:
secret: {생성된 암호키}
...
JwtProvider
Spring Security와 JWT를 사용하여 인증(Authentication)과 권한 부여(Authorization)를 처리하는 클래스다.
이 클래스에서 JWT 생성, 복호화, 검증 기능을 구현해 보자.
@Slf4j
@Component
public class JwtProvider {
private final Key key;
private final long ACCESS_TOKEN_MILLISECONDS = 1000 * 60 * 30;
private final long REFRESH_TOKEN_MILLISECONDS = 1000 * 60 * 60 * 24 * 7;
public JwtProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public JwtToken generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(""));
long now = System.currentTimeMillis();
Date tokenExpires = new Date(now + ACCESS_TOKEN_MILLISECONDS);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(tokenExpires)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
tokenExpires = new Date(now + REFRESH_TOKEN_MILLISECONDS);
String refreshToken = Jwts.builder()
.setExpiration(tokenExpires)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities = Arrays
.stream(
claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JwtProvider 필드 및 생성자
@Slf4j
@Component
public class JwtProvider {
private final Key key;
private final long ACCESS_TOKEN_MILLISECONDS = 1000 * 60 * 30;
private final long REFRESH_TOKEN_MILLISECONDS = 1000 * 60 * 60 * 24;
public JwtProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
...
}
- access 토큰과 refresh 토큰의 유효 시간을 상수 필드로 각각 30분, 7일로 작성한다.
- JwtProvider 생성자
- 파라미터에 application.yml에 작성한 jwt.secret 값을 `@Value("${jwt.secret}")`으로 가져온다.
- `Decoders.BASE64.decode()`로 바이트 배열로 디코딩한다.
- `Keys.hmacShaKeyFor()`는 JWT에서 서명(Signature)과 검증(Verification)에 사용되는 HMAC-SHA 키를 생성한다.
generateToken
...
public JwtToken generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(""));
long now = System.currentTimeMillis();
Date tokenExpires = new Date(now + ACCESS_TOKEN_MILLISECONDS);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(tokenExpires)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
tokenExpires = new Date(now + REFRESH_TOKEN_MILLISECONDS);
String refreshToken = Jwts.builder()
.setExpiration(tokenExpires)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
...
- Authentication 객체를 기반으로 accessToken과 refreshToken을 생성한다.
getAuthentication
...
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities = Arrays
.stream(
claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
...
- parseClaims()
- parseClaimsJws()에서 전달된 accessToken을 파싱 하고, 서명을 검증하며, 토큰의 Claims를 추출한다.
이 과정에서 JWT의 페이로드 부분이 디코딩된다.
(JWT는 기본적으로 Base64Url 인코딩을 사용하므로, 이 단계에서 페이로드가 디코딩되어 원래의 JSON 형태로 변환된다.) - 만료된 토큰인 경우 Claims를 반환한다.
- parseClaimsJws()에서 전달된 accessToken을 파싱 하고, 서명을 검증하며, 토큰의 Claims를 추출한다.
- `Collection<? extends GrantedAuthority>`
- 권한 정보를 다양한 타입의 객체로 처리할 수 있어서 유연성과 확장성을 갖는다.
- Authentication 객체 생성 과정
- 토큰의 Claims에서 권한 정보를 가져온다.
generateToken() 메서드에서 설정한 "auth" claim은 토큰에 저장된 권한 정보를 나타낸다. - 가져온 권한 정보를 SimpleGrantedAuthority 객체로 변환하여 컬렉션에 추가한다.
- UserDetails 객체를 생성하여 주체(subject)와 권한 정보(authority), 기타 필요 정보를 설정한다.
- UsernamePasswordAuthenticationToken 객체를 생성하여 주체와 권한 정보를 포함한 Authentication 객체를 생성한다.
- 토큰의 Claims에서 권한 정보를 가져온다.
validateToken
...
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
...
- 주어진 토큰을 검증하여 유효성을 확인한다.
- `Jwts.parserBuilder()`를 사용하여 토큰의 서명 키를 설정하고, 예외 처리를 통해 토큰의 유효성 여부를 판단한다.
JwtAuthenticationFilter
클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 Filter로, GenericFilterBean을 상속받아 재정의해서 구현한다.
GenericFilterBean은 스프링에서 제공하는 Filter의 구현 클래스 중 하나이다.
이 클래스를 사용하게 되면 Filter의 생명 주기를 Spring LifeCycle과 함께 관리할 수 있다.
따라서 클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고,
유효한 토큰인 경우 해당 토큰의 Authentication을 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtProvider jwtProvider;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String token = resolveToken((HttpServletRequest) servletRequest);
if (token != null && jwtProvider.validateToken(token)) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer")) {
return token.substring(7);
}
return null;
}
}
- doFilter()
- resolveToken() 메서드를 사용하여 요청 헤더에서 JWT 토큰을 추출한다.
- JwtProvider의 validateToken() 메서드로 JWT 토큰의 유효성을 검증한다.
- 토큰이 유효하면 JwtProvider의 getAuthentication() 메서드로 Authentication 객체를 가져와서 SecurityContext에 저장한다. (요청을 처리하는 동안에는 인증 정보가 유지된다.)
- `filterChain.doFilter()`메서드를 호출하여 다음 Filter로 요청을 전달한다.
- resolveToken()
- 주어진 HttpServletRequest에서 토큰 정보를 추출한다.
- 여기서는 "Authorization" 헤더에서 "Bearer" 접두사로 시작하는 토큰을 사용한다.
SecurityConfig 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((authorize) ->
authorize
.requestMatchers(HttpMethod.POST, "/sign-up").permitAll()
.requestMatchers(HttpMethod.GET, "/sign-in").permitAll()
.anyRequest().authenticated())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(
new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- `sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))`
- JWT를 사용하기 때문에 세션을 사용하지 않는다.
- `addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)`
- JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하여 JWT 인증을 처리한다.
PasswordEncoder는 추후에 변경해 보면 좋을 것 같다.
AuthService
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final AuthenticationManager authenticationManager;
private final JwtProvider jwtProvider;
@Override
@Transactional(readOnly = true)
public AuthMemberResponseDto signIn(AuthRequestDto authRequestDto) {
Authentication authentication = authenticationMember(authRequestDto);
JwtToken jwtToken = jwtProvider.generateToken(authentication);
return generateAuthResponseDto(authentication, jwtToken);
}
private Authentication authenticationMember(AuthRequestDto authRequestDto) {
String name = authRequestDto.getName();
String rawPassword = authRequestDto.getPassword();
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(name, rawPassword);
return authenticationManager.authenticate(authenticationToken);
}
private AuthMemberResponseDto generateAuthResponseDto(Authentication authentication, JwtToken jwtToken) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String accessToken = jwtToken.getAccessToken();
String refreshToken = jwtToken.getRefreshToken();
return AuthMemberResponseDto.ofAuthMember(userDetails, accessToken, refreshToken);
}
}
@Getter
@Setter
public class AuthRequestDto {
private String name;
private String password;
}
@Getter
@Setter
@Builder
public class AuthMemberResponseDto {
private String username;
private List<String> authority;
private String accessToken;
private String refreshToken;
public static AuthMemberResponseDto ofAuthMember(UserDetails userDetails, String accessToken, String refreshToken) {
return AuthMemberResponseDto.builder()
.username(userDetails.getUsername())
.authority(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}
로그인 요청이 들어왔을 때 큰 과정은 아래와 같다.
- 로그인 요청으로 들어온 AuthRequestDto의 username과 password를 기반으로 UsernamePasswordAuthenticationToken() 객체를 생성한다.
- 생성한 UsernamePasswordAuthenticationToken()을 기반으로 `authenticationManager.authenticate()` 인증을 실행한다.
- 인증 성공 후 생성된 Authentication 객체를 기반으로 `jwtProvider.generateToken()`을 실행하여 JWT를 생성한다.
이때 JWT에는 accessToken과 refreshToken이 생성된다. - 로그인 요청에 응답할 AuthMemberResponseDto를 만들어 return 한다.
UsernamePasswordAuthenticationToken
로그인 요청으로 들어온 username과 password를 기반으로 UsernamePasswordAuthenticationToken()을 생성한다.
이때 password는 인코딩 되지 않은 원시 비밀번호의 값이어야 한다.
이렇게 생성된 토큰 객체를 생성함으로써 사용자 인증에 필요한 기본적인 자격 증명(credentials)을 설정한 것이다.
처음엔 잘 몰라서 DB에는 인코딩 된 문자열이 들어가 있으니까 비밀번호를 인코딩 시켜서 담아야겠다고 생각했는데, 아래에 설명을 하겠지만 인증 절차 중에 자동으로 인코딩을 하기 때문에 미리 인코딩할 필요가 없다.
authenticate
AuthenticationManager를 통해 인증 절차를 밟아 보자.
위에서 얻은 자격 증명에 대한 토큰을 기반으로 `authenticationManager.authenticate(authenticationToken)` 인증을 실행한다.
- ProviderManager 클래스의 authenticate() 메서드가 동작한다.
- AuthenticationManager는 인터페이스고, 실제 구현은 ProviderManager가 담당하기 때문이다.
- 내부 구현에는 AuthenticationProvider.authenticate()가 동작한다.
- AbstractUserDetailsAuthenticationProvider 클래스의 authenticate() 메서드가 동작한다.
- AuthenticationProvider는 인터페이스고, 실제 구현은 AbstractUserDetailsAuthenticationProvider가 담당하기 때문이다.
- usernamer과 UsernamePasswordAuthenticationToken를 기반으로 User를 찾아 UserDetails 객체를 생성한다.
- DaoAuthenticationProvider 클래스의 retrieveUser() 메서드가 동작한다.
- AbstractUserDetailsAuthenticationProvider를 상속한 클래스가 DaoAuthenticationProvider이다.
- 여기서 SpringSecurity가 제공하는 UserDetailsService의 loadUserByUsername()이 실행된다.
- UserDetailsService의 loadUserByUsername()는 username이 DB에 있는지 확인하는 구현체를 만들어줘야 한다. (아래에서 다시 살펴보자.)
- Override 한 loadUserByUsername()에서 로그인 요청한 username이 Member 테이블에 존재하는지 확인한 후, SpringSeucrity User 객체 타입으로 만들어 return 한다.
- loadUserByUsername()에서 Member 확인 후, UserDetails의 구현체인 User를 return 한다.
- AbstractUserDetailsAuthenticationProvider에서 retrieveUser()가 완료되고, additionalAuthenticationChecks()가 실행되어 비밀번호를 검증한다.
- retrieveUser()로 받은 UserDetails와 UsernamePasswordAuthenticationToken을 기반으로 additionalAuthenticationChecks()가 실행된다.
- 여기서 자격 증명(credentials)할 비밀번호가 있는지 확인한다.
- SecurityConfig에서 주입한 Bean인 PasswordEncoder를 사용하여 로그인 요청한 비밀번호와 테이블에 등록된 비밀번호가 일치하는지 확인한다.
- 참고로 UserDetails에는 테이블에 등록된 인코딩 되어 있는 비밀번호, UsernamePasswordAuthenticationToken에는 로그인 요청한 원시 비밀번호가 들어있다.
AuthMemberResponseDto는 인증 후 반환된 UserDetails에는 보안과 관련된 중요한 정보들이 있으므로,
응답에 필요한 정보만 제공하기 위해서 만들었다.
AuthController
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@GetMapping("/sign-in")
public ResponseEntity<ResponseDto<AuthResponseDto>> signIn(@RequestBody AuthRequestDto authRequestDto) {
AuthMemberResponseDto authMemberResponseDto = authService.signIn(authRequestDto);
AuthResponseDto authResponseDto = AuthResponseDto.ofAuth(authMemberResponseDto);
HttpHeaders headers = createAuthHeader(authMemberResponseDto);
ResponseDto<AuthResponseDto> responseDto = ResponseDto.ofSuccess("SignIn Success.", authResponseDto);
return ResponseEntity
.status(HttpStatus.OK)
.headers(headers)
.body(responseDto);
}
...
}
@Getter
@Setter
@Builder
public class AuthResponseDto {
private String username;
private List<String> authority;
public static AuthResponseDto ofAuth(AuthMemberResponseDto authMemberResponseDto) {
return AuthResponseDto.builder()
.username(authMemberResponseDto.getUsername())
.authority(authMemberResponseDto.getAuthority())
.build();
}
}
@Getter
@Setter
@RequiredArgsConstructor
public class ResponseDto<D> {
private final HttpStatus httpStatus;
private final String message;
private final D data;
public static <D> ResponseDto<D> ofSuccess(String message, D data) {
return new ResponseDto<>(HttpStatus.OK, message, data);
}
}
다형성에 대해 구현해보고 싶어서 컨트롤러에서 응답을 할 때 사용해 봤다.
요구사항 중에 token은 header에 넣으라는 요청이 있어서 header를 추가했고,
이를 위해 body에 보낼 정보에 대한 캡슐화가 필요했기에 AuthResponseDto를 추가했다.
패키지 구조와 메서드 각각의 로직들을 유연하게 만들고 싶었고, 혹여나 복잡해지지는 않을지 리팩토링을 많이 해봤는데,
아직 완벽하게 뭐가 좋다! 라고 잡히지는 않은 것 같다.
지금 작성한 코드들과 구조가 최선은 당연히 아닐 것이다.
앞으로도 나를 꾸준히 개선해 보자.
도움 받은 곳
https://gong-check.github.io/dev-blog/BE/%EC%96%B4%EC%8D%B8%EC%98%A4/jwt_impl/jwt_impl/