session, token, cookie / 기능 구현 (로그인/로그아웃/토큰 발급/재발급)

여름빛새·2023년 7월 11일

dev-diary

목록 보기
6/8

1. 들어가기에 앞서

1.1. HTTP

브라우저와 서버가 통신할 수 있도록 만들어주는 여러 프로토콜 가운데 한 종류로 웹 브라우저와 웹 서버 사이에 HTML(웹 문서를 만들기 위한 언어) 문서를 주고받는데 쓰이는 통신 프로토콜입니다.


HTTP에서는 클라이언트가 서버에 요청 메세지를 보내고 서버는 클라이언트의 요청에 대한 응답을 반환합니다. 연결 상태를 유지하지 않는 비연결성 프로토콜이며, 요청과 응답(request, response) 방식으로 동작합니다.

연결 상태를 유지하지 않는다는 말은 서버는 응답 메시지를 반환한 후에 클라이언트의 상태를 저장하지 않는다는 것입니다. 때문에 HTTP 프로토콜은 상태가 없는 프로토콜, 무상태성(stateless)라고도 불립니다.

여기서 상태가 없다라는 말은 데이터를 주고받기 위한 각각의 데이터 요청이 서로 독립적으로 관리가 된다는 말이고, 이전 데이터 요청과 다음 데이터 요청이 서로 관련이 없다는 뜻입니다.

HTTP 통신은 요청(Request) -> 응답(Response) 이 종료되면 stateless(상태가 유지되지 않은) 한 특징 때문에 연결을 끊는 처리 방식입니다.
로그인과 같은 일을 할 때, '누가' 로그인 중인지 상태를 기억하기 위해 쿠키, 세션, 토큰을 사용합니다.

Connectionless 프로토콜(비연결 지향)
클라이언트가 서버에 요청을 했을 때, 요청에 맞는 응답을 보낸 후 연결을 끊는 처리방식이다.
Stateless 프로토콜(상태정보 유지 안함)
클라이언트의 상태 정보를 가지지않는 서버 처리 방식이다. 클라이언트와 첫번재 통신에 데이터를 주고 받았다 해도, 두버재 통신에 이전 데이터를 유지하지않는다.

클라이언트와 서버 간의 HTTP 통신에서 ID/비밀번호와 같은 사용자 정보를 클라이언트에 저장하는 방식입니다.
해당 방식에서, 서버는 클라이언트로부터 받은 요청에서 set-cookie에 응답 헤더를 담아 응답합니다. 클라이언트는 이후, 매 요청마다 응답 헤더를 쿠키에 담아 보냅니다.
일반적으로 특정 서비스의 UI 설정, 브라우저의 자주 보는 페이지 목록 저장 같은 것들은 쿠키로 저장 됩니다.
해당 방식은 제3자 역시 언제든지 볼 수 있으므로 보안에 취약합니다.

1.3. session

클라이언트로부터 인증 요청을 받은 서버는, 해당 접속에 대한 'ID/비밀번호'를 담은 session을 서버 메모리에 생성합니다. 이는 방문자가 웹 서버에 접속한 상태를 하나의 단위로서 기억합니다.
해당 접근이 가능한 고유한 session-id를 생성해 클라이언트에 응답 합니다.
사용자는 매 HTTP 요청에서 ID/비밀번호를 첨부 하는 대신, 해당 session-id를 통해 사용자들 식별합니다.
세션은 서버에 저장되므로, 사용자가 많을수록 해당 유저의 정보를 찾고 매칭하는데 시간이 증가합니다.

2. 토큰

클라이언트가 서버에게 최초 인증 정보를 보낼 시, 해당 정보가 유효하다면, 서버는 secret key를 통해 암호화된 토큰을 보냅니다.
클라이언트는 서버로부터 발급 받은 토큰을 저장하고, 서버의 요청에 따라 해당 토큰을 헤더에 포함시켜 전송 합니다.
서버는 인증 절차 과정에서 토큰의 유효성을 검사합니다.

2.1. 토큰 방식 (token, JWT)

사용자가 로그인을 요청 시 서버에서는 어떠한 데이터를 기반으로 인증이 가능한 토큰을 만들어서 클라이언트에 전달하는 방식입니다. 클라이언트는 요청 시 토큰을 서버로 보내고 서버는 토큰에 대한 유효성을 검사하여 사용자가 인증되었는지 판별합니다.

토큰방식은 토큰의 유효성을 확인 할 수만 있다면 유저의 인증을 별도로 분리할 수 있습니다. 예로 구글 로그인을 통해서 구글에서 발급한 토큰을 구글의 토큰 인증 서버를 통해 유효성을 검증하여 사용자를 인증할 수 있습니다.

JWT토큰은 토큰에 대한 인증을 토큰 자체를 통해서도 가능하게 하여 토큰의 유효성을 확인하기 위해 별도의 인증 서버를 통하지 않고도 JWT토큰을 해석하여 인증 여부를 확인 할 수 있게 만들었습니다.


2.1. JWT 토큰의 구조

토큰 방식에 사용되는 JWT(JSON Web Token)은 암호화된 JSON 포맷의 데이터를 통신하는 객체입니다. 모든 파라미터들은 key-value로 이루어져있습니다.


https://ssup2.github.io/theory_analysis/JWT/

Header는 Type을 나타내는 "typ" Key와 Algorithm을 나타내는 "alg" Key로 구성되었습니다. Type은 Token의 Type을 의미합니다. Algorithm은 해당 signature를 생성하는 알고리즘을 지칭하며, Header의 내용은 Base64로 Encoding되어 JWT에 저장됩니다.

1.2. Payload

Payload에는 Token의 Meta Data와 Token이 전달하려는 실제 Data가 Key-value 형태로 저장됩니다. 이러한 데이터 형태를 claim이라 부르며, 다음과 같이 분류됩니다.

registerd claim
토큰 정보를 표현하기 위해 이미 정해진 종류의 데이터로 선택적으로 작성이 가능하며 사용이 권장됩니다.

도표로 표시할것
iss	토큰 발급자(issuer)
sub	토큰 제목(subject)
aud	토큰 대상자(audience)
exp	토큰 만료 시간(expiration), NumericDate 형식으로 되어 있어야 함 ex) 1480849147370
nbf	토큰 활성 날짜(not before), 이 날이 지나기 전의 토큰은 활성화되지 않음
lat	토큰 발급 시간(issued at), 토큰 발급 이후의 경과 시간을 알 수 있음
jti	JWT 토큰 식별자(JWT ID), 중복 방지를 위해 사용하며, 일회용 토큰(Access Token) 등에 사용7

Custom Claim
Custom Claim에는 Token이 전달하려는 실제 Data가 저장됩니다. Public ClaimPrivate Claim으로 구분 됩니다.

  • 공개 클레임 (public claim)
    사용자 정의 클레임으로 공개용 정보를 위해 사용되며 충돌 방지를 위해 URI 포맷을 이용합니다.

    {
    	"https://naver.com": true
    }
    
  • 비공개 클레임 (Private Claim)
    사용자 정의 클레임으로 서버와 클라이언트 사이에 임의로 지정한 정보를 저장합니다.

    {
    	"token_type": "access"
    }

1.3. Signature
JWT의 Header와 Payload는 Base64로 Encoding 되어 있기에, 누구든지 decoding할 수 있으며, 이를 통해 해당 JWT를 가로채어 Payload를 변조할 수 있는 단점이 있습니다. Signature는 이런 지점을 보완합니다.

Signature의 형식은 다음과 같습니다.

base64(HMACSHA256(Base64(Header) + . + Base64(Payload),Password))
  1. Signature에서 Header와 Payload는 각각 Base64로 인코딩 된 다음, 서로를 "."로 연결하고
  2. 사용자가 설정한 Password(대칭키, 비대칭키)를 이용해 암호화 알고리즘을 사용합니다.
  3. 이를 다시 Base64로 인코딩하여 생성합니다.
  4. 생성된 JWT는 통신에 따라 해당 App에 보내집니다.
  5. 수신 받은 Application은 이를 기반으로 Header와 Payload, app에서 등록된 Password를 이용하여 Signature를 생성한 다음, 수신한 JWT의 Signature와 비교하여 유효성을 판별합니다.

JWT에서는 Signature 생성을 위해서 다양한 암호화 Algorithm을 이용할 수 있습니다. 일반적으로 대칭키 기반의 암호화에는 HMACSHA256 Algorithm이 사용되고, 비대칭키 (공개키, 비공개키) 기반의 암호화에는 RSA256 Algorithm을 이용합니다.

1.4. 특징, 용도
JWT는 Payload에 원하는 Data를 저장할 수 있고, Signature를 이용하여 자신의 무결성을 검증할 수 있습니다. 이렇게 스스로에게 모든 정보를 담고 있는 특징을 Self-contained라고 표현하는데요, JWT는 Self-contained 특징을 바탕으로 Payload에 인가 정보를 넣어 주로 Service의 인증/인가를 수행하는 Token으로 이용되거나, Web 환경에서 암호화 하여 Data를 주고받는 목적으로 이용합니다.

장점
header와 payload를 가지고 signature를 생성하므로 데이터 위변조를 막을 수 있습니다.
인증 정보에 대한 별도의 저장소가 필요하지 않습니다.
사용자의 인증을 토큰을 통해서 관리하기 때문에 서버의 scale-out에서도 인증이 자유롭습니다.
사용자에 대한 인증 방식의 확장이 가능합니다. OAuthentification2.0을 통한 소셜 로그인들이 그러한 예 입니다.

단점
JWT는 쿠키와 세션 방식과 달리, 데이터의 크기가 큰 편입니다.
토큰이 탈취될 경우 대처하기 어렵습니다.
인증된 토큰을 발급을 하면 토큰이 expire되기 전까지 토큰의 유효성을 막을 방법이 없습니다.
사용자를 block을 시켜야 할 때 세션방식은 세션 아이디를 파기하여 즉시 block할 수 있지만, 토큰방식은 발급한 토큰이 expire 되기 전까지 사용자에 대한 인증이 유효할 수 있습니다.

2.2. 토큰의 보안 전략

  1. 짧은 만료 기한 설정
    토큰의 만료 시간이 짧으면 탈취되더라도 금방 만료되기 때문에 피해를 최소화 할 수 있습니다. 하지만 사용자가 자주 로그인 해야하는 불편함이 있습니다.

  2. Sliding Session
    예를들어 로그인하고 글을 작성하는 도중 토큰이 만료 된다면 저장 작업이 정상적으로 작동하지않고 작성한 글이 날아가는 일이 생기는 등 불편함이 존재합니다. Sliding Session은 서비스를 지속적으로 이용하는 클라이언트에게 자동으로 토큰 만료 기한을 늘려주는 방법입니다. 1번의 자주 로그인해야하는 단점을 보완시켜줍니다.

  3. Access Token과 Refresh Token의 이원화
    클라이언트가 로그인 요청을 보내면 서버는 Access Token과 그 보다 만료기간이 긴 Refresh Token을 함께 내려줍니다. 클라이언트는 Access Token이 만료되었을 때, Refresh Token을 사용하여 Access Token의 재발급을 요청합니다. 서버는 DB에 저장된 Refresh Token과 비교하여 유요한 경우 새로운 Access Token을 발급하고, 만료된 경우 다시 로그인 시킵니다.
    검증을 위해서는 서버에 Refresh Token을 별도로 저장시켜야합니다.


3. 각 기능 구현과 테스트

3.1. userinfo/해당 dto

소스코드

3.2. Controller

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
    private final AuthenticationService service;
    private final AuthorizationService authorizationService;

    
    @PostMapping("/authenticate")   //로그인 시 Token 체크. //로그인 때 마다 accessToken 발급.
    public ResponseEntity<AuthenticationResponse> authenticate(@RequestBody AuthenticationRequest request){
        return ResponseEntity.ok(service.authenticate(request));
    }

    @PutMapping("/re-authenticate/{email}") //아이디 혹은 패스워드 바꿀 때. 
    public ResponseEntity<AuthenticationResponse> reAuthenticate(@PathVariable(name = "email") String email, @RequestBody AuthenticationRequest request){
        return ResponseEntity.ok(service.reAuthenticate(email, request));
    }

    @PutMapping("/withdraw/{email}")
    public ResponseEntity<?> withdraw(@PathVariable(name = "email") String email){
        authorizationService.withdraw(email);

        HttpHeaders headers = new HttpHeaders();
        headers.setLocation(URI.create("/api/v1/auth/logout"));

        return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
    }
}

3.3. AuthenticationService.java


@Service
@RequiredArgsConstructor
@Slf4j
public class AuthenticationService {
    private final UserRepository userRepository;
    private final TokenRepository tokenRepository;
    private final RefreshTokenRepository refreshRepository;

    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    
    private final AuthenticationManager authenticationManager;

    //DB에 등록. 토큰 생성
    public AuthenticationResponse register(RegisterRequest request)  {
        var user = Userinfo.builder()
            .emailId(request.getEmail_id())
            .password(passwordEncoder.encode(request.getPassword()))
            .role(Role.USER)
            .withdraw(false)
            .build();

        userRepository.save(user);
        var savedUser = userRepository.save(user);

        var accessToken = jwtService.generateAccessToken(user);
        saveUserAccessToken(savedUser, accessToken);

        var refreshToken = jwtService.generateRefreshToken(user);        
        saveUserRefreshToken(savedUser, refreshToken);

        return AuthenticationResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build(); 
    }
    
    //새로운 accessToken과 refreshToken 생성
    //jwtToken이 expired됐을 시, client에서는 header의 authorization을 null하고 authenticate로 보낸다.
    public AuthenticationResponse authenticate(AuthenticationRequest request) {
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getEmail_id(), request.getPassword())
        );

        var user = userRepository.findByEmail(request.getEmail_id())
            .orElseThrow(null);

        if(user.getWithdraw()) {
            user.setWithdraw(false); 
            user.setWithdrawDate(null);
        }
        
        revokeAllUserTokens(user);

        var accessToken = jwtService.generateAccessToken(user);
        saveUserAccessToken(user, accessToken);

        var refreshToken = jwtService.generateRefreshToken(user);
        saveUserRefreshToken(user, refreshToken);
        
        return AuthenticationResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
    }
    
    //참조
    //https://webcache.googleusercontent.com/search?q=cache:sNMEucCGzjkJ:https://wonit.tistory.com/130&cd=1&hl=ko&ct=clnk&gl=kr
    //https://velog.io/@sun1203/Spring-BootPut-Patch
    public AuthenticationResponse reAuthenticate(String email, AuthenticationRequest request){
        var user = userRepository.findByEmail(email)
            .orElseThrow(() -> new NullPointerException("해당 responseBody가 무존재"));

        user.setEmailId(request.getEmail_id());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        
        userRepository.save(user);

        return authenticate(request);
    }

    //case1 : access token과 refresh token 모두가 만료된 경우 → 에러 발생 (재 로그인하여 둘다 새로 발급)
    //case2 : access token은 만료됐지만, refresh token은 유효한 경우 →  refresh token을 검증하여 access token 재발급
    //case3 : access token은 유효하지만, refresh token은 만료된 경우 →  access token을 검증하여 refresh token 재발급                    
    //case4 : access token과 refresh token 모두가 유효한 경우 → 정상 처리
    //https://junhyunny.github.io/spring-boot/spring-security/issue-and-reissue-json-web-token/ <- 참조할것
    public AuthenticationResponse reIssuance(RestRequest request, String jwtAccessToken) {
        var user = userRepository.findByEmail(request.getEmail_id())
            .orElseThrow(null);
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(request.getEmail_id());

        var jwtRefreshToken = refreshRepository.findRefreshTokenByUsername(request.getEmail_id())
            .orElseThrow(null);

        String accessToken = jwtAccessToken.substring(7);
        String refreshToken = jwtRefreshToken.getToken();
        
        if(!jwtService.isTokenValid(accessToken, userDetails) && !jwtService.isTokenValid(refreshToken, userDetails)){
            revokeAllUserTokens(user);
        }else{
            if(jwtService.isTokenIssuer(accessToken, userDetails)){
                var token = tokenRepository.findByToken(accessToken)
                        .orElseThrow(null);
                token.setExpired(true);
                token.setRevoked(true);
                tokenRepository.save(token);
    
                accessToken = jwtService.generateAccessToken(userDetails);            
                saveUserAccessToken(user, accessToken);
            }
    
            if(jwtService.isTokenIssuer(refreshToken, userDetails)){
                var token = refreshRepository.findByToken(refreshToken)
                    .orElseThrow(null);
                token.setExpired(true);
                refreshRepository.save(token);
    
                refreshToken = jwtService.generateRefreshToken(userDetails);
                saveUserRefreshToken(user, refreshToken);
            }    
        }

        return AuthenticationResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
    }

    //토큰 저장 메서드 -> accessToken은 localStorage 혹은 redis 저장될 예정. 지금은 sql로.
    //아마 OAuth2.0 적용하면서 한번 더 갈아야 할지도.
    
    private void saveUserAccessToken(Userinfo user, String jwtToken) {
            var accessToken = AccessToken.builder()
                .userinfo(user)
                .token(jwtToken)
                .tokenType(TokenType.BEARER)
                .expired(false)
                .revoked(false)
                .build();
        
            tokenRepository.save(accessToken);
    }

    private void saveUserRefreshToken(Userinfo user, String jwtToken) {
            var refreshToken = RefreshToken.builder()
                .userinfo(user)
                .token(jwtToken)
                .expired(false)
                .build();
        
            refreshRepository.save(refreshToken);
    }    

    private void revokeAllUserTokens(Userinfo user) {
        var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId());
        var validRefreshTokens = refreshRepository.findAllValidTokenByUser(user.getId());

        if(!validUserTokens.isEmpty()){
            validUserTokens.forEach(token -> {
                token.setExpired(true);
                token.setRevoked(true);

            });
            tokenRepository.saveAll(validUserTokens);            
        }
        
        if(!validRefreshTokens.isEmpty()){
            validRefreshTokens.forEach(token -> {
                token.setExpired(true);
            });
        
            refreshRepository.saveAll(validRefreshTokens);    
        }
    }
}
    

3.4. SecurityConfiguration.java

@Configuration(proxyBeanMethods = false) 
@EnableWebSecurity 
@RequiredArgsConstructor
@ConditionalOnDefaultWebSecurity 
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class SecurityConfiguration {
    private final JwtAuthentificationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;
    private final LogoutHandler logoutHandler;
    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeHttpRequests()
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/api/v1/user/**").permitAll()
                .requestMatchers("/api/v1/oauth2/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .sessionManagement(management -> management
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authenticationProvider(authenticationProvider)
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                .logout(logout -> logout	//logout handler 지정
                        .logoutUrl("/api/v1/auth/logout")
                        .addLogoutHandler(logoutHandler)
                        .logoutSuccessHandler((request, response, authentication) 
                        	-> SecurityContextHolder.clearContext()));                                    
        return http.build();
    }


}

3.5. JwtService.java

@Service
public class JwtService {        
    //해당 비밀 키는 클라이언트에서 생성해야 하는 듯.
    //혹은 gradle에 jwt.secret:
    //그리고 securityConfig에 객체 생성 후 @Value("${jwt.secret}")
    private final String SECRET_KEY = "472D4B6150645367566B58703273357638792F423F4528482B4D625165546857";
    private final Long ACCESS_TOKEN_EXPIRATION = 2000 * 60 * 24L;
    private final Long REFRESH_TOKEN_EXPIRATION = 30000 * 60 * 24L;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public<T> T extractClaim(String token, Function<Claims, T> claimsResolver){
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    public String generateAccessToken(UserDetails userDetails){
        return generateAccessToken(new HashMap<>(), userDetails);
    }

    public String generateRefreshToken(UserDetails userDetails){
        return generateRefreshToken(new HashMap<>(), userDetails);
    }

    public String generateAccessToken(Map <String, Object> extractClaims, UserDetails userDetails){
        return Jwts.builder()
                .setClaims(extractClaims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION))
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    public String generateRefreshToken(Map <String, Object> extractClaims, UserDetails userDetails){
        return Jwts.builder()
                .setClaims(extractClaims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION))
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    public boolean isTokenValid(String token, UserDetails userDetails){
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);  
    }

    public boolean isTokenIssuer(String token, UserDetails userDetails){
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenReIssuer(token);  
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
    
    private boolean isTokenReIssuer(String token){
        Long reIssuer = extractExpiration(token).getTime() - (extractExpiration(token).getTime()/10L);
        return extractIssuedAt(token).after(new Date(reIssuer));
    }

    private Date extractExpiration(String token){
        return extractClaim(token, Claims::getExpiration);
    }

    private Date extractIssuedAt(String token){
        return extractClaim(token, Claims::getIssuedAt);
    }

    public Claims extractAllClaims(String token){
        return Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                .parseClaimsJws(token)
            .getBody();
    }

    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }

}

4. 기능 별 테스트

4.1. 토큰 생성

public class JwtService {
	
    ...
    
    private void saveUserAccessToken(Userinfo user, String jwtToken) {
            var accessToken = AccessToken.builder()
                .userinfo(user)
                .token(jwtToken)
                .tokenType(TokenType.BEARER)
                .expired(false)
                .revoked(false)
                .build();
        
            tokenRepository.save(accessToken);
    }

    private void saveUserRefreshToken(Userinfo user, String jwtToken) {
            var refreshToken = RefreshToken.builder()
                .userinfo(user)
                .token(jwtToken)
                .expired(false)
                .build();
        
            refreshRepository.save(refreshToken);
    }    
  	
 }
 
 	...
 

AccessToken과 RefreshToken의 생성입니다.
AccessToken은 해당 토큰이 유효성 검사에 필요한 속성들이 암호화된 토큰이며, RefreshToken은 해당 토큰의 재발급 여부를 참고합니다.
두가지 유형의 토큰의 기간은 서로 다릅니다. 사용자에게 발급되는 토큰은 같은 유형끼리 중복될 수 있으며, 기한이 만료되거나 로그아웃 시, 해당 accessToken은 revoked로 처리됩니다.

4.1. 토큰 유효성 검사

@Service
public class JwtService {        
    private final String SECRET_KEY = "472D4B6150645367566B58703273357638792F423F4528482B4D625165546857";
    private final Long ACCESS_TOKEN_EXPIRATION = 2000 * 60 * 24L;
    private final Long REFRESH_TOKEN_EXPIRATION = 30000 * 60 * 24L;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public<T> T extractClaim(String token, Function<Claims, T> claimsResolver){
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    public boolean isTokenValid(String token, UserDetails userDetails){
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);  
    }

    public boolean isTokenIssuer(String token, UserDetails userDetails){
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenReIssuer(token);  
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
    
    private boolean isTokenReIssuer(String token){
        Long reIssuer = extractExpiration(token).getTime() - (extractExpiration(token).getTime()/10L);
        return extractIssuedAt(token).after(new Date(reIssuer));
    }

    private Date extractExpiration(String token){
        return extractClaim(token, Claims::getExpiration);
    }

    private Date extractIssuedAt(String token){
        return extractClaim(token, Claims::getIssuedAt);
    }

    public Claims extractAllClaims(String token){
        return Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                .parseClaimsJws(token)
            .getBody();
    }

    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }

토큰의 유효성을 체크하는 메서드입니다.
나열된 메서드들은 Spring Security의 Filter에서 jwt의 유효성을 체크할 때 사용됩니다.

4.2. 토큰 재발급


public class AuthenticationService {
    public AuthenticationResponse reIssuance(RestRequest request, String jwtAccessToken) {
        var user = userRepository.findByEmail(request.getEmail_id())
            .orElseThrow(null);
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(request.getEmail_id());

        var jwtRefreshToken = refreshRepository.findRefreshTokenByUsername(request.getEmail_id())
            .orElseThrow(null);

        String accessToken = jwtAccessToken.substring(7);
        String refreshToken = jwtRefreshToken.getToken();
        
        if(!jwtService.isTokenValid(accessToken, userDetails) && !jwtService.isTokenValid(refreshToken, userDetails)){
            revokeAllUserTokens(user);
        }else{
            if(jwtService.isTokenIssuer(accessToken, userDetails)){
                var token = tokenRepository.findByToken(accessToken)
                        .orElseThrow(null);
                token.setExpired(true);
                token.setRevoked(true);
                tokenRepository.save(token);
    
                accessToken = jwtService.generateAccessToken(userDetails);            
                saveUserAccessToken(user, accessToken);
            }
    
            if(jwtService.isTokenIssuer(refreshToken, userDetails)){
                var token = refreshRepository.findByToken(refreshToken)
                    .orElseThrow(null);
                token.setExpired(true);
                refreshRepository.save(token);
    
                refreshToken = jwtService.generateRefreshToken(userDetails);
                saveUserRefreshToken(user, refreshToken);
            }    
        }

        return AuthenticationResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
    }
}

해당 프로세스는 로그인 상태를 유지하는 동시에 기간이 만료된 토큰을 처리하고 재발급하기 위해 작성되었습니다.

4.3. 로그인

public class AuthenticationService {
	...
    public AuthenticationResponse authenticate(AuthenticationRequest request) {
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getEmail_id(), request.getPassword())
        );

        var user = userRepository.findByEmail(request.getEmail_id())
            .orElseThrow(null);

        if(user.getWithdraw()) {
            user.setWithdraw(false); 
            user.setWithdrawDate(null);
        }
        
        revokeAllUserTokens(user);

        var accessToken = jwtService.generateAccessToken(user);
        saveUserAccessToken(user, accessToken);

        var refreshToken = jwtService.generateRefreshToken(user);
        saveUserRefreshToken(user, refreshToken);
        
        return AuthenticationResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
    }
	...
}

로그인 메서드 입니다.
혹시나 유효성이 있는 토큰이 중복됨을 방지하기 위해 해당 비즈니스 로직에서 모든 토큰을 revoked 처리를 한 뒤, 토큰을 새로이 생성합니다.

4.4. 로그아웃


@Service
@RequiredArgsConstructor
@Slf4j
public class LogoutService implements LogoutHandler {
    private final TokenRepository tokenRepository;
    private final RefreshTokenRepository refreshTokenRepository;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        if (authHeader == null ||!authHeader.startsWith("Bearer ")) return;
        
        jwt = authHeader.substring(7);
        var storedToken = tokenRepository.findByToken(jwt)
            .orElse(null);

        if(storedToken != null){
            storedToken.setExpired(true);
            storedToken.setRevoked(true);
            tokenRepository.save(storedToken);

            var refreshToken = refreshTokenRepository.findByUserEmail(
                        storedToken.getUserinfo().getUsername()
                )
                .orElse(null);

            if(refreshToken != null){
                refreshToken.setExpired(true);
                refreshTokenRepository.save(refreshToken);
            }

            SecurityContextHolder.clearContext();
        }
    }   
}
public class AuthenticationService {
    private void revokeAllUserTokens(Userinfo user) {
        var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId());
        var validRefreshTokens = refreshRepository.findAllValidTokenByUser(user.getId());

        if(!validUserTokens.isEmpty()){
            validUserTokens.forEach(token -> {
                token.setExpired(true);
                token.setRevoked(true);

            });
            tokenRepository.saveAll(validUserTokens);            
        }
        
        if(!validRefreshTokens.isEmpty()){
            validRefreshTokens.forEach(token -> {
                token.setExpired(true);
            });
        
            refreshRepository.saveAll(validRefreshTokens);    
        }
    }
}

스쿼드 매니아의 인증 앱에서는 로그아웃 절차를 Spring Security를 통해 실행합니다.
로그아웃 핸들러는 시큐리티 필터 체인에 등록된 url을 통해 실행됩니다.
로그아웃 시 발급되었던 토큰들은 모두 만료처리를 하며, SecurityContext에 쌓여있는 토큰 객체들을 모두 삭제합니다.

5. 레퍼런스


비문, 오탈자와 코드 오류 및 잘못된 지식에 대한 지적 및 질문은 언제나 환영합니다.

profile
프로개발자를 지망하는.

0개의 댓글