2024. 3. 25. 02:02ㆍSpring
[ 고민거리 ]
스프링 시큐리티를 사용하면서 AccessToken을 사용했을 때는 DB 내용이 섞여들어가지 않았었다. 그런데, RefreshToken을 사용하니 필터에 DB 커넥션이 필요한 로직들이 필터 내부에 섞여 들어가기 시작했다. 또한 필터가 영속성 컨텍스트 범위 바깥이라 필터 내부에서 DB에서 찾아온 객체 내부의 값을 변경해도 변경이 반영이 되지 않았었다. 그래서 아래와 같은 코드가 나왔다.
JWTTokenValidation.java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String jwt = request.getHeader(SecurityConstants.JWT_HEADER);
SecretKey key = Keys.hmacShaKeyFor(
SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));
if (null != jwt && !request.getRequestURI().equals("/reissue")) {
try {
Claims claims = getClaims(key, jwt);
String username = String.valueOf(claims.get("username"));
String authorities = (String) claims.get("authorities");
Authentication auth = new UsernamePasswordAuthenticationToken(username, null,
AuthorityUtils.commaSeparatedStringToAuthorityList(authorities));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
throw new BadCredentialsException("Invalid Token received!");
}
} else if(null != jwt && request.getRequestURI().equals("/reissue")){
try {
/**
* 만료 시간 지났을 시 BadCredentialException
* -> 프엔에게 해당 에러코드시 login 화면으로 가라 혹은 리다이렉션 헤더 채워주면 됨
*/
Claims claims = getClaims(key, jwt);
String session = String.valueOf(claims.get("session"));
String username = String.valueOf(claims.get("username"));
Session findSession = sessionRepository.findByUsername(username);
// 블랙리스트 등록의 경우는 토큰 재발급 요청
if(findSession.getBlackStatus() == Boolean.TRUE){
throw new BadCredentialsException("Please Login Again");
}
// 최신 발급 내용과 다른 경우도 토큰 재발급 요청
if(!findSession.getSession().equals(session)){
throw new BadCredentialsException("Please Login Again");
}
// 로그인 상태인데 리프레시 토큰 만료 안되고 엑세스 토큰만 만료됐을시 재발급
// 바로 get해도 되는 이유는 없으면 BadCredential이고 있으면 원래 로직대로 돼서 상관없음.
Member findMember = memberRepository.findByUserEmailWithAuthorities(username).get();
String accessToken = Jwts.builder().issuer("Recfli").subject("JWT Token")
.claim("username", findMember.getUsername())
.claim("authorities", populateAuthorities(findMember.getAuthorities()))
.issuedAt(new Date())
.expiration(new Date((new Date()).getTime() + 1800*1000))
.signWith(key).compact();
String uuid = UUID.randomUUID().toString();
String refreshToken = Jwts.builder()
.claim("username", findMember.getUserEmail())
.claim("session", uuid)
.issuedAt(new Date())
.expiration(new Date((new Date()).getTime() + 3000000))
.signWith(key).compact();
sessionRepository.delete(findSession);
Session newSession = new Session(uuid, username);
sessionRepository.save(newSession);
// 3600*1000*24*7
tokenManager.setToken(accessToken, refreshToken);
} catch (Exception e) {
throw new BadCredentialsException("Invalid Token received!", e);
}
}
filterChain.doFilter(request, response);
}
위의 코드는 Authorization 헤더가 들어왔을 때 URI가 토큰 재발급이냐 아니냐에 따라 RefreshToken이 들어오냐 AccessToken이 들어오냐를 구분하고 처리로직을 작성했다. AccessToken이 들어오는 경우는 당연히 AccessToken이 유효한지만 검사를 하면 내부에 있는 값들을 사용해서 인가처리를 할 수 있기에 불필요한 데이터 베이스를 거칠 필요가 없다. 그리고 그렇게 사용하지 않으면 JWT 토큰을 사용해서 인증과 인가를 구현하는 의미가 없다.
그런데 RefreshToken이 들어오는 경우는 좀 다르다. RefreshToken은 회원의 로그인 여부를 파악하는데 사용된다. AccessToken은 짧은 시간동안 부여하기 때문에 계정에 관해 상태유지가 없다. 하지만 RefreshToken은 해당 회원의 로그인이 유효한지 아닌지를 확인한다. 회원이 로그아웃을 한 경우에 RefreshToken을 블랙리스트에 등록해 사용이 불가능하게 만들어야한다. 또한 재발급을 여러 번 받았다면 이전에 발급 받은 RefereshToken을 통해서는 재발급이 불가능하게 만들어야한다. 이를 구현하기 위해서는 반드시 메모리에 저장을 하건 DB에 저장을 해야한다. 개인적으로 DB를 사용하거나 Redis를 사용하는 게 동시성 제어에 관한 고민을 덜해도 되서 좋다고 생각한다. 그런데, Redis 사용법을 몰라서 DB로 구현을 일단 했다.
개인적으로 위의 코드 중에서 JWTValidation 부분이 토큰의 유효성을 판단하는 역할만을 가져야 한다고 생각한다. 하지만, 위의 코드는 지금 재발급까지 역할을 다하고 있다. 그러니 아래처럼 DB에 관한 의존관계가 늘어나고 있어서 복잡해지기 시작하고 찾아온 Session 객체가 JPA 변경감지가 동작하지 않아 한참동안 문제를 찾고 있었던 것이다. 그러니 이를 분리해야 한다고 생각했다.
private final MemberRepository memberRepository;
private final SessionRepository sessionRepository;
분리를 하기 전에 아래의 로직은 DB에 의존적이지만 찾기만 할 뿐 이후에 추가적인 로직이 없고 유효한 요청인지를 확인하는데 적합하다고 생각했다.
Session findSession = sessionRepository.findByUsername(username);
// 블랙리스트 등록의 경우는 토큰 재발급 요청
if(findSession.getBlackStatus() == Boolean.TRUE){
throw new BadCredentialsException("Please Login Again");
}
// 최신 발급 내용과 다른 경우도 토큰 재발급 요청
if(!findSession.getSession().equals(session)){
throw new BadCredentialsException("Please Login Again");
}
또한 아래의 코드는 Validation에 추가되어야 한다고 생각했다. Authentication 정보가 SecurityContextHolder에 있어야지 Authentication을 컨트롤러 계층에서 받아 토큰을 만들 수 있기 때문이다.
// 로그인 상태인데 리프레시 토큰 만료 안되고 엑세스 토큰만 만료됐을시 재발급
// 바로 get해도 되는 이유는 없으면 BadCredential이고 있으면 원래 로직대로 돼서 상관없음.
Member findMember = memberRepository.findByUserEmailWithAuthorities(username).get();
Authentication auth = new UsernamePasswordAuthenticationToken(username, null,
AuthorityUtils.commaSeparatedStringToAuthorityList(populateAuthorities(findMember.getAuthorities())));
SecurityContextHolder.getContext().setAuthentication(auth);
JWTToken의 유효성을 검증하는 필터 부분은 결과적으로 아래처럼 코드가 바뀌었다. DB 로직이 어쩔 수 없이 두개가 들어가는 건 기분이 나쁘지만 해결방법이 생각이 안나고 인가 토큰 검증과 인증 정보를 가져온다는 역할에는 맞기에 이렇게 구현하였다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String jwt = request.getHeader(SecurityConstants.JWT_HEADER);
SecretKey key = Keys.hmacShaKeyFor(
SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));
if (null != jwt && !request.getRequestURI().equals("/reissue")) {
try {
Claims claims = getClaims(key, jwt);
String username = String.valueOf(claims.get("username"));
String authorities = (String) claims.get("authorities");
Authentication auth = new UsernamePasswordAuthenticationToken(username, null,
AuthorityUtils.commaSeparatedStringToAuthorityList(authorities));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
throw new BadCredentialsException("Invalid Token received!");
}
} else if(null != jwt && request.getRequestURI().equals("/reissue")){
try {
/**
* 만료 시간 지났을 시 BadCredentialException
* -> 프엔에게 해당 에러코드시 login 화면으로 가라 혹은 리다이렉션 헤더 채워주면 됨
*/
Claims claims = getClaims(key, jwt);
String username = String.valueOf(claims.get("username"));
Session findSession = sessionRepository.findByUsername(username);
// 블랙리스트 등록의 경우는 토큰 재발급 요청
if(findSession.getBlackStatus() == Boolean.TRUE){
throw new BadCredentialsException("Please Login Again");
}
// 최신 발급 내용과 다른 경우도 토큰 재발급 요청
if(!findSession.getSession().equals(jwt)){
throw new BadCredentialsException("Please Login Again");
}
// 로그인 상태인데 리프레시 토큰 만료 안되고 엑세스 토큰만 만료됐을시 재발급
// 바로 get해도 되는 이유는 없으면 BadCredential이고 있으면 원래 로직대로 돼서 상관없음.
Member findMember = memberRepository.findByUserEmailWithAuthorities(username).get();
Authentication auth = new UsernamePasswordAuthenticationToken(username, null,
AuthorityUtils.commaSeparatedStringToAuthorityList(populateAuthorities(findMember.getAuthorities())));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
throw new BadCredentialsException("Invalid Token received!", e);
}
}
filterChain.doFilter(request, response);
}
그러면 컨트롤러에서 토큰 두 개를 새로 재발급하고 DB에 재발급 내역을 반영만 해주면 된다. 이전에 필터에서는 영속성 컨텍스트의 생명주기를 벗어난 공간이었지만 이제는 서비스 계층의 메서드에서 DB 내에 있는 세션 정보를 조회하고 바꾸기 때문에 변경이 아주 잘 일어난다. 이전처럼 굳이 불필요하게 DB 내용을 지우고 새로운 객체를 만들어서 안해도 된다! 다음처럼 재발급 부분 컨트롤러 코드를 작성하여 문제를 해결했다.
재발급 RestAPI
@GetMapping("/reissue")
public TokenDto reIssueToken(){
SecretKey key = Keys.hmacShaKeyFor(SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String accessToken = Jwts.builder().issuer("Recfli").subject("JWT Token")
.claim("username", authentication.getName())
.claim("authorities", populateAuthorities(authentication.getAuthorities()))
.issuedAt(new Date())
.expiration(new Date((new Date()).getTime() + 1800*1000))
.signWith(key).compact();
String uuid = UUID.randomUUID().toString();
String refreshToken = Jwts.builder()
.claim("username", authentication.getName())
.claim("session", uuid)
.issuedAt(new Date())
.expiration(new Date((new Date()).getTime() + 3600*1000*24*7))
.signWith(key).compact();
sessionService.sessionReissue(refreshToken, authentication.getName());
return new TokenDto(accessToken, refreshToken);
}
'Spring' 카테고리의 다른 글
JPA, Json 사용 필수 메서드와 주의사항 (0) | 2024.03.27 |
---|---|
스프링의 Layered Architecture와 폴더 구조 (1) | 2024.03.27 |
스프링 MVC 1편 - 서블릿과 서블릿 컨테이너 (0) | 2024.02.02 |
스프링 MVC 1편 - Web Server와 WAS (0) | 2024.02.02 |
스프링 핵심 원리 기본편 - 빈 스코프 (0) | 2024.01.26 |