[Spring] Boot 3.2.0 Spring Security + JPA + JWT

 

GitHub - sjiwon/security-jwt: Spring Security + JWT를 이용한 간단한 예제

Spring Security + JWT를 이용한 간단한 예제. Contribute to sjiwon/security-jwt development by creating an account on GitHub.

github.com

  • 깃허브 코드 부트 버전 = 3.2.0

 

Session vs Token

 

[Network] Cookie & Session & Token

HTTP의 Stateless HTTP 프로토콜은 기본적으로 요청 응답이 진행한 후 이전 통신에 대한 상태를 저장하지 않는 Stateless한 구조를 가지고 있다 HTTP가 Stateless함에 따라 다음과 같은 이점이 존재한다 상

sjiwon-dev.tistory.com

  • 자세한 내용은 위의 포스팅을 참고

 

세션 기반 인증 방식

  • 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] DelegatingFilterProxy & FilterChainProxy

본 블로그의 모든 Spring Security 포스팅은 Spring Boot 3 이상 버전을 기준으로 작성됩니다 DelegatingFilterProxy Spring Security는 Servlet Filter 레벨에서 인증 및 인가를 처리하기 위한 프레임워크이다 여기서 S

sjiwon-dev.tistory.com

 

[Spring Security] Authentication & SecurityContext

본 블로그의 모든 Spring Security 포스팅은 Spring Boot 3 이상 버전을 기준으로 작성됩니다 Authentication Spring Security에서의 Authentication은 인증 및 인가 프로세스 전역적으로 사용되는 토큰의 개념이라고

sjiwon-dev.tistory.com

 

[Spring Security] Authentication Flow - 인증 프로세스 (UsernamePassword Form-Login)

본 블로그의 모든 Spring Security 포스팅은 Spring Boot 3 이상 버전을 기준으로 작성됩니다 이 포스팅은 기본적인 Form-Login Based 인증 프로세스를 디버깅을 통해서 알아보는 포스팅으로써 기본적인 Secur

sjiwon-dev.tistory.com

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 PrincipalSpring 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();
    }
}

최종 Security 필터 흐름 (FilterChainProxy - VirtualFilterChain)

 

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. 로그인

로그인 응답 Body
AccessToken & RefreshToken 발급
Cookie (RefreshToken)
RefreshToken 저장

AccessToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYwNzM1LCJleHAiOjE3MDMxNjc5MzV9.5A3KSubofagIjvfZ7AR4IQ7F6Qr64ac785YT3GCRrLs
RefreshToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYwNzM1LCJleHAiOjE3MDQzNzAzMzV9.hk2ZMSKZuE4zAaIqM8ZVU17WSAeGB60MUzkNc9-QJuk

 

2. 토큰 재발급

(1) Cookie에 RefreshToken이 없는 경우

 

(2) Cookie에 RefreshToken이 있는 경우

AccessToken & RefreshToken 재발급
Cookie (RefreshToken)
RefreshToken 업데이트

AccessToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYxMDUxLCJleHAiOjE3MDMxNjgyNTF9.3FHsE9K6Okq08Sj5awX1V5bkleinn8UOECX7piWUVeI
RefreshToken = eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNzAzMTYxMDUxLCJleHAiOjE3MDQzNzA2NTF9.QB9UB46E4gf1Dv5juYMKnHEgJCeNoxOqf7gt-xOLJQU

 

3. 로그아웃

RefreshToken 제거

 

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

정상
변조
AccessToken X