아래와 같이 로그인 요청 시 클라이언트로 부터 받은 이메일과 비밀번호가 DB에 저장된 사용자 정보와 일치하는지 비교하고 올바른 사용자면 Access Token(이하 ATK)과 Refresh Token(이하 RTK)을 생성해 클라이언트에 전달해주고 토큰 노출에 따른 위험부담을 모두 사용자가 갖도록 인증처리과정을 만들었다.
너무나도 무책임한 방식이 아닐까? 아무리 서버의 부담을 줄일 수 있다고는 하지만 최소한은 안전고리를 만들어보고 싶다는 생각이 들었다.
(카드 잃어버린건 고객 잘못이라도 카드사가 최소한 카드정지는 도와줘야지...)
// 로그인 인증 요청을 처리하는 Custom Security Filter 구현
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { // 디폴트 Security Filter인 UsernamePasswordAuthenticationFilter를 확장해서 구현
private final AuthenticationManager authenticationManager;
// DI 받은 AuthenticationManager는 로그인 인증 정보(Username/Password)를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부를 판단
private final JwtTokenizer jwtTokenizer;
// DI 받은 JwtTokenizer는 클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
@SneakyThrows // 예외처리 무시
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { // 인증을 시도하는 로직을 구현
ObjectMapper objectMapper = new ObjectMapper();
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); // 역직렬화(Deserialization)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword()); // Username과 Password 정보를 포함한 UsernamePasswordAuthenticationToken 생성
return authenticationManager.authenticate(authenticationToken); // UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리를 위임
}
// 인증에 성공할 경우 (Spring Security에서 자동으로) 호출되는 메서드
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws ServletException, IOException {
Member member = (Member) authResult.getPrincipal(); // 인증 정보로 Member 엔티티 객체 만들기
String accessToken = delegateAccessToken(member); // Access Token 생성
String refreshToken = delegateRefreshToken(member); // Refresh Token 생성
response.setHeader("Authorization", "WishJWT " + accessToken); // 클리이언트한테 Access Token 보내주기 (이후에 클라이언트 측에서 백엔드 애플리케이션 측에 요청을 보낼 때마다 request header에 추가해서 클라이언트 측의 자격을 증명하는데 사용)
response.setHeader("Refresh", "WishJWT " + refreshToken); // 클리이언트한테 Refresh Token 보내주기
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult); // MemberAuthenticationSuccessHandler의 onAuthenticationSuccess() 메서드 호출
// 인증 성공 후에 할 동작을 설정해둔걸 불러와서 수행
// 인증 실패 할 경우 MemberAuthenticationFailureHandler클래스의 onAuthenticationFailure() 메서드는 코드 추가 없이도 알아서 호출된다.
}
// Access Token을 생성하는 구체적인 로직
private String delegateAccessToken(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("email", member.getEmail());
claims.put("roles", member.getRoles());
claims.put("nickName", member.getNickName());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
// Refresh Token을 생성하는 구체적인 로직
private String delegateRefreshToken(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
@RestController
@RequestMapping("/refresh")
@AllArgsConstructor
public class RefreshController {
private final JwtTokenizer jwtTokenizer;
private final MemberRepository memberRepository;
@PostMapping
public ResponseEntity<String> refreshAccessToken(HttpServletRequest request) { // 리프레쉬 토큰 받으면 엑세스 토큰 재발급
String refreshTokenHeader = request.getHeader("Refresh");
if (refreshTokenHeader != null && refreshTokenHeader.startsWith("Bearer ")) {
String refreshToken = refreshTokenHeader.substring(7);
try {
Jws<Claims> claims = jwtTokenizer.getClaims(refreshToken, jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()));
String email = claims.getBody().getSubject();
Optional<Member> optionalMember = memberRepository.findByEmail(email);
if (optionalMember.isPresent()) {
Member member = optionalMember.get();
String accessToken = delegateAccessToken(member);
return ResponseEntity.ok().header("Authorization", "Bearer " + accessToken).body("Access token refreshed");
} else {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid member email");
}
} catch (JwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid refresh token");
}
} else {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Missing refresh token");
}
}
private String delegateAccessToken(Member member) { // 코드의 중복...찝찝하다.
Map<String, Object> claims = new HashMap<>();
claims.put("email", member.getEmail());
claims.put("roles", member.getRoles());
claims.put("nickName", member.getNickName());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
}
아주 기본적인 방법이다. 이부분은 기본이고 ATK를 재발급할 수 있는 RTK에 대한 문제점이 남는 아쉬운 방법이다. RTK의 유효시간을 지나치게 짧게 만들면 본래 목적(ATK를 쉽게 재발급)을 잃어버리기 때문에 적절하지 못한 방법이다. 따라서 여전히 RTK를 탈취당하면 너무 위험하다.
발급하는 ATK를 서버의 메모리나 DB에 저장해 관리하며 ATK를 검증하거나 특정상황(로그아웃 등)에 강제로 만료시켜버려서 보안을 더 향상시킬 수 있을 것 같다.
하지만 이 방법은 결국 서버의 부담을 증가시켜 차라리 세션인증 방식을 사용하는게 더 좋아보인다.
서버의 메모리나 DB에 RTK를 저장해 관리한다.
이렇게 하면 매번 요청마다 검증하는게 아니라 ATK가 만료될 때만 RTK 검증과정을 진행하기 때문에 세션인증 방식보다 서버의 부담도 덜하며 토큰인증 방식의 장점인 사용자 편의성도 그대로 챙길 수 있을 것 같다.
그렇다면 어디에 저장하는게 좋을까?
속도
간단한 구현
영속성
확장성
메모리 관리
이 방법은 위와 같은 단점들 때문에 적절한 방법은 아니다.
(이럴거면 세션인증방식을 쓰는게 더 좋아보인다.)
영속성과 무결성에 더 중점을 두는 경우 Redis보다 MySQL이 더 좋은 성택지가 될 수 있지만 Redis에 저장할 경우 최악의 경우의 수를 고려해봐도 전체 로그아웃이 될 뿐이다. 때문에 Redis의 장점(속도와 확장성)으로 얻을 수 있는 이득이 더 많다고 판단된다.
기존(기본) 방식에서는 발급시에 설정해둔 만료기간에 따른 만료 외에는 서버측에서 RTK에 대한 추가 검증이나 강제 만료를 못하기 때문에 RTK에 대한 드리블을 서버에서 할 수 있도록 하기 위해 RTK를 Redis에 저장해봐야겠다.