- 깃허브 코드 부트 버전 = 3.2.0
Session vs Token
- 자세한 내용은 위의 포스팅을 참고
세션 기반 인증 방식
- Client의 인증 정보를 서버 메모리상에서 관리하므로 보안상 안전
- Client에 대한 세션이 많아질수록 서버 부하가 심해지고 Scale Out시 세션 불일치에 대한 해결방안을 구축해야 한다
- Sticky Session
- Session Clustering
- Session Storage
토큰 기반 인증 방식
- 서버에서 전자서명을 통해서 발급한 토큰을 통해서 인증을 진행하고 서버 메모리에서 관리하지 않는다 (HTTP Stateless)
- 그에 따라서 Scale Out을 하더라도 서버에서 관리하지 않기 때문에 확장성 측면에서 이점을 얻을 수 있다
- 하지만 발급한 토큰의 Payload는 jwt.io와 같은 사이트에서 원본 데이터를 볼 수 있기 때문에 민감 데이터는 Payload에 넣으면 안된다
Spring Security + JPA + JWT 토큰 기반 인증
1. JPA Entity 설계
기본적인 User & Role 관련 JPA Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "login_id", nullable = false, updatable = false, unique = true)
private String loginId;
@Column(name = "password", nullable = false)
private String password;
@Column(name = "name", nullable = false)
private String name;
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
private final List<Role> roles = new ArrayList<>();
public User(final String loginId, final String password, final String name, final Set<RoleType> roleTypes) {
this.loginId = loginId;
this.password = password;
this.name = name;
this.roles.addAll(
roleTypes.stream()
.map(roleType -> Role.createRole(this, roleType))
.toList()
);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private RoleType roleType;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false)
private User user;
private Role(final User user, final RoleType roleType) {
this.user = user;
this.roleType = roleType;
}
public static Role createRole(final User user, final RoleType roleType) {
return new Role(user, roleType);
}
public String getAuthority() {
return roleType.getAuthority();
}
}
@Getter
@RequiredArgsConstructor
public enum RoleType {
ADMIN("ROLE_ADMIN", "관리자"),
USER("ROLE_USER", "사용자"),
;
private final String authority;
private final String description;
}
2. 토큰 관련 컴포넌트
(1) JWT 형식의 토큰을 관리하는 컴포넌트 → TokenProvider - JwtTokenProvider
// application.yml
jwt:
secret-key: 2da7acad220ffe59e6943c826ec1fcf879a4339521ff5837fa92aab485e94bcb # 테스트용 Secret Key
access-token-validity: 7200 # 2시간
refresh-token-validity: 1209600 # 2주
// Class
public interface TokenProvider {
String createAccessToken(final Long userId);
String createRefreshToken(final Long userId);
Long getId(final String token);
void validateToken(final String token);
}
@Slf4j
@Component
public class JwtTokenProvider implements TokenProvider {
private final SecretKey secretKey;
private final long accessTokenValidityInMilliseconds;
private final long refreshTokenValidityInMilliseconds;
public JwtTokenProvider(
@Value("${jwt.secret-key}") final String secretKey,
@Value("${jwt.access-token-validity}") final long accessTokenValidityInMilliseconds,
@Value("${jwt.refresh-token-validity}") final long refreshTokenValidityInMilliseconds
) {
this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds;
this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;
}
@Override
public String createAccessToken(final Long userId) {
return createToken(userId, accessTokenValidityInMilliseconds);
}
@Override
public String createRefreshToken(final Long userId) {
return createToken(userId, refreshTokenValidityInMilliseconds);
}
private String createToken(final Long userId, final long validityInMilliseconds) {
final Claims claims = Jwts.claims();
claims.put("id", userId);
final ZonedDateTime now = ZonedDateTime.now();
final ZonedDateTime tokenValidity = now.plusSeconds(validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
@Override
public Long getId(final String token) {
return getClaims(token)
.getBody()
.get("id", Long.class);
}
@Override
public void validateToken(final String token) {
try {
final Jws<Claims> claims = getClaims(token);
final Date expiredDate = claims.getBody().getExpiration();
final Date now = new Date();
if (expiredDate.before(now)) {
throw new InvalidTokenException();
}
} catch (final ExpiredJwtException |
SecurityException |
MalformedJwtException |
UnsupportedJwtException |
IllegalArgumentException e) {
throw new InvalidTokenException();
}
}
private Jws<Claims> getClaims(final String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
}
}
- createAccessToken → userId Payload 기반 AccessToken 생성
- createRefreshToken → userId Payload 기반 RefreshToken 생성
- getId → 토큰의 Payload(userId) 응답
- validateToken → 토큰이 만료되었거나 무결성에 위배되었는지 검증
(2) 토큰을 RDB에서 관리하기 위한 컴포넌트 → TokenManager
@Component
@RequiredArgsConstructor
public class TokenManager {
private final TokenRepository tokenRepository;
@Transactional
public void synchronizeRefreshToken(final Long userId, final String refreshToken) {
tokenRepository.findByUserId(userId)
.ifPresentOrElse(
token -> token.updateRefreshToken(refreshToken),
() -> tokenRepository.save(Token.issueRefreshToken(userId, refreshToken))
);
}
public void updateRefreshToken(final Long userId, final String newRefreshToken) {
tokenRepository.updateRefreshToken(userId, newRefreshToken);
}
public void deleteRefreshToken(final Long userId) {
tokenRepository.deleteRefreshToken(userId);
}
public boolean isUserRefreshToken(final Long userId, final String refreshToken) {
return tokenRepository.existsByUserIdAndRefreshToken(userId, refreshToken);
}
}
public interface TokenRepository extends JpaRepository<Token, Long> {
// @Query
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE Token t" +
" SET t.refreshToken = :refreshToken" +
" WHERE t.userId = :userId")
void updateRefreshToken(@Param("userId") final Long userId, @Param("refreshToken") final String refreshToken);
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Token t WHERE t.userId = :userId")
void deleteRefreshToken(@Param("userId") final Long userId);
// Query Method
Optional<Token> findByUserId(final Long userId);
boolean existsByUserIdAndRefreshToken(final Long userId, final String refreshToken);
}
(3) 토큰을 Client에게 발급하기 위한 컴포넌트 → TokenIssuer
@Component
@RequiredArgsConstructor
public class TokenIssuer {
private final TokenProvider tokenProvider;
private final TokenManager tokenManager;
public AuthToken provideAuthorityToken(final Long userId) {
final String accessToken = tokenProvider.createAccessToken(userId);
final String refreshToken = tokenProvider.createRefreshToken(userId);
tokenManager.synchronizeRefreshToken(userId, refreshToken);
return new AuthToken(accessToken, refreshToken);
}
public AuthToken reissueAuthorityToken(final Long userId) {
final String newAccessToken = tokenProvider.createAccessToken(userId);
final String newRefreshToken = tokenProvider.createRefreshToken(userId);
tokenManager.updateRefreshToken(userId, newRefreshToken);
return new AuthToken(newAccessToken, newRefreshToken);
}
public boolean isUserRefreshToken(final Long userId, final String refreshToken) {
return tokenManager.isUserRefreshToken(userId, refreshToken);
}
public void deleteRefreshToken(final Long userId) {
tokenManager.deleteRefreshToken(userId);
}
}
(4) 토큰을 HTTP Header & Cookie에 적용하기 위한 컴포넌트 → TokenResponseWriter
@Component
public class TokenResponseWriter {
public static final String REFRESH_TOKEN_COOKIE = "refresh_token";
public static final String AUTHORIZATION_HEADER_TOKEN_PREFIX = "Bearer";
private final long refreshTokenCookieAge;
public TokenResponseWriter(@Value("${jwt.refresh-token-validity}") final long refreshTokenCookieAge) {
this.refreshTokenCookieAge = refreshTokenCookieAge;
}
public void applyToken(final HttpServletResponse response, final AuthToken token) {
applyAccessToken(response, token.accessToken());
applyRefreshToken(response, token.refreshToken());
}
private void applyAccessToken(final HttpServletResponse response, final String accessToken) {
response.setHeader(AUTHORIZATION, String.join(" ", AUTHORIZATION_HEADER_TOKEN_PREFIX, accessToken));
}
private void applyRefreshToken(final HttpServletResponse response, final String refreshToken) {
final ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE, refreshToken)
.maxAge(refreshTokenCookieAge)
.sameSite(STRICT.attributeValue())
.secure(true)
.httpOnly(true)
.path("/")
.build();
response.setHeader(SET_COOKIE, cookie.toString());
}
}
- AccessToken → HTTP Authorization Header에 담아서 응답
- RefreshToken → HTTP Cookie에 담아서 응답
(5) HttpServletRequest로부터 Token을 추출하는 컴포넌트 → RequestTokenExtractor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RequestTokenExtractor {
public static Optional<String> extractAccessToken(final HttpServletRequest request) {
final String token = request.getHeader(AUTHORIZATION);
if (isEmptyToken(token)) {
return Optional.empty();
}
return checkToken(token.split(" "));
}
public static Optional<String> extractRefreshToken(final HttpServletRequest request) {
final Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) {
return Optional.empty();
}
final String token = Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(TokenResponseWriter.REFRESH_TOKEN_COOKIE))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
if (isEmptyToken(token)) {
return Optional.empty();
}
return Optional.of(token);
}
private static boolean isEmptyToken(final String token) {
return !StringUtils.hasText(token);
}
private static Optional<String> checkToken(final String[] parts) {
if (parts.length == 2 && parts[0].equals(AUTHORIZATION_HEADER_TOKEN_PREFIX)) {
return Optional.ofNullable(parts[1]);
}
return Optional.empty();
}
}
3. Security 필터, 핸들러 관련 설정
Spring Security 인증 관련 전체 흐름이 아직 익숙하지 않다
→ 이어지는 설명을 이해하기 위해서 반드시 위의 포스팅들을 먼저 읽고 이해
Spring Boot에서는 기본적으로 SpringBootWebSecurityConfiguration을 통해서 최소한의 Security 설정을 진행해준다
하지만 본 포스팅은 커스텀한 Security 설정을 진행할 것이기 때문에 이와 관련없이 전체적인 인증 흐름을 커스터마이징할 예정이다
- Ajax(application/json) 요청을 통한 인증
Security 설정은 Spring Boot 3.2.0 기준 Deprecated된 WebSecurityConfigurerAdapter가 아닌 SecurityFilterChain을 빈으로 등록
(0) JPA Entity + implements UserDetails?
구글링을 해보면 다음과 같이 UserDetails를 구현한 것을 많이 볼 수 있다
@Entity
public class Member implements UserDetails {
...
}
- JPA Entity에 UserDetails를 implements하는 행위
과연 이 방법이 최선일까?
위의 참고 포스팅에서도 말했듯이 UserDetails 기반 Authentication Principal은 Spring Security 전역적으로 활용하는 Authentication Model이다
이러한 전역적인 Model에 대해서 JPA Entity와의 직접적인 강결합으로 구현하게 되면 다양한 문제가 발생할 수 있다
대표젹으로 User - Role간의 OneToMany 관계를 fetchLazy로 설정했을 경우 AuthenticationToken을 만들기 위해서는 해당 사용자의 Role이 필요할거고 이 Access 시점에 Role을 Lazy로 가져오게 된다
이러한 JPA Lazy Loading 메커니즘은 영속성 컨텍스트가 활성화된 Scope에서 진행되는 메커니즘이다
Spring Security가 Filter Level에서 인증/인가를 처리하는데 과연 영속성 컨텍스트를 Filter Level부터 열어버리는게 옳은 선택일까?
영속성 컨텍스트를 Open한다는 의미는 Transaction Scope도 같이 가져간다는 의미이다
Filter Level부터 영속성 컨텍스트를 Open한다는 말은 다른 측면에서 보면 불필요한 리소스를 너무나도 큰 범위에서 의미없게 활용하게 된다
따라서 이러한 여러가지 이유로 인해 JPA Entity와 Security UserDetails간의 강결합을 피하고 AuthenticationModel을 위한 별도의 모델을 구현하는 것을 추천한다
public record UserPrincipal(
Long id,
String name,
String loginId,
String password,
List<String> roles
) implements UserDetails {
public UserPrincipal(final User user) {
this(
user.getId(),
user.getName(),
user.getLoginId(),
user.getPassword(),
user.getRoles()
.stream()
.map(Role::getAuthority)
.toList()
);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
final Collection<GrantedAuthority> authorities = new ArrayList<>();
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return loginId;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
(1) JsonAuthenticationFilter
public class JsonAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final AntPathRequestMatcher DEFAULT_LOGIN_API_URL = new AntPathRequestMatcher("/api/login", "POST");
private final ObjectMapper objectMapper;
public JsonAuthenticationFilter(final ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_API_URL);
this.objectMapper = objectMapper;
}
@Override
public Authentication attemptAuthentication(
final HttpServletRequest request,
final HttpServletResponse response
) throws AuthenticationException, IOException {
validateContentType(request);
final LoginRequest loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class);
validateLoginRequestData(loginRequest.loginId(), loginRequest.loginPassword());
final UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
loginRequest.loginId(),
loginRequest.loginPassword()
);
return this.getAuthenticationManager().authenticate(token);
}
private void validateContentType(final HttpServletRequest request) {
if (isInvalidContentType(request)) {
throw SecurityJwtAuthenticationException.type(AuthErrorCode.INVALID_AUTH_CONTENT_TYPE);
}
}
private boolean isInvalidContentType(final HttpServletRequest request) {
return request.getHeader(CONTENT_TYPE) == null
|| !request.getHeader(CONTENT_TYPE).contains(APPLICATION_JSON_VALUE);
}
private void validateLoginRequestData(final String loginId, final String loginPassword) {
if (isEmpty(loginId) || isEmpty(loginPassword)) {
throw SecurityJwtAuthenticationException.type(AuthErrorCode.INVALID_AUTH_DATA);
}
}
private boolean isEmpty(final String value) {
return !StringUtils.hasText(value);
}
}
(2) JsonAuthenticationProvider & RdbUserDetailsService
@RequiredArgsConstructor
public class JsonAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
final String username = (String) authentication.getPrincipal();
final String password = (String) authentication.getCredentials();
final UserDetails userDetails = userDetailsService.loadUserByUsername(username);
validatePassword(password, userDetails);
return createAuthenticationSuccessToken(userDetails);
}
private void validatePassword(final String rawPassword, final UserDetails userDetails) {
if (isNotCorrectPassword(rawPassword, userDetails)) {
throw new BadCredentialsException(UserErrorCode.INVALID_PASSWORD.getMessage());
}
}
private boolean isNotCorrectPassword(final String rawPassword, final UserDetails userDetails) {
return userDetails == null || !passwordEncoder.matches(rawPassword, userDetails.getPassword());
}
private Authentication createAuthenticationSuccessToken(final UserDetails userDetails) {
final UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
token.eraseCredentials();
return token;
}
@Override
public boolean supports(final Class<?> authentication) {
return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class);
}
}
@RequiredArgsConstructor
public class RdbUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(final String loginId) throws UsernameNotFoundException {
final User user = userRepository.findByLoginIdWithRoles(loginId)
.orElseThrow(() -> new UsernameNotFoundException(UserErrorCode.USER_NOT_FOUND.getMessage()));
return new UserPrincipal(user);
}
}
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u" +
" FROM User u" +
" JOIN FETCH u.roles" +
" WHERE u.loginId = :loginId")
Optional<User> findByLoginIdWithRoles(@Param("loginId") String loginId);
}
(3) JsonAuthenticationSuccessHandler & JsonAuthenticationFailureHandler
@RequiredArgsConstructor
public class JsonAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final TokenIssuer tokenIssuer;
private final TokenResponseWriter tokenResponseWriter;
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(
final HttpServletRequest request,
final HttpServletResponse response,
final Authentication authentication
) throws IOException {
final UserPrincipal user = getPrincipal(authentication);
final AuthToken authToken = tokenIssuer.provideAuthorityToken(user.id());
tokenResponseWriter.applyToken(response, authToken);
sendResponse(response, user);
}
private UserPrincipal getPrincipal(final Authentication authentication) {
return (UserPrincipal) authentication.getPrincipal();
}
private void sendResponse(final HttpServletResponse response, final UserPrincipal user) throws IOException {
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), new LoginResponse(user.id(), user.name()));
}
}
@Slf4j
@RequiredArgsConstructor
public class JsonAuthenticationFailureHandler implements AuthenticationFailureHandler {
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(
final HttpServletRequest request,
final HttpServletResponse response,
final AuthenticationException exception
) throws IOException {
final ErrorResponse errorResponse = createErrorResponse(exception);
sendResponse(response, errorResponse);
}
private static ErrorResponse createErrorResponse(final AuthenticationException exception) {
if (exception instanceof final SecurityJwtAuthenticationException ex) {
return ErrorResponse.from(ex.getCode());
} else if (exception instanceof UsernameNotFoundException) {
return ErrorResponse.from(UserErrorCode.USER_NOT_FOUND);
} else if (exception instanceof BadCredentialsException) {
return ErrorResponse.from(UserErrorCode.INVALID_PASSWORD);
} else {
log.error("{}", exception, exception);
return ErrorResponse.from(GlobalErrorCode.INTERNAL_SERVER_ERROR);
}
}
private void sendResponse(final HttpServletResponse response, final ErrorResponse errorResponse) throws IOException {
response.setStatus(errorResponse.getStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), errorResponse);
}
}
(4) JwtAuthorizationFilter & JwtAuthorizationExceptionTranslationFilter
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(
final HttpServletRequest request,
final HttpServletResponse response,
final FilterChain filterChain
) throws ServletException, IOException {
final Optional<String> token = RequestTokenExtractor.extractAccessToken(request);
if (token.isPresent()) {
try {
final String accessToken = token.get();
tokenProvider.validateToken(accessToken);
final User user = getUserByToken(accessToken);
applyUserToSecurityContext(user);
} catch (final InvalidTokenException e) {
throw SecurityJwtAccessDeniedException.type(AuthErrorCode.INVALID_TOKEN);
}
}
filterChain.doFilter(request, response);
}
private User getUserByToken(final String accessToken) {
return userRepository.findByIdWithRoles(tokenProvider.getId(accessToken))
.orElseThrow(() -> SecurityJwtAccessDeniedException.type(UserErrorCode.USER_NOT_FOUND));
}
private void applyUserToSecurityContext(final User user) {
final UserPrincipal principal = new UserPrincipal(user);
final UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
@RequiredArgsConstructor
public class JwtAuthorizationExceptionTranslationFilter extends OncePerRequestFilter {
private final AccessDeniedHandler accessDeniedHandler;
@Override
protected void doFilterInternal(
final HttpServletRequest request,
final HttpServletResponse response,
final FilterChain filterChain
) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (final SecurityJwtAccessDeniedException ex) {
accessDeniedHandler.handle(request, response, ex);
}
}
}
(5) JwtAuthenticationEntryPoint & JwtAccessDeniedHandler
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(
final HttpServletRequest request,
final HttpServletResponse response,
final AuthenticationException authException
) throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), ErrorResponse.from(AuthErrorCode.LOGIN_REQUIRED));
}
}
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(
final HttpServletRequest request,
final HttpServletResponse response,
final AccessDeniedException accessDeniedException
) throws IOException {
final ErrorResponse errorResponse = createErrorResponse(accessDeniedException);
sendResponse(response, errorResponse);
}
private ErrorResponse createErrorResponse(final AccessDeniedException exception) {
if (exception instanceof final SecurityJwtAccessDeniedException ex) {
return ErrorResponse.from(ex.getCode());
}
return ErrorResponse.from(AuthErrorCode.INVALID_TOKEN);
}
private void sendResponse(final HttpServletResponse response, final ErrorResponse errorResponse) throws IOException {
response.setStatus(errorResponse.getStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), errorResponse);
}
}
(6) JwtLogoutTokenCheckHandler & JwtLogoutSuccessHandler & LogoutExceptionTranslationFilter
public class JwtLogoutTokenCheckHandler implements LogoutHandler {
@Override
public void logout(
final HttpServletRequest request,
final HttpServletResponse response,
final Authentication authentication
) {
if (authentication == null) {
throw SecurityJwtAccessDeniedException.type(AuthErrorCode.INVALID_PERMISSION);
}
}
}
@RequiredArgsConstructor
public class JwtLogoutSuccessHandler implements LogoutSuccessHandler {
private final TokenIssuer tokenIssuer;
@Override
public void onLogoutSuccess(
final HttpServletRequest request,
final HttpServletResponse response,
final Authentication authentication
) {
removeRefreshToken(authentication);
clearSecurityContextHolder();
sendResponse(response);
}
private void removeRefreshToken(final Authentication authentication) {
final UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
tokenIssuer.deleteRefreshToken(userPrincipal.id());
}
private void clearSecurityContextHolder() {
SecurityContextHolder.clearContext();
}
private void sendResponse(final HttpServletResponse response) {
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
}
}
@RequiredArgsConstructor
public class LogoutExceptionTranslationFilter extends OncePerRequestFilter {
private final AccessDeniedHandler accessDeniedHandler;
@Override
protected void doFilterInternal(
final HttpServletRequest request,
final HttpServletResponse response,
final FilterChain filterChain
) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (final SecurityJwtAccessDeniedException ex) {
accessDeniedHandler.handle(request, response, ex);
}
}
}
(7) SecurityConfiguration with SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final AuthenticationConfiguration authenticationConfiguration;
private final CorsProperties corsProperties;
private final ObjectMapper objectMapper;
private final UserRepository userRepository;
private final TokenProvider tokenProvider;
private final TokenIssuer tokenIssuer;
private final TokenResponseWriter tokenResponseWriter;
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
final CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOriginPatterns(corsProperties.getAllowedOriginPatterns());
corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "HEAD", "OPTIONS"));
corsConfiguration.setAllowedHeaders(List.of("*"));
corsConfiguration.setAllowCredentials(true);
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", corsConfiguration);
return source;
}
@Bean
public UserDetailsService rdbUserDetailsService() {
return new RdbUserDetailsService(userRepository);
}
@Bean
public AuthenticationProvider jsonAuthenticationProvider() {
return new JsonAuthenticationProvider(rdbUserDetailsService(), passwordEncoder());
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
final ProviderManager authenticationManager = (ProviderManager) authenticationConfiguration.getAuthenticationManager();
authenticationManager.getProviders().add(jsonAuthenticationProvider());
return authenticationManager;
}
@Bean
public AuthenticationSuccessHandler jsonAuthenticationSuccessHandler() {
return new JsonAuthenticationSuccessHandler(tokenIssuer, tokenResponseWriter, objectMapper);
}
@Bean
public AuthenticationFailureHandler jsonAuthenticationFailureHandler() {
return new JsonAuthenticationFailureHandler(objectMapper);
}
@Bean
public JsonAuthenticationFilter jsonAuthenticationFilter() throws Exception {
final JsonAuthenticationFilter authenticationFilter = new JsonAuthenticationFilter(objectMapper);
authenticationFilter.setAuthenticationManager(authenticationManager());
authenticationFilter.setAuthenticationSuccessHandler(jsonAuthenticationSuccessHandler());
authenticationFilter.setAuthenticationFailureHandler(jsonAuthenticationFailureHandler());
return authenticationFilter;
}
@Bean
public AuthenticationEntryPoint jwtAuthenticationEntryPoint() {
return new JwtAuthenticationEntryPoint(objectMapper);
}
@Bean
public AccessDeniedHandler jwtAccessDeniedHandler() {
return new JwtAccessDeniedHandler(objectMapper);
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(tokenProvider, userRepository);
}
@Bean
public JwtAuthorizationExceptionTranslationFilter jwtAuthorizationExceptionTranslationFilter() {
return new JwtAuthorizationExceptionTranslationFilter(jwtAccessDeniedHandler());
}
@Bean
public LogoutHandler jwtLogoutTokenCheckHandler() {
return new JwtLogoutTokenCheckHandler();
}
@Bean
public LogoutSuccessHandler jwtLogoutSuccessHandler() {
return new JwtLogoutSuccessHandler(tokenIssuer);
}
@Bean
public LogoutExceptionTranslationFilter logoutExceptionTranslationFilter() {
return new LogoutExceptionTranslationFilter(jwtAccessDeniedHandler());
}
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
http.formLogin(AbstractHttpConfigurer::disable);
http.httpBasic(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests(request ->
request
.requestMatchers("/api/login").permitAll()
.requestMatchers("/api/logout").hasRole("USER")
.requestMatchers("/api/token/reissue").permitAll()
.requestMatchers("/call-with-access-token", "/call-with-refresh-token", "/get-auth-info").permitAll()
.anyRequest().authenticated()
);
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.addFilterBefore(logoutExceptionTranslationFilter(), LogoutFilter.class);
http.addFilterBefore(jwtAuthorizationFilter(), LogoutExceptionTranslationFilter.class);
http.addFilterBefore(jwtAuthorizationExceptionTranslationFilter(), JwtAuthorizationFilter.class);
http.addFilterAt(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
http.logout(logout ->
logout.logoutUrl("/api/logout")
.clearAuthentication(true)
.deleteCookies(TokenResponseWriter.REFRESH_TOKEN_COOKIE)
.addLogoutHandler(jwtLogoutTokenCheckHandler())
.logoutSuccessHandler(jwtLogoutSuccessHandler())
);
http.exceptionHandling(exception ->
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint())
.accessDeniedHandler(jwtAccessDeniedHandler())
);
return http.build();
}
}
4. 토큰 재발급 API
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/token/reissue")
public class TokenReissueApiController {
private final ReissueTokenUseCase reissueTokenUseCase;
private final TokenResponseWriter tokenResponseWriter;
@PostMapping
public ResponseEntity<Void> reissueTokens(
@ExtractToken(tokenType = TokenType.REFRESH) final String refreshToken,
final HttpServletResponse response
) {
final AuthToken authToken = reissueTokenUseCase.invoke(new ReissueTokenCommand(refreshToken));
tokenResponseWriter.applyToken(response, authToken);
return ResponseEntity.noContent().build();
}
}
public record ReissueTokenCommand(
String refreshToken
) {
}
@Service
@RequiredArgsConstructor
public class ReissueTokenUseCase {
private final TokenProvider tokenProvider;
private final TokenIssuer tokenIssuer;
public AuthToken invoke(final ReissueTokenCommand command) {
final Long userId = tokenProvider.getId(command.refreshToken());
validateUserToken(userId, command.refreshToken());
return tokenIssuer.reissueAuthorityToken(userId);
}
private void validateUserToken(final Long userId, final String refreshToken) {
if (isAnonymousRefreshToken(userId, refreshToken)) {
throw CommonException.type(AuthErrorCode.INVALID_TOKEN);
}
}
private boolean isAnonymousRefreshToken(final Long userId, final String refreshToken) {
return !tokenIssuer.isUserRefreshToken(userId, refreshToken);
}
}
API 테스트
1. 로그인
AccessToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYwNzM1LCJleHAiOjE3MDMxNjc5MzV9.5A3KSubofagIjvfZ7AR4IQ7F6Qr64ac785YT3GCRrLs
RefreshToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYwNzM1LCJleHAiOjE3MDQzNzAzMzV9.hk2ZMSKZuE4zAaIqM8ZVU17WSAeGB60MUzkNc9-QJuk
2. 토큰 재발급
(1) Cookie에 RefreshToken이 없는 경우
(2) Cookie에 RefreshToken이 있는 경우
AccessToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYxMDUxLCJleHAiOjE3MDMxNjgyNTF9.3FHsE9K6Okq08Sj5awX1V5bkleinn8UOECX7piWUVeI
RefreshToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYxMDUxLCJleHAiOjE3MDQzNzA2NTF9.QB9UB46E4gf1Dv5juYMKnHEgJCeNoxOqf7gt-xOLJQU
3. 로그아웃
4. 토큰 활용 일반 API
public record Authenticated(
Long id,
String name,
List<String> roles
) {
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}
@RequiredArgsConstructor
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(Auth.class);
}
@Override
public Authenticated resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory
) {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (isNotAuthUser(authentication)) {
throw CommonException.type(AuthErrorCode.INVALID_PERMISSION);
}
return extractAuthInfo(authentication);
}
private boolean isNotAuthUser(final Authentication authentication) {
return !(authentication instanceof UsernamePasswordAuthenticationToken)
|| !(authentication.getPrincipal() instanceof UserPrincipal);
}
private Authenticated extractAuthInfo(final Authentication authentication) {
final UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
return new Authenticated(principal.id(), principal.name(), principal.roles());
}
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtractToken {
TokenType tokenType() default TokenType.ACCESS;
}
@RequiredArgsConstructor
public class ExtractTokenArgumentResolver implements HandlerMethodArgumentResolver {
private final TokenProvider tokenProvider;
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(ExtractToken.class);
}
@Override
public Object resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory
) {
final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
final ExtractToken extractToken = parameter.getParameterAnnotation(ExtractToken.class);
final String token = getToken(request, extractToken.tokenType());
tokenProvider.validateToken(token);
return token;
}
private String getToken(final HttpServletRequest request, final TokenType type) {
if (type == TokenType.ACCESS) {
return extractAccessToken(request)
.orElseThrow(() -> CommonException.type(AuthErrorCode.INVALID_PERMISSION));
}
return extractRefreshToken(request)
.orElseThrow(() -> CommonException.type(AuthErrorCode.INVALID_PERMISSION));
}
}
@RestController
@RequiredArgsConstructor
public class TestApiController {
private final TokenProvider tokenProvider;
public record AuthInfo(
Long id,
String token
) {
}
@GetMapping("/call-with-access-token")
public AuthInfo withAccessToken(
@ExtractToken(tokenType = TokenType.ACCESS) final String accessToken
) {
return new AuthInfo(tokenProvider.getId(accessToken), accessToken);
}
@GetMapping("/call-with-refresh-token")
public AuthInfo withRefreshToken(
@ExtractToken(tokenType = TokenType.REFRESH) final String refreshToken
) {
return new AuthInfo(tokenProvider.getId(refreshToken), refreshToken);
}
@GetMapping("/get-auth-info")
public Authenticated withRefreshToken(
@Auth final Authenticated authenticated
) {
return authenticated;
}
}
Access = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMjIxMjQ2LCJleHAiOjE3MDMyMjg0NDZ9.A4oPFBUhQeVIc4gfgWXDnXvwBQlVLQRvwNVzWCnmuno
Refresh = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMjIxMjQ2LCJleHAiOjE3MDQ0MzA4NDZ9.UV9QFtBEU46Q2wD858aKjvUiX_lfPE5q-UtVt_dky1E
(1) AccessToken
(2) RefreshToken
(3) AccessToken Info