목표
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 도중 마주칠 수 있는 예외들은 다음과 같다는걸 알 수 있다
1.
OAuth2 로그인 도중 발생하는 예외
a.
Oauth Profile 파싱 도중 NPE
2.
JWT Filter에서 발생하는 예외
a.
Jwt 관련 Exception
b.
AccessToken 만료
3.
AccessDeniedException
a.
권한 부족
b.
CSRF 관련(토큰 변형되거나 없음)
4.
AuthenticationException
a.
JWT 토큰 변형 및 훼손