지난 게시글에서 Access Token 과 Refresh Token 에 대해서 알아보았습니다. 이번 게시글에서는 토큰을 이용한 로그인에 대해 정리해보려 합니다. 전체 코드는 GITHUB에서 확인하실 수 있습니다
Access Token 을 통해 사용자를 인증할 수 있습니다. 하지만 보안 상의 이유로 Access Token 은 만료 시간이 짧게 설정되어 있으며, 만료 시간이 짧은 불편함을 해소하기 위해 Refresh Token 이 존재합니다.
클라이언트는 Refresh Token 을 서버에 전달해 Access Token 을 재발급 받습니다. Refresh Token 을 확인하고 Refresh Token 이 유효하다면 ( 만료되지 않았다면 ) Access Token 을 재발급합니다.
유효성 확인 및 Access Token 재발급을 위해 Refresh Token 을 사용자와 매핑하여 DB 에 저장시켜놓아야 하는데 이 때 Redis 를 사용하였습니다.
세션을 사용하면 메모리에 부하가 걸리게되며, 만약 여러 대의 서버가 정보 공유를 위해 메모리가 아닌 DB 에 저장을 하게 되면 속도가 느려지게 됩니다.
이를 해결하는 것이 바로 Redis 입니다. 데이터를 RAM 에 저장하므로 데이터를 영구적으로 저장할 순 없지만, 빠른 액세스 속도를 보장받을 수 있어 여러 대의 서버가 있어도 정보를 공유할 수 있습니다.
또한 Refresh Token 은 발급된 이후로 일정 시간이 지나면 만료되어야 합니다. Redis 가 아닌 다른 DB 에 저장하게 되면 주기적으로 만료된 토큰을 제거해주어야 하는데 Redis 에서는 데이터의 유효 기간을 지정할 수 있기 때문에 이런 작업이 불필요합니다.
CustomAuthenticationFilter 가 동작하기 전에 거치도록 등록한 필터입니다.
/login 요청이 오면 CustomAuthenticationFilte 로 넘깁니다. 그 외 요청이 오면 Access Token 을 검증하고 SecurityContext 에 저장합니다.
Session 방식을 사용하면 자동으로 저장되지만, Token 을 사용하면서 Session 을 사용하지 않도록 하였기 때문에 수동으로 저장이 필요하며, 이는 권한 처리에 사용됩니다.
이 필터를 거치지 않도록 URL 을 지정할 수 있으며, OncePerRequestFilter 를 상속하였기 때문에 해당 필터는 사용자 요청 한 번 당 딱 한 번만 실행됩니다.
Controller 로 가기 전, 권한을 확인하기 위해 생성하였습니다.
Controller 에서 @PreAuthorize
를 사용하는 방법도 있지만, Controller 로 가기 전에 처리하는 것이 더 바람직하다고 생각되어 Interceptor 로 구현하게 되었습니다.
로그인 요청이 들어오면 실행되는 필터입니다. 이 필터에는 나중에 알아볼 로그인 성공 필터, 로그인 실패 필터를 등록할 수 있으며, 어떤 URL 이 들어왔을 때 동작하게 할 것인지 또한 설정할 수 있습니다.
사용자 요청에서 정보를 꺼내 UserPasswordAuthenticationToken 을 만듭니다. 이때 토큰은 아직 사용자 인증이 완료되지 않은 토큰입니다.
CustomAuthenticationFilter 로부터 인증 전 AuthenticationToken 을 전달받아, Provider 에게 인증을 수행하도록 다시 전달합니다.
AuthenticationToken 에 담긴 id 를 꺼내 UserDetilsServiceImpl 의 loadUserByUsername()
에 전달합니다. 이 결과는 UserDetailsImpl 을 반환합니다.
이를 이용하여 인증이 완료된 UsernamePasswordAuthenticationToken 을 생성하여 반환합니다.
인증이 성공하면 CustomAuthenticationFilter 에 등록한 로그인 성공 핸들러가 실행됩니다. Access Token 과 Refresh Token 을 생성하며, Refresh Token 은 Redis 에 저장하고, 클라이언트에게 두 개의 토큰 모두를 전달합니다.
@RequiredArgsConstructor
public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final JwtService jwtService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// 인증 성공 후 처리되는 핸들러이기 때문에 전달받은 authentication 은 인증이 완료된 객체
Member member = ((UserDetailsImpl) authentication.getPrincipal()).getMember();
// 인증이 성공한 경우 토큰을 생성하여, 응답 헤더에 담아 클라이언트에게 전달
String accessToken = JwtUtils.generateAccessToken(member);
String refreshToken = JwtUtils.generateRefreshToken(member);
// 인증이 성공했으니 Refresh Token 을 DB( Redis )에 저장한다
jwtService.save(new RefreshToken(refreshToken, member.getId()));
// 헤더로 accessToken 전달
response.addHeader(JwtConstants.ACCESS, JwtConstants.JWT_TYPE + accessToken);
// Refresh Token 은 Cookie 에 담아서 전달하되, XSS 공격 방어를 위해 HttpOnly 를 설정한다
Cookie cookie = new Cookie(JwtConstants.REFRESH, refreshToken);
cookie.setMaxAge((int) (JwtConstants.REFRESH_EXP_TIME / 1000)); // 5분 설정
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
}
로그인이 성공했을 때 수행되는 Handler 이며, 로그인이 끝났기 때문에 이곳에서 Access Token 과 Refresh Token 을 생성하고 DB 에 저장 및 클라이언트에게 반환합니다.
토큰을 생성할 때는 JwtUtils 의 generateXXXToken()
메서드를 호출하는데 이 때 인증이 완료된 Authentication 내부의 Member 객체를 꺼내 넘겨주게 됩니다.
JwtUtils 에서 Access Token 을 생성할 때는 Claim 에 memberId 와 role 에 대한 정보를 담는 반면, RefreshToken 을 만들 때는 들어가지 않도록 하였습니다.
Redis 에 Refresh Token 을 저장할 때는 token 과 memberId 를 가진 RefreshToken 이라는 객체를 만들어 전달하였습니다.
@Repository
@RequiredArgsConstructor
public class JwtRepository {
private final RedisTemplate redisTemplate;
public RefreshToken save(RefreshToken refreshToken) {
ValueOperations valueOperations = redisTemplate.opsForValue();
valueOperations.set(refreshToken.getToken(), refreshToken.getMemberId());
redisTemplate.expire(refreshToken.getToken(), JwtConstants.REFRESH_EXP_TIME, TimeUnit.MILLISECONDS); // 5분 동안 Redis 에 저장
return refreshToken;
}
...
}
RedisTemplate 을 이용해 String, List, Set 등을 Redis 에 저장할 수 있는데, token 과 memberId 모두 String 을 사용하기 때문에 opsForValue()
를 사용하였습니다.
RedisTemplate 의 opsForXXX()
메서드들은 특정 컬렉션의 커맨드를 호출할 수 있는 기능들을 모아둔 XXXOperations 를 반환합니다.
set()
메서드를 통해 Redis 에 RefreshToken 객체 내부에 담긴 token 과 memberId 를 저장하고, expire()
메서드를 통해 token 이 저장되는 만료 시간을 지정합니다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String[] whitelist = {"/", "/signUp", "/login", "/renew", "/js/**", "/loginPage"};
// 필터를 거치지 않을 URL 을 설정하고, true 를 return 하면 바로 다음 필터를 진행하게 됨
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
String header = request.getHeader(JwtConstants.JWT_HEADER);
// 토큰이 없거나 정상적이지 않은 경우
if (header == null || !header.startsWith(JwtConstants.JWT_TYPE)) {
response.setStatus(SC_BAD_REQUEST);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
new ObjectMapper().writeValue(response.getWriter(), "Token 이 존재하지 않습니다");
return;
}
try {
// 토큰 검증
String token = JwtUtils.getTokenFromHeader(header);
DecodedJWT decodedJWT = verifyToken(token);
// SecurityContext 에 authenticationToken 저장
UsernamePasswordAuthenticationToken authenticationToken = JwtUtils.getAuthenticationToken(decodedJWT);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 이거 없으면 다음 실행 안됨!!
doFilter(request, response, filterChain);
} catch (TokenExpiredException e) {
// 토큰 만료 시 발생하는 예외
response.setStatus(SC_UNAUTHORIZED);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
new ObjectMapper().writeValue(response.getWriter(), "Access Token 이 만료되었습니다.");
} catch (Exception e) {
response.setStatus(SC_BAD_REQUEST);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
new ObjectMapper().writeValue(response.getWriter(), "올바르지 않은 Token 입니다.");
}
}
}
사용자의 요청이 들어왔을 때 먼저 실행되는 필터입니다. 이 필터를 거치지 않을 URI 를 지정하여 shouldNotFilter()
를 통해 바로 다음 필터를 진행시킬 수 있습니다.
이 필터에서는 토큰에 대한 검증을 수행하며 JwtUtils 의 verifyToken() 을 사용하여 검증을 수행합니다. 내부에서는 JWTVerifier 를 사용하는데 이는 주어진 토큰이 적절한 JWT 형식일 뿐만 아니라 서명도 일치하는지 확인하는 verify()
메서드를 가지고 있습니다.
검증이 완료되면 디코딩된 DecodedJWT 를 반환합니다. 이 토큰 내부에는 토큰 생성 시 추가한 id 와 role 을 가지고 있는데 이를 이용하여 UsernamePasswordAuthenticationToken 를 생성하고, SecurityContext 에 저장합니다.
이렇게 저장된 UsernamePasswordAuthenticationToken 를 보고 Interceptor 에서 권한 처리를 진행합니다.
public class AccessPermissionCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (request.getRequestURI().startsWith("/admin")) {
if (authentication.getAuthorities().contains(new SimpleGrantedAuthority(MemberRole.ADMIN.getValue()))) {
return true;
}
} else {
if (authentication.getAuthorities().contains(new SimpleGrantedAuthority(MemberRole.USER.getValue()))) {
return true;
}
}
response.setStatus(SC_FORBIDDEN);
response.setCharacterEncoding("utf-8");
new ObjectMapper().writeValue(response.getWriter(), "접근 권한이 없습니다.");
return false;
}
}
권한에 따른 처리를 하기 위한 Interceptor 이며, Interceptor 의 preHandle()
은 DispatcherServlet 이후, Controller 이전에 호출됩니다.
Interceptor 를 등록하고, Interceptor 를 호출할 URL 을 지정하는 것은 WebConfig 에서 진행합니다. 저는 excludePathPatterns 를 이용하여 검사하지 않을 URI 만 지정하였습니다.
권한 검사를 진행할 때는 SecurityContext 에 저장된 Authentication 객체를 사용합니다. 그 후 authentication 의 권한들 확인해 권한이 올바르다면 계속 진행하도록 하고, 그렇지 않으면 403 상태코드와 함께 접근 권한이 없다는 메세지를 작성합니다.
@RestController
@RequiredArgsConstructor
public class JwtController {
private final JwtService jwtService;
@GetMapping("/renew")
public ResponseEntity<?> renewToken(HttpServletRequest request, HttpServletResponse response) {
try {
String refreshToken = JwtUtils.getTokenFromHeader(request.getHeader(JwtConstants.JWT_HEADER));
return ResponseEntity.ok(JwtConstants.JWT_TYPE + jwtService.renewToken(refreshToken));
} catch (NoSuchElementException e) {
return ResponseEntity.badRequest().body("Refresh Token 이 만료되었습니다");
} catch (UsernameNotFoundException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
request 에서 토큰이 저장된 헤더 정보를 가져와 토큰만 추출한 다음, jwtService 의 renewToken()
메서드를 호출하여, Access Token 을 재발급 받습니다.
이 과정에서 Redis 에 Refresh Token 이 없다면 NoSuchElementException 이 발생하고 이는 해당 토큰은 만료된 것입니다.
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtRepository jwtRepository;
private final MemberRepository memberRepository;
...
public Optional<RefreshToken> findByToken(String token) {
return jwtRepository.findByToken(token);
}
public String renewToken(String refreshToken) {
// token 이 존재하는지 찾고, 존재한다면 RefreshToken 안의 memberId 를 가져와서 member 를 찾은 후 AccessToken 생성
RefreshToken token = this.findByToken(refreshToken).orElseThrow(NoSuchElementException::new);
Member member = memberRepository.findById(token.getMemberId()).orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
return JwtUtils.generateAccessToken(member);
}
}
주어진 token 을 TokenRepository 에 전달하여, Redis 에 토큰이 있는지 확인합니다. 토큰이 있다면 RefreshToken 이 반환되지만 없다면 NoSuchElementException 이 반환됩니다.
Token 을 만들기 위해서는 Member 객체가 필요하기 때문에 TokenRepository 를 통해 RefreshToken 에 존재하는 MemberId 를 통해 MemberRepository 에서 Member 를 조회합니다.
이렇게 찾아진 Member 객체를 TokenUtils 에 전달하여 토큰을 만들고, 이를 반환합니다.
@Repository
@RequiredArgsConstructor
public class JwtRepository {
private final RedisTemplate redisTemplate;
...
public Optional<RefreshToken> findByToken(String refreshToken) {
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
String userId = valueOperations.get(refreshToken);
if (Objects.isNull(userId)) {
return Optional.empty();
}
return Optional.of(new RefreshToken(refreshToken, userId));
}
}