앱개발

Redis 활용 JWT 로그아웃 구현하기

컴공코딩러 2024. 5. 28. 20:12

 

 

 

 

 

 

 

 

작년에 진행한 미간지 프로젝트!

 

로그인은 JWT 로그인으로 구현 하였다.

 

 

나는 그 당시 Redis를 활용해보고 싶었고 JWT와 결합하여 사용하면 좋을것 같아서

 

팀원들을 설득해서 JWT로 구현하였다.

 

https://evga7.tistory.com/140 <- 이건 JWT 토큰에 대한 내용이다

 

JWT는 기본적으로 AccessToken , RefreshToken  두가지를 활용한다.

 

액세스토큰은 말그대로 접근을 위한 토큰 토큰을 가지고있다면 권한을 가지고있다고 판단한다.

 

하지만 보통 토큰은 stateless 특징을 가지므로 탈취된다면 서버는 똑같이 허용된 사용자로 판단하기때문에

 

30분정도의 유효기간을 두고 만료 되었을시 refreshToken을 통해 재발급을 받아 다시 사용할수 있도록한다.

 

물론 RefreshToken도 유효기간을 두고 발급하게 된다. (두개 모두 로그인시 발급)

 

 

나는 프로젝트를 이렇게 적용하였다.

https://github.com/mi-gan-zi/BE-MiGanZi/discussions/54

 

JWT 토큰에 대한 설명 · mi-gan-zi BE-MiGanZi · Discussion #54

토큰 사용법을 헷갈리시는 거 같아서 적어놓겠습니다. RefreshToken 유효 + AccessToken 유효 => 정상적으로 로그인이 유지되는 경우. RefreshToken 유효 + AccessToken 만료 => AccessToken이 만료되었지만 RefreshToke

github.com

 

위에 글 내용은

  1. RefreshToken 유효 + AccessToken 유효
    => 정상적으로 로그인이 유지되는 경우.
  2. RefreshToken 유효 + AccessToken 만료
    => AccessToken이 만료되었지만 RefreshToken이 유효하기 때문에
    RefreshToken을 사용해 AccessToken을 발급받을 수 있음.
  3. RefreshToken 만료 + AccessToken 유효
    => AccessToken이 만료될 때까지 AccessToken을 사용할 수 있음.
    하지만 만료 기간이 짧기 때문에 이후 로그인이 필요함.
  4. RefreshToken 만료 + AccessToken 만료
    => 로그인이 필요한 경우.

JWT 토큰( RefreshToken  , AccessToken )은 로그인시 토큰이 발급이 된다.

 

로그인 함수

public ResponseEntity<?> login(HttpServletRequest request,String nickname, String password) {
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(nickname , password);

    // 검증
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);


    // 검증된 인증 정보로 JWT 토큰 생성
    UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);

    redisService.saveRefreshToken(RefreshToken.builder()
            .id(authentication.getName())
            .ip(Helper.getClientIp(request))
            .authorities(authentication.getAuthorities())
            .refreshToken(tokenInfo.getRefreshToken())
            .build());
    return response.success(tokenInfo);
}

이건 내 프로젝트의 로그인 코드다

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(nickname , password);

// 검증
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

일단 입력값을 통해 authenticationToken 을 만들어서 검증하게 된다.

위 사진은 스프링 시큐리티의 로그인 과정이다. 인증매니저가 프로파이더에게 전달후 검증 후 검증값을 return 하게 된다.

 

UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);

인증된 유저라면 generateToken 함수를 통해 토큰을 생성한다.

 

public UserResponseDto.TokenInfo generateAccessToken(String name,
                                               Collection<? extends GrantedAuthority> inputAuthorities) {
    //권한 가져오기
    String authorities = inputAuthorities.stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));

    Date now = new Date();
    //Access Token 생성
    String accessToken = Jwts.builder()
            .setSubject(name)
            .claim(AUTHORITIES_KEY, authorities)
            .claim("type",TYPE_ACCESS)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + ExpireTime.ACCESS_TOKEN_EXPIRE_TIME))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();

    return UserResponseDto.TokenInfo.builder()
            .grantType(BEARER_TYPE)
            .accessToken(accessToken)
            .accessTokenExpirationTime(ExpireTime.ACCESS_TOKEN_EXPIRE_TIME)
            .nickname(name)
            .build();
}

이건 자세한 함수 내용이다.

권한을 가져온후 액세스, 리프래시 토큰에 데이터들을 넣는다. 알고리즘은 HS256 이다.

(HS256은 HMAC (Hash-based Message Authentication Code)과 SHA-256 (Secure Hash Algorithm 256) 을 결합한 JWT (JSON Web Token)에 사용되는 암호화 알고리즘 ,  JWT의 헤더와 페이로드가 변경되지 않았음을 검증하는 데 사용된다.)

 

현재 시간과 엑스파이어 시간을 설정하여 토큰을 방급 시킨다.

public class ExpireTime {
    public static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L;               //30분
    public static final long REFRESH_TOKEN_EXPIRE_TIME = 30 * 24 * 60 * 60 * 1000L;     //30일
    public static final long REFRESH_TOKEN_EXPIRE_TIME_FOR_REDIS = REFRESH_TOKEN_EXPIRE_TIME / 1000L;
    public static final long USER_COMMENT_ALERT_TIME_FOR_REDIS = REFRESH_TOKEN_EXPIRE_TIME / 1000L;
}

이건 따로 내가 설정해놓은 expiretime 클래스이다. 기본적으로 토큰 사용 기간과 redis를 활용한 시간 4가지 변수를 썼다.

 

public static class TokenInfo {
    private String grantType;
    private String accessToken;
    private Long accessTokenExpirationTime;
    private String refreshToken;
    private Long refreshTokenExpirationTime;
    private String nickname;
}

이건 로그인시 리턴시키는 정보들이다.

2가지 토큰과 만료기간 닉네임을 반환시킨다.

닉네임은 API 이용시 편하게 사용하기 위한 넣어놓았다.

로그인시 쿠키에 저장되는 데이터다

refresh torken, expire time, access token 에 대한 정보가 담겨져있다.

 

redisService.saveRefreshToken(RefreshToken.builder()
        .id(authentication.getName())
        .ip(Helper.getClientIp(request))
        .authorities(authentication.getAuthorities())
        .refreshToken(tokenInfo.getRefreshToken())
        .build());

 

코드 마지막은 레디스에 Refresh 토큰을 저장하는 코드이다.

 

이유는 로그아웃 기능도 구현하기 위해서이다.

 

로그인 과정

 

 

로그인을 하면 Refresh 토큰을 Redis에 저장한다.

 

(access token 만료시 Refresh 토큰을 통해 갱신 시키는데 Refresh 토큰을 조회할때 Redis를 활용하여 조회한다)

 

로그아웃시 Redis에 있는 Refresh Token을 삭제시킨다. (로그아웃 토큰일경우 갱신이 안됨)

 

물론 Access 토큰도 Redis에 BlackList로 등록시켜 액세스시간동안 (30분) 저장하여 로그아웃된 토큰일 경우

 

Block 시키고 액세스시간이 지난 이후 자동으로 삭제하게 해놓았다.

(Redis 자동삭제 기능 <- 이 기능은 어떻게 동작하는지 궁금하니 다음 블로그 글은 이것으로 작성할 예정)

 

로그아웃 함수

 

public ResponseEntity<?> logout(String token) {
    // 1. Access Token 검증
    if (!jwtTokenProvider.validateToken(token)) {
        return response.fail("잘못된 요청입니다.", HttpStatus.BAD_REQUEST);
    }

    // 2. Access Token 에서 User email 을 가져옵니다.
    Authentication authentication = jwtTokenProvider.getAuthentication(token);

    // 3. Redis 에서 해당 User name 로 저장된 Refresh Token 이 있는지 여부를 확인 후 있을 경우 삭제합니다.
    Optional<RefreshToken> byId = redisService.findRefreshTokenById((authentication.getName()));
    if (byId.get() != null) {
        // Refresh Token 삭제
        redisService.removeRefreshToken(byId.get());
    }

    // 4. 해당 Access Token 유효시간 가지고 와서 BlackList 로 저장하기
    Long expiration = jwtTokenProvider.getExpiration(token);
    redisTemplate.opsForValue()
            .set(token, "logout", expiration, TimeUnit.MILLISECONDS);

    return response.success("로그아웃 되었습니다.");
}

 

이게 바로 그 코드이다

 

로그아웃하면 Redis에서 토큰을 찾은후 refresh 토큰을 삭제한다

 

이후 Access 토큰을 레디스에 만료기간까지 BlackList로 등록 시켜서 두 토큰모두 무효화 시킨다.

 

 

Refresh Token 을 활용하여 Access 토큰 재발급 받기

 

public ResponseEntity<?> reissue(HttpServletRequest request) {
    //TODO:: 1, 2 는 JwtAuthenticationFilter 동작과 중복되는 부분, 때문에 jwt filter 에서 다른 key 값으로 refresh token 값을
    //넘겨주고 여기서 받아서 처리하는 방법도 적용해 볼 수 있을 듯

    //1. Request Header 에서 JWT Token 추출
    String token = jwtTokenProvider.resolveToken(request);

    //2. validateToken 메서드로 토큰 유효성 검사
    if (token != null && jwtTokenProvider.validateToken(token)) {
        //3. refresh token 인지 확인
        if (jwtTokenProvider.isRefreshToken(token)) {
            //refresh token
            RefreshToken refreshToken = redisService.findByRefreshString(token);
            if (refreshToken != null) {
                //4. 최초 로그인한 ip 와 같은지 확인 (처리 방식에 따라 재발급을 하지 않거나 메일 등의 알림을 주는 방법이 있음)
                String currentIpAddress = Helper.getClientIp(request);
                if (refreshToken.getIp().equals(currentIpAddress)) {
                    // 5. Redis 에 저장된 RefreshToken 정보를 기반으로 JWT Token 생성
                    UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateAccessToken(refreshToken.getId(), refreshToken.getAuthorities());
                    return response.success(tokenInfo);
                }
                else
                {
                    return response.fail("새로운곳에서 로그인하여 실패했습니다.",HttpStatus.BAD_REQUEST);
                }
            }
        }
    }

    return response.fail("토큰 갱신에 실패했습니다.",HttpStatus.BAD_REQUEST);
}

 

마지막으로 Refresh 토큰을 이용한 Access 토큰 재발급 받는 API이다.

 

가장 재밌었던 코드

 

동작 과정

1. 토큰 추출 

2. 유효성 검사 ( 유효한 토큰인지)

3. refshToken인지 확인

4. 발급받았던 IP과 같은지 확인 (보안을 높이기 위해 넣었다 물론 IP 정보까지 탈취되었다면 의미가 없다

ipv4 는 최대 약 43억개 지만 뭐 맘먹으면 브루트 포스도 뚫리긴 하겠다.)

5. 새로운 토큰을 만들어 정보를 return 시켜준다.

 

이과정에서 하나라도 안되면 fail response를 날려준다.

 

2번 째 유효성 검사 코드를 보자

 

public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        return true;
    }catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
        throw new JwtException("유효하지 않은 토큰");
    } catch (ExpiredJwtException e) {
        throw new JwtException("토큰 기한 만료");
    } catch (UnsupportedJwtException e) {
        throw new JwtException("유효하지 않은 토큰");
    } catch (IllegalArgumentException e) {
        throw new JwtException("유효하지 않은 토큰");
    }
}

 

parseClaimsJws 함수로 유효성이 맞으면 True

아니면 각 예외처리에 맞는 응답을 Return 한다

 

public boolean isRefreshToken(String token) {
    String type = (String) Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("type");
    return type.equals(TYPE_REFRESH);
}

이건 RefreshToken 이맞는지 확인하는 바디에서 Type을 String으로 추출해 Refresh가 맞는지 확인한다.

 

RefreshToken refreshToken = redisService.findByRefreshString(token);
if (refreshToken != null) {
    //4. 최초 로그인한 ip 와 같은지 확인 (처리 방식에 따라 재발급을 하지 않거나 메일 등의 알림을 주는 방법이 있음)
    String currentIpAddress = Helper.getClientIp(request);
    if (refreshToken.getIp().equals(currentIpAddress)) {
        // 5. Redis 에 저장된 RefreshToken 정보를 기반으로 JWT Token 생성
        UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateAccessToken(refreshToken.getId(), refreshToken.getAuthorities());
        return response.success(tokenInfo);
    }

그다음 레디스 에서 토큰을 찾은후 null 이 아닐경우 

IP 주소가 토큰에 등록된 IP와 같을경우 새로운 토큰을 Return 시킨다.

'앱개발' 카테고리의 다른 글

[Android] 안드로이드 localhost 안될 때 해결하기  (0) 2021.08.05