Spring/Spring Security

스프링 시큐리티 4. 로그인 정보 갱신(RefreshToken 구현)

Recfli 2024. 3. 25. 18:16

[ 리프레시 토큰과 구현 로직 설명 ]

 3편에서 JWT 토큰을 이용한 로그인 이후 발급되는 AccessToken를 유저에게 주었다. JWT 토큰의 특성으로 인해 DB 없이 상태유지를 하지 않고도 서명 시스템으로 인해 위조 여부를 판단할 수 있었다. 또한, JWT 토큰 내부 정보로 사용자의 아이디와 권한 정보를 이용해 불필요한 인증과정을 없앴다. 문제는 AccessToken은 강력한 권한을 가지고 있다. 서버 내에 저장되어있지 않기 때문에 AccessToken이 빼았겨버리면 만료시간까지 그 유저의 모든 권한을 공격자가 가질 수 있다. 그렇다고 보안을 위해 만료시간을 줄여버리면 사용자가 로그인을 매번해야 하는 불편함이 있다. 보통 이 문제를 해결하기 위해 RefreshToken과 Redis를 이용한 구현을 한다는 내용은 들었었다. 일단 Redis 사용방법을 모르니 DB로 그 역할을 대신하도록 하고 RefreshToken을 어떻게 사용할지 고민하였다. 

 

 고민 내용은 왜 JWT 토큰을 저장할까였다. JWT 토큰을 사용하는 것은 최대한 구현에 있어 서버 내부의 부하를 막고 무상태성을 유지하기 위해서인데, 저장은 그 목적과 다르기 때문이다. 그래서 RefreshToken이 만료된 상황과 갈취된 상황을 생각해보았다. 어떤 글에서는 UUID를 RefreshToken으로 놓고 DB에 유저 정보와 함께 저장하는 방법을 얘기했었다. 이 때 든 생각은 UUID니까 무차별 대입에는 경우의 수가 많으니 강할 것이라 생각했지만 만료시간을 검증할 방법이 문제였다. 그러니 JWT 토큰을 사용하는 건 유지해야 한다 생각했다. 갈취되는 상황에서는 만료 시간이 남은 경우에는 어쩔 수 없다고 생각했다. 그러면 안전 장치를 줄 방법이 없을까 고민했고 갈취된 토큰이라도 사용자가 새로 다시 로그인을 하거나 로그아웃을 하는 경우 DB 내의 RefreshToken과 내용이 일치하는지 혹은 블랙리스트에 등록되었는지를 확인하기로 하였다. 이를 구현하기 위해서는 RefreshToken과 관련된 로직에는 토큰의 상태를 유지하지 않는 이상 불가능하다는 결론이 나왔다.그렇게 로그인, 로그아웃, RefreshToken 만료 상황에 따라 상황을 나누어 로직의 그림을 그리고 시작했다.

 

 로그인 상황에서는 과거엔 AccessToken만을 발급했지만 이제는 RefreshToken도 함께 발급한다. 이때 RefreshToken은 Username과 만료시간만 저장이 되어있고 서명을 하여 외부에서 조작을 방지하는 용으로 사용된다. 로그인이 되면 RefreshToken만 DB에 저장이 되는데 이 때 DB에 있던 RefreshToken값을 바꾸고 활성화 시킨다. 그렇게 하면 토큰이 갈취당하더라도 원래 사용자가 로그인을 하면 더이상 그 토큰은 만료시간이 지나지 않았지만 최신이 아니므로 사용이 불가능하다. 

 

 로그아웃은 프론트엔드의 도움을 받아야 한다. 저장한 AccessToken을 지워버리고 RefreshToken은 블랙리스트에 등록(비활성화)한다. 그러면 이 때도 RefreshToken이 갈취된 경우, 비활성화가 되었기 때문에 그 토큰으로의 재발급은 무효화된다.

 재발급은 상황 별로 구분을 해봤다. 로그아웃이 된 경우, 블랙리스트가 활성화되어있으므로 요청을 거부한다. 또한 로그인 때 새로 지급된 RefreshToken이 있음에도 이전 로그인 때의 토큰을 사용하거나 재발급된 경우 이전에 발급된 RefreshToken을 사용하는 경우 재발급 요청을 거부한다. 그렇기 때문에 동일한 재발급 Post 요청을 두 번 보냈을 때 첫번째와 두번째는 응답이 다르다. RefreshToken이 만료된 경우도 요청을 거부한다. 이 모든 상황을 다 뚫고 DB의 내용과 동일하고 유효시간이 만료되지 않고 조작되지 않은 토큰만이 재발급 요청을 승인해서 토큰 두 개를 재발급해서 준다.


[ 코드 작성 ]

 토큰을 우선 DB에 저장해야하므로 아래처럼 엔티티를 구성하였다. 메모리나 Redis를 사용하던 다른 방법으로 사용해도 무관하다. 단지, 개인적으로 DB에 했다.

 

Session.java

@Entity
@NoArgsConstructor
@Getter
public class Session {

    @Id @GeneratedValue
    private Long id;

    private String session;
    private String username;
    private Boolean blackStatus;

    public Session(String session, String username) {
        this.session = session;
        this.username = username;
        blackStatus = Boolean.FALSE;
    }

    public void reissueSession(String session, String username){
        this.session = session;
        this.username = username;
        blackStatus = Boolean.FALSE;
    }

    public void blackSession(){
        blackStatus = Boolean.TRUE;
    }
}

 

 그 다음엔 토큰을 생성할 때에 RefreshToken을 추가적으로 생성해야 한다. 그런데 토큰을 헤더에 넘기는데 Authorization 헤더에 ,로 구분하여 넘겨도 되겠지만 AccessToken과 RefreshToken 둘 다를 넘기기에는 헤더보단 바디에 넘기는 게 더 좋을 것 같다는 생각이 들었다. 이 때 기존 방식을 그대로 사용하는 경우 헤더에서 AccessToken을 넘기는데 헤더로 값을 넘겨버리면 Header에 흔적이 남는다. 그래서 바디랑 헤더 둘 다 값이 나가므로 혼선의 여지가 있을 것 같다는 생각이 들었다. 

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (null != authentication) {
            SecretKey key = Keys.hmacShaKeyFor(SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));
            String jwt = 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() + 30000000))
                    .signWith(key).compact();
            response.setHeader(SecurityConstants.JWT_HEADER, jwt);
        }

        filterChain.doFilter(request, response);
    }

 

 그래서 값을 어떻게 넘길지 고민하다가 이전에 쓰레드 로컬을 배웠던 기억이 나서 이번에 한 번 활용해보는 과정을 가졌다. 어차피 WAS에서 받은 요청은 쓰레드 풀에서 택한 쓰레드를 처음부터 끝까지 가지고 간다. 그리고 필터 이후 컨트롤러로 넘어가 응답을 내보내니, 토큰 홀더를 만들어 필터에서 토큰 홀더에 값을 채우고 컨트롤러에서 홀더를 비우면 될 것 같다 생각했다.

 

TokenDto.java

@Data
public class TokenDto {
    String AccessToken;
    String RefreshToken;

    public TokenDto(String accessToken, String refreshToken) {
        AccessToken = accessToken;
        RefreshToken = refreshToken;
    }
}

 

TokenManager.java

@Component
public class TokenManager {

    private static ThreadLocal<TokenDto> tokenDtoThreadLocal = new ThreadLocal<>();

    public void setToken(String accessToken, String refreshToken){
        TokenDto tokenDto = new TokenDto(accessToken, refreshToken);
        tokenDtoThreadLocal.set(tokenDto);
    }

    public TokenDto getToken(){
        return tokenDtoThreadLocal.get();
    }

    public void removeToken(){
        tokenDtoThreadLocal.remove();
    }
}

 

 이렇게 구현을 하면 사용 방법은 토큰을 만들고 토큰을 tokenManager에 저장을 한다. 그럼 해당 내용이 컨트롤러까지 사용이 가능하다. 아래의 내용은 RefreshToken과 tokenManager에 내용이 추가된 것 밖에 없다.

 

JWTTokenGeneratorFilter.java

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                FilterChain filterChain) throws ServletException, IOException {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (null != authentication) {
        SecretKey key = Keys.hmacShaKeyFor(SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));

        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();

        tokenManager.setToken(accessToken, refreshToken);
    }

    filterChain.doFilter(request, response);
}

 

 그러면 로그인 창에서는 기존 RefreshToken을 DB에서 찾아 지워버리고 새로 발급된 RefreshToken 정보를 넣은 뒤, 활성화 시키고 쓰레드 로컬을  지워주기만 하면 된다.

 

로그인 API

@RequestMapping("/user")
public TokenDto getUserDetailsAfterLogin(Authentication authentication) {

    TokenDto token = tokenManager.getToken();

    Session findSession = sessionService.findSession(authentication.getName());
    // Optional을 사용해보는 걸 생각해보자.
    // 로그인 시 내용 제거 후 새 내용 추가
    if(findSession != null){
        sessionService.deleteSession(findSession);
    }
    sessionService.makeSessionAndSave(token.getRefreshToken(), authentication.getName());

    tokenManager.removeToken(); // 쓰레드 로컬 값을 반드시 지울 것
    return token;
}

 

 그러면 로그인 과정은 되었으니 재발급을 구현해야 한다. 재발급은 "/reissue" API로 POST요청으로 작성을 했는데 이 때도 Authorization에 RefreshToken이 담겨 날아온다. 그리고 개인적으로 컨트롤러에서 이 요청 및 검증 과정을 처리하면 좋겠지만 분리하는 걸 선호한다. 왜냐하면 이전에 프로젝트를 해봤을 때 컨트롤러에서 검증까지 너무 많은 권한이 있으면 추후 API가 늘어날 때마다 인증 정보가 필요한 경우 코드를 고칠 곳이 너무 많았었다. 그래서 이번에 새로 공부를 하며 JWTTokenValidatorFilter를 만들어 검사를 하게 만든 것이다. 그러니 재발급의 경우도 필터 내에서 검증이 완료되도록 하였다.

 

 일단 일반 API에서 인증 정보를 얻기 위한 경우는 "/reissue"가 아닌 경우에 사용할테니 URI 값을 통해 구분하였다. 그리고 기존 로직은 전혀 건드리지 않았다. 단지 JWT 토큰 검증 부분이 긴데 반복돼서 바꿨을 뿐이다. 재발급의 경우에는  토큰을 검증하는 과정에서 만료된 토큰 및 조작된 토큰에 대해 요청을 거부할 수 있다.  그리고 내부 정보가 userEmail 정보로 DB에서 정보를 찾아 최신 토큰이 아닌 경우,로그아웃하여 블랙리스트인 토큰의 요청을 거부하게 만들었다. 모든 거부 상황이 통과되면 인증 정보를 SecurityContextHolder에 저장했다. 여기에서 한 추가적인 고민을 보고 싶다면 다음 글을 참고하기를 바란다.

 

JWTTokenValidationFilter.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 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);
}

 

 이 다음이 고민이었다. 필터가 Repository나 Service 계층이 아니어서 JPA 변경 감지가 동작하지 않았고 이 문제를 해결하기 위해 고민을 했었다. 그래서 검증에서 토큰 생성까지 하는 과정을 분리해서 검증 필터에서 딱 검증의 역할만 하게 만들었다. 이것까진 좋은데, 토큰의 재발급이 JWTTokenGenarateFilter에서도 되고 컨트롤러에서도 되니 이게 제대로 한 게 맞나 싶었다. 일관성이 없다고 해야할까? 아무튼 컨트롤러에 재발급 로직을 넣었고 TokenDto로 반환하게 만들었다. Post 요청으로 만든 이유는 멱등이 아니고 동일한 재발급을 여러 번 요청하는 경우 다른 결과가 나올 수 있기 때문에 멱등하지 않을 수 있음을 알리기 위해 Post 요청으로 설계를 했다.

 

재발급 API

@PostMapping("/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);
}

 

 그리고 마지막으로 로그아웃은 AccessToken 정보를 받아 인증 정보를 놓기 때문에 그 정보를 이용해 Session을 찾아 블랙리스트로 등록시켰다. 이 부분에서 웹 브라우저에서 저장된 AccessToken은 백엔드가 할 수 있는 게 없기 때문에 일단 이렇게만 만들었고 아마 협업을 한다면 지워달라는 요청을 했을 것 같아서 위에 그림 설명에서 프론트엔드에게 요청이라고 남겨놓은 것이다. 

@PostMapping("/login/logout")
public String userLogout(){
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    String userEmail = authentication.getName();
    loginService.blackSession(userEmail);

    return "유저의 로그아웃이 성공하였습니다.";
}

 

 이상으로 스프링 시큐리티를 통한 로그인 구현은 여기서 마치도록하겠다. 개인적으로 AccessToken과 RefreshToken을 이용한 로그인 구현을 하는 사람에게 어떻게 하면 컨트롤러에서의 인가처리 요청을 따로 분리할 지를 최대한 고민해서 작성을 했던 것 같다. 이 부분이 잘 문제가 없었으면 좋겠다!

[ 참고자료 ]

https://engineerinsight.tistory.com/232