Search
🔑

Spring Security 도입기 - JWT와 쿠키, 예외 핸들링 편

글감
Spring
인증
작성자
작성 일자
2025/03/30 10:36
상태
완료
공개여부
공개
Date
2025/04/08
생성자
작업자

목표

1.
ServletFilter에서 url별 인가 검증하기
2.
여러 개의 소셜 로그인 연동 추가 방식 간소화하기
3.
access, refresh token 도입하기 및 정보 덜어내기
4.
Security에서 발생하는 예외와 MVC단에서 발생하는 예외 별도처리하기

JWT, 그리고 Cookie…

길고 길었던 Oauth2 Login 파트가 끝났다. 근데 아직 FE로 떠나지 말아다오.. JWT를 발급하고, 이를 쿠키에 담아서 redirect 하기로 했잔슴!!!
목표: access, refresh token 도입하기 및 정보 덜어내기!
기존에는 refreshToken을 사용하지 않고 expiry를 한달정도로 지정한 후 accessToken만을 사용했었다.
추가적으로 내부에는 userPK뿐 아니라 email, intraName, blackHoledAt 등 많지만 사용하지 않는 정보들을 다수 담고 있었기 때문에 탈취가될 경우 유저를 특정 가능 + 긴 기간동안 사용자 계정에 접근 위험성을 갖고 있었다.
이를 개선하기 위해 accessToken의 만료기한을 1시간으로 줄이고, accessToken을 재발급할 refreshToken을 도입하도록 하자

Jwt 흐름과 이용방식

yml 파일에 jwt 관련 설정들을 정의하고 ConfigurationProperties 어노테이션을 활용해 이를 바인딩해준다. TokenProvider 클래스는 이를 주입받아 단순히 토큰을 생성하는 역할을 담당하고, validate 및 비즈니스로직이 추가되어야 한다면 JwtService 클래스에서 진행해주자.
백엔드에서 사용할 정보는 유저의 PK값으로 사용할 userId, 인가를 위해 사용할 roles, 로그인 경로인 provider 이정도가 전부이므로 이것만 담아서 토큰으로 만든다.
CookieService 에서는 환경이 local인지에 따라 domain, secure, httpOnly를 결정하고 response에 이를 담아줬다 refreshToken는 쿠키에 담아 보낼 때 httpOnly, secure 설정을 통해 XSS 공격 방지 ㄱㄱ
이후에 FE는 AccessToken을 Authorization Header에 담아 api 요청을 보내고 JwtAuthenticationFilter는 OncePerRequestFilter를 상속받아 모든 요청에 대해 Authorization Header에서 accessToken을 찾아 Authentcation 객체를 만들어내고, Security Context Holder에 이를 저장한다.
security는 이 Authentication을 기준으로 url별로 접근할 수 있는 인가를 지닌 유저인지 판별해낸다.
기존 UserSessionDto 커스텀 어노테이션을 Security에서 제공하는 AuthenticationPrincipal 어노테이션으로 교체해 SecurityContextHolder 내에 저장되어있는 UserInfoDto를 가져오고, 필요한 값을 가져와 사용!
캬~ 끝! 이 아니라.. accessToken과 refreshToken을 사용하기 때문에 무한재발급을 막아야한다.. 타닥타닥

재발급

JWT 흐름은 다음과 같다
1.
로그인 시 accessToken, refreshToken을 쿠키에 담아 보냄. stateless할래요 refreshToken도 드릴게욘..
2.
FE는 api 요청 시 accessToken을 헤더에 담아 요청
3.
BE는 JwtAuthenticationFilter 에서 요청마다 Header에서 accessToken을 찾고, 이를 parse, 만료 시 401 반환
4.
FE는 BE에게 refreshToken을 보내 accessToken 재발급 요청(/jwt/reissue)
a.
refreshToken도 만료라면 401, FE는 유저에게 재로그인 요구
b.
아니라면 accessToken 재발급, Refresh Token Rotation 패턴
Refresh Token Rotation 패턴이란 간단하게.. accessToken을 재발급할 때 refreshToken도 재발급하는 것이다.
// JwtService.class public TokenDto reissueToken(HttpServletRequest req, HttpServletResponse res, String refreshToken) { String accessToken = extractToken(req); if (accessToken == null || refreshToken == null) { throw ExceptionStatus.JWT_TOKEN_NOT_FOUND.asServiceException(); } try { Claims claims = tokenProvider.parseToken(refreshToken); UserInfoDto userInfoDto = UserInfoDto.fromClaims(claims); TokenDto currentTokens = new TokenDto(accessToken, refreshToken); if (userInfoDto.hasRole(AdminRole.ADMIN.name()) || userInfoDto.hasRole(AdminRole.MASTER.name())) { return reissueAdminToken(req, res, currentTokens, userInfoDto); } return reissueUserToken(req, res, currentTokens, userInfoDto); } catch (ExpiredJwtException e) { throw ExceptionStatus.EXPIRED_JWT_TOKEN.asServiceException(); } catch (JwtException e) { throw ExceptionStatus.JWT_EXCEPTION.asServiceException(); } } private TokenDto reissueUserToken(HttpServletRequest req, HttpServletResponse res, TokenDto currentTokens, UserInfoDto userInfoDto) { User user = userQueryService.getUser(userInfoDto.getUserId()); if (jwtRedisService.isUsedAccessToken(user.getId(), currentTokens.getAccessToken()) || jwtRedisService.isUsedRefreshToken(user.getId(), currentTokens.getRefreshToken())) { throw ExceptionStatus.JWT_ALREADY_USED.asServiceException(); } TokenDto tokens = createPairTokens(user.getId(), user.getRoles(), userInfoDto.getOauth()); cookieService.setPairTokenCookiesToClient(res, tokens, req.getServerName()); jwtRedisService.addUsedUserTokensToBlackList( user.getId(), currentTokens.getAccessToken(), currentTokens.getRefreshToken()); return tokens; }
Java
복사
재발급 시 기존 access, refreshToken을 redis에 저장하고 refreshToken과 accessToken을 함께 재발급하는 방식으로 구현
이 방식을 쓰면 머가 조음?
1.
RefreshToken 탈취 시 만료기한동안 AccessToken 무한 재발급하는 것 방지 가능
2.
Redis에 토큰 저장 → 서버 측에서 토큰 추적 및 관리 가능, 이전 토큰 무효화
3.
토큰 탈취 시 공격 가능 시간 최소화

JwtAuthenticationFilter, JwtExceptionFilter

모든 요청에 대해서 JwtAuthenticationFilter가 검수를 하고 있다고 말했다. 그렇다면 이 필터는 어떻게 작동하고있을까? 일단 해당 필터뿐만 어떤 필터든지 예외 발생 시, 이를 핸들링할 상위 필터까지 역주행을 한다는 것만 기억하자!
만약 아무도 핸들링 안하면요? → 계속 위로 역주행하다가 마주치는 security 기본 설정에 따라 님이 보는 의문의 403 아니면 401 에러코드죠 뭐.. 반드시 핸들링을 잘 해야만한다..
앞서 말했듯 BE는 커스텀 필터인 JwtAuthenticationFilter에 따라 모든 요청에 대해 Authorization 헤더에서 AccessToken을 찾는다.
만약 토큰이 존재하지 않는다면 바로 예외를 반환하지 않고 필터를 그대로 수행
→ 쭉 흐르다가 Security의 기본 필터인 AnonymousAuthenticationFilter에서 SecurityContextHolder가 비어있다는걸 확인, 임시인가(ROLE_ANONYMOUS) 형성 이후 url 기준으로 public하거나 anonymous도 열어둔 url이라면 접근이 가능하지만 그 이상의 특정 Role이 필요하다면 accessDeniedHandler가 호출, AccessDeniedException, 403 에러코드 반환
토큰이 존재한다면 parse를 수행하고, 예외가 발생해도 JwtAuthenticationFilter에서 예외를 처리하는 부분이 따로 적혀있지 않기 때문에 바로 예외를 반환하지 않고, 일단 앞단의 필터인 JwtExceptionFilter로 돌아가게된다.
역주행해서 도착한 JwtExceptionFilter에서는 CustomException인 SpringSecurityException을 잡아 securityExceptionHandler에게 이를 넘겨준다. 만약 핸들링하지 못한 다른 특수한 예외라면 로깅을 남기고, Security의 기본 설정에 따라 AuthenticationEntryPoint로 넘어가 401 Unauthorized를 반환하게된다
다이어그램으로 보면 다음과 같다. 재발급을 예시로 들자면 JwtAuthenticationFilter 에서 ExpiredJwtException이 발생할거고, 해당 필터에서 바로 예외를 반환하는 것이 아닌 JwtExceptionFilter로 역주행 → 해당 필터에서 예외 처리
AuthenticationEntryPoint는 인증 실패시, AccessDeniedHandler는 인증은 됐지만 권한이 부족한 경우 호출된다

Security Filter Chain의 예외와 MVC 예외 분리하기

목표: Security에서 발생하는 예외와 MVC단에서 발생하는 예외 별도처리하기 수많은 고난과 역경을 거쳐 security의 에러핸들링만이 남아있다..
Spring Security 필터는 Dispatcher Servlet보다 앞단에 위치해있다. 그러므로 당연히 Spring Security 과정에서 발생하는 예외는 Dispatcher Servlet에 도달하기 전에 발생하게되고, 기존 MVC에서 처리방식대로 ControllerAdvice 어노테이션을 사용한 ExceptionController방식으로 처리할 수 없다는 것이다.
이를 해결하기 위해서 Spring Security 도중 발생하는 예외는 SpringSecurityException이라는 커스텀 예외를 따로 만들고, 전용 핸들러를 통해 예외 처리를 하자.
Spring Security에 대한 전체적인 흐름은 1편을 복습해보도록 하고, 대략 흐름을 느꼈다면 Security 도중 마주칠 수 있는 예외들은 다음과 같다는걸 알 수 있다

Spring Security 수행 도중 발생할 수 있는 예외들

FE로 Redirect + 예외상황 전달
1.
OAuth2 로그인 도중 발생하는 예외
a.
Oauth Profile 파싱 도중 NPE
b.
잘못된 접근(연동을 하지 않았는데 메인에서 로그인 시도 등..)
c.
Oauth access 토큰만료, 변형 및 훼손
2.
AuthenticationException
a.
로그인 실패(로그인 도중 RefreshToken 만료)
ResponseBody를 만들어 반환
1.
JWT Filter에서 발생하는 예외
a.
Jwt 관련 Exception
b.
AccessToken 만료
2.
AccessDeniedException
a.
권한 부족
b.
CSRF 관련(토큰 변형되거나 없음)
구현을 진행하면서 마주했던 예외 상황들이다. 물론 제가 미처 잡지 못한 예외상황들이 더 있을 수도.. 그럴수도..
고민이 되었던 점은 역시나 또.. Oauth 도중 발생한 예외.. 🫠
Oauth 도중 예외 발생 시 → FE 에게 redirect + 예외 상황 전달
나머지 케이스는 response에 담아서 반환
1, 2번의 경우, Oauth를 진행하던 도중 발생한 예외 → redirect-uri가 BE로 잡혀있으므로, redirect를 수행하며 FE에게 예외 코드와 메세지를 전달해야한다. 하지만 redirect를 시키는거라 body에 예외 상황을 담아줄 수 없으니 에러코드를 통해 예외 케이스를 반환하기로 했다. 나머지의 경우, ResponseEntity에 MVC 예외 방식을 처리하던 것처럼 형식을 잘 맞춰서 반환
우선 에러가 발생하자마자 일단 반환하게 된다면 어디에서 예외를 다루고 있는지에 대한 혼선이 커지기 때문에, 크게 SecurityExceptionHandler를 사용할 최상위 스택을 JwtExceptionFilter, SuccessHandler, AccessDeniedHandler, AuthenticationEntryPoint로 정하고 Security 수행 도중 발생하는 예외들은 SpringSecurityException 이라는 커스텀 예외를 던지도록 구현했다.
인터페이스를 만들어 상속받아 구현하고, Manager를 만들어 형식에 맞는 exceptionHandler를 호출해주자
두 핸들러는 당연히 사전에 Bean으로 등록해줘야함 ㅇㅅㅇ
// Default, RedirectException public class SecurityDefaultExceptionHandler implements SecurityExceptionHandler { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void handle(HttpServletResponse res, ExceptionStatus status) throws IOException { res.setStatus(status.getStatusCode()); res.setCharacterEncoding("UTF-8"); res.setContentType(MediaType.APPLICATION_JSON_VALUE); Map<String, Object> errResponse = new HashMap<>(); errResponse.put("status", status.getStatusCode()); errResponse.put("message", status.getMessage()); errResponse.put("error", status.getError()); errResponse.put("timestamp", Instant.now().toString()); objectMapper.writeValue(res.getWriter(), errResponse); } } @RequiredArgsConstructor public class SecurityRedirectExceptionHandler implements SecurityExceptionHandler { private final AuthPolicyService authPolicyService; @Override public void handle(HttpServletResponse res, ExceptionStatus status) throws IOException { String uri = UriComponentsBuilder.fromHttpUrl(authPolicyService.getLoginUrl()) .queryParam("code", status.getError()) .queryParam("status", status.getStatusCode()) .queryParam("message", status.name()) .encode(StandardCharsets.UTF_8) .toUriString(); res.sendRedirect(uri); } }
Java
복사

사용 예시

Jwt 정보를 파싱해서 Authentication 객체를 만드는 도중 예외가 발생한다면 SpringSecurityExcpetion 반환
가장 상위 호출 스택이 되는 successHandler 부분에서 exceptionHandlerManager를 호출한다. 핸들러 사용 시 isRedirect를 boolean 타입으로 받아 defaultExceptionHandler를 호출할지, redirectExceptionHandler를 호출할지 결정

+) Logout

로그아웃 하는 유저의 Access, Refresh 토큰 무효화
관리중인 BlackList(Redis)에 저장
Authentication, Session 등 무효화
SecurityConfig 부분에서 요 부분에 해당
1.
FE → BE /v5/auth/logout api 호출
2.
이 때 해당 api를 컨트롤러로 작성해둬도, 내부의 메서드는 실행되지 않는다. logoutUrl로 명시해뒀기 때문에 오로지 security 메서드의 logout을 수행할 트리거로만 사용됨
3.
invalidateHttpSession(세션 무효화지만 세션 쓰는 곳이 없긴함..), clearAuthentication(JWT로 만들어둔 Authentication 비우기)
4.
logoutSuccessHandler 수행
LogoutSuccessHandler 에서는 이미 authentication, session 처리가 끝난 상태로 들어오기 때문에 각각 admin, user logout을 호출해
1.
인가에 대한 validate(user임? admin임?)
2.
JWT를 Redis에 저장
3.
cookie 무효화
를 진행한 후 200OK를 담아 반환한다

후기

너무 길고 긴 여정의.. Security 도입기가 끝..?이 났다
OAuth2 및 JWT 기반 인증을 구현하며 인증/인가 흐름뿐 아니라 HTTP 통신구조 전반에 대한 이해도를 크게 높힐 수 있었던 경험이었다. 특히 인증 과정에서 발생하는 수많은(..ㅠㅠ) 예외 상황들을 처리하며 FE BE 사이에 어떻게 예외를 명확하게 전달할지와 책임분리에 대해 고민하고, 표준적인 에러 응답 구조의 중요성을 크게 체감했다.
이번 작업을 통해 기존에 추상적으로 알고있던 인증 플로우 및 에러 핸들링에 대해 정말 원없이 다뤄볼 수 있었고, redis를 사용한 refresh token 관리 및 multi-provider OAuth 연동을 하면서 인가 부여 및 검증 진행방향에 대한 로직적인 고민도 많았다.
기능 구현 자체는 빠르게 끝났던 것 같은데.. BE는 혼자 작업하다보니 리팩토링이나 보안적 측면에서 csrf를 어떻게 처리할지, 정책적으로 유저에게 기능을 어디까지 허용해야할지를 고민하면서 방향이 수정되는 과정에서 작업이 지체되어 다소 아쉬웠다.
함께 작업해준 FE의 춘식이, 춘순이, 지킴님께 스페셜 땡스를 드리며 추후에는 7기 BE 분들이 resolver를 커스터마이징하거나, Role에 대해 명확한 테이블 분리를 하면 더 나은 구조가 될 것이다.