프론트 동료들과 어떤 로그인 방식을 사용할지 논의한 결과, jwt 구현 경험이 없어 jwt 방식을 택했습니다.
주요 로직은 JwtFilter, JwtService, JsonLoginService class
SecurityConfig class의 FilterChain 부분입니다. 기존에 사용하던 websecurityconfigureradapter class는 deprecated 되어 필요한 메소드를 @Bean으로 등록해 사용했습니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.cors()
.and()
.csrf().disable()
.headers().frameOptions().disable()
.and()
.httpBasic().disable()
.formLogin().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/", "/logout", "/login/oauth2/code/**", "/user/signup", "/login",
"/swagger-ui/**", "/v3/**", // /v3/api~ : swagger 리소스 url
"/product/get/**", "/brand/get/**" // 서비스 기능 )
.permitAll()
.anyRequest().authenticated() // denyAll() 옵션을 주면 토큰이 있어도 막아버림
.and()
// OAuth2 Login
.oauth2Login()
.successHandler(oAuth2LoginSuccessHandler)
.failureHandler(oAuth2LoginFailureHandler)
.userInfoEndpoint().userService(customOAuth2UserService);
httpSecurity.addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class);
httpSecurity.addFilterBefore(jwtFilter(), CustomUsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
jwt 인증 과정 이미지
문제 상황
- 기존 코드는 인증이 실패하면 filter를 거쳐 failureHandler를 통해 302코드를 반환했습니다. 하지만 프론트의 Axios에서는 300번대 상태 코드를 잡아낼 수 없어 access token의 만료 시간이 지나버리면 서버에서는 재로그인을 해야하는 상황이 되고 프론트는 재로그인을 해야 하는 상황이라는 것 자체를 모르게 됩니다.
해결 방법
- 302 코드는 프론트 Axios에서 잡을 수 없을 뿐더러 인증 실패를 하나의 예외로 처리해버리는 것은 좋지 않다고 생각하여 구체적인 Exception을 구현하고 상태 코드를 활용해 예외 발생 시 filter를 통해 끝까지 보내지 않고 early return으로 Response에 상태 코드를 저장하여 failureHandler에서 예외의 종류와 함께 클라이언트에 전달하도록 했습니다.
구현 코드의 주요 부분
public String createAccessToken(String email) {
return JWT.create()
.withSubject(ACCESS_TOKEN)
.withExpiresAt(Instant.now().plusMillis(accessTokenExpiration))
.withClaim(CLAIM, email)
.sign(Algorithm.HMAC512(secretKey));
}
public String createRefreshToken() {
return JWT.create()
.withSubject(REFRESH_TOKEN)
.withExpiresAt(Instant.now().plusMillis(refreshTokenExpiration))
.sign(Algorithm.HMAC512(secretKey));
}
public boolean verifyToken(String token) throws TokenExpiredException {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
return false;
}
}
public Cookie findCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
return Arrays.stream(cookies)
.filter(c -> c.getName().equals(refreshHeader))
.findAny()
.orElse(null);
}
private void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
response.setHeader(accessHeader, accessToken);
}
private void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) {
createCookie(response, refreshToken);
}
private void createCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from(refreshHeader, refreshToken)
.path("/")
.secure(true)
.sameSite("None")
.httpOnly(true)
.maxAge(60 * 60 * 24)
.domain("내 서버 도메인")
.build();
response.setHeader("Set-Cookie", cookie.toString());
}
Spring Security에게 통행증을 발급 받기 위해 UserDetailsService를 구현했습니다.
@RequiredArgsConstructor
@Service
@Slf4j
public class JsonLoginService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws EmailNotFoundException {
var user = userRepository.findByEmail(email)
.orElseThrow(() -> new EmailNotFoundException(email, ErrorCode.ENTITY_NOT_FOUND));
if (!user.getSocialType().equals(SocialType.WEB)) {
throw new EmailTypeSocialException(email, ErrorCode.EMAIL_TYPE_SOCIAL);
}
return User.builder()
.username(user.getEmail())
.password(user.getPassword())
.roles(user.getRole().name())
.build();
}
}
OncePerRequestFilter를 상속 받아 한 요청에 대해 한 번만 동작합니다.
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
private static final List<String> NO_CHECK_PATHS = Arrays.asList(
"/login", "/", "/logout",
"/login/oauth2/code/**",
"/oauth2/authorization/google",
"/user/signup", "/swagger-ui/**", "/v3/**"
);
private static final List<String> NO_CHECK_SERVICE_PATHS = Arrays.asList(
"/product/get/**",
"/brand/get/**"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException
{
String requestURI = request.getRequestURI();
Optional<String> accessToken = jwtService.getAccessToken(request);
for (String path : NO_CHECK_PATHS) {
if (new AntPathMatcher().match(path, requestURI)) {
filterChain.doFilter(request, response);
return;
}
}
for (String servicePath : NO_CHECK_SERVICE_PATHS) {
if (new AntPathMatcher().match(servicePath, requestURI) && accessToken.isEmpty()) {
filterChain.doFilter(request, response);
return;
}
}
authenticationAccessToken(request, response, filterChain);
}
private void authenticationAccessToken(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Optional<String> accessToken = jwtService.getAccessToken(request);
if (accessToken.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
log.error("Access token is Null");
return;
}
if (!jwtService.verifyToken(accessToken.get())) {
String refreshToken = jwtService.findCookie(request).getValue();
if (refreshToken == null) {
log.error("Refresh Token is Null");
return;
}
validateAndRenewAccessToken(request, response, refreshToken);
return;
}
jwtService.getEmail(accessToken.get())
.flatMap(userRepository::findByEmail)
.ifPresent(this::saveAuthentication);
filterChain.doFilter(request, response);
}
private void validateAndRenewAccessToken(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String renewRefreshToken = renewRefreshToken(user);
log.info("renewRefreshToken: {}", renewRefreshToken);
try {
jwtService.sendAccessTokenAndRefreshToken(response,
jwtService.createAccessToken(user.getEmail()),
renewRefreshToken);
} catch (IOException e) {
throw new RuntimeException(e);
}
log.info("AccessToken 재발급");
});
}
private String renewRefreshToken(User user) {
String renewRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(renewRefreshToken);
userRepository.saveAndFlush(user);
return renewRefreshToken;
}
private void saveAuthentication(User user) {
String password = user.getPassword();
if (password == null) {
password = PasswordUtil.generateRandomPassword();
}
UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(password)
.roles(user.getRole().name())
.build();
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, authoritiesMapper.mapAuthorities(userDetails.getAuthorities())
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
for (String path : NO_CHECK_PATHS) {
if (new AntPathMatcher().match(path, requestURI)) {
filterChain.doFilter(request, response);
return;
}
}
for (String servicePath : NO_CHECK_SERVICE_PATHS) {
if (new AntPathMatcher().match(servicePath, requestURI) && accessToken.isEmpty()) {
filterChain.doFilter(request, response);
return;
}
}
private void authenticationAccessToken(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Optional<String> accessToken = jwtService.getAccessToken(request);
if (accessToken.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
log.error("Access token is Null");
return;
}
if (!jwtService.verifyToken(accessToken.get())) {
String refreshToken = jwtService.findCookie(request).getValue();
if (refreshToken == null) {
log.error("Refresh Token is Null");
return;
}
validateAndRenewAccessToken(request, response, refreshToken);
return;
}
jwtService.getEmail(accessToken.get())
.flatMap(userRepository::findByEmail)
.ifPresent(this::saveAuthentication);
filterChain.doFilter(request, response);
}
private void saveAuthentication(User user) {
String password = user.getPassword();
if (password == null) {
password = PasswordUtil.generateRandomPassword();
}
UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(password)
.roles(user.getRole().name())
.build();
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, authoritiesMapper.mapAuthorities(userDetails.getAuthorities())
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
Jwt 방식을 사용하는 이유는 트래픽이 몰렸을 때, Session 방식보다 가볍기에 서버 부담이 덜하고, MSA식 아키텍쳐에서 Session 방식은 클러스터링을 해야하지만 Jwt 방식은 토큰이 정보를 담고 있어 서로 다른 서비스에서 상태를 공유하지 않아도 되는 등의 장점을 가지고 있기 때문이라고 생각합니다.
토큰 보안을 위해 access token을 재발급하면 refresh token도 재발급하는 refresho token rotation 방식을 사용했습니다. 물론 현재의 토큰 검증 방식이 부족한 점이 많지만 이를 다 커버하려 한다면 여러 로직들이 추가되고 이는 결국 Session 방식보다 가볍다는 장점이 사라지는 것이 아닐까 하는 생각을 했습니다.
현재는 DB에 refresh token을 저장하여 계속해서 DB입출력이 일어고 있지만 이후 Redis를 도입하여 refresh token을 캐쉬로 저장하도록 리팩토링 해볼 것입니다.
문제 상황2
- 서비스 페이지를 로그인 해두고 며칠이 지나 다시 접속을 했을 때 로그인이 된 상태에서 로그아웃도 되지 않고 모든 서비스가 막혀 있는 인증되지 않은 로그인 된 사용자라는 이상한 상태에 빠졌다.
해결 방법
- 브라우저에서는 access token이 만료 상태였고 서버 로그를 확인해보니 Cookie의 만료기간을 1일로 설정했었기 때문에 며칠이 지난 상태에서는 Cookie가 사라지고 Cookie를 찾는 과정에서 NullPointException을 발생시키게 되었습니다. 이는 아래와 같이 Optional 타입을 통해 처리했습니다.
public Cookie findCookie(HttpServletRequest request) {
Optional<Cookie[]> settingCookie = Optional.ofNullable(request.getCookies());
if (settingCookie.isEmpty()) {
return null;
}
Cookie[] cookies = settingCookie.get();
return Arrays.stream(cookies)
.filter(c -> c.getName().equals(refreshHeader))
.findAny()
.orElse(null);
}
private void authenticationAccessToken(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Optional<String> accessToken = jwtService.getAccessToken(request);
if (accessToken.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
log.error("Access token is null");
return;
}
if (!jwtService.verifyToken(accessToken.get())) {
Optional<Cookie> cookie = Optional.ofNullable(jwtService.findCookie(request));
if (cookie.isEmpty()) {
log.error("Refresh token is null");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
String refreshToken = cookie.get().getValue();
validateAndRenewAccessToken(request, response, refreshToken);
return;
}
jwtService.getEmail(accessToken.get())
.flatMap(userRepository::findByEmail)
.ifPresent(this::saveAuthentication);
filterChain.doFilter(request, response);
}
- 하지만 근본적인 문제는 인증되지 않은 로그인 된 사용자인 것이고 이는 Cookie의 만료시간을 refresh token과 같이 가져가는 것으로 해결되었습니다.
private void createCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from(refreshHeader, refreshToken)
.path("/")
.secure(true)
.sameSite("None")
.httpOnly(true)
.maxAge(refreshTokenExpiration)
.domain("내 도메인")
.build();
response.setHeader("Set-Cookie", cookie.toString());
}