본문 바로가기
JAVA

JWT + Spring Security + Redis를 이용한 로그인 구현(2)

by 엉아멋지지 2023. 9. 8.

지난 1편에 이어 이번에는 JWT 발급 구현에 대해 적어보았습니다 !!

https://whymecoding.tistory.com/2

 

JWT + Spring Security를 이용한 로그인 구현(1)

Spring Security 의 구조 1. 클라이언트의 요청이 들어오면 AuthenticationFilter에서 이를 가로챈다. 2-3. 전달받은 클라이언트의 아이디 / 비밀번호 를 UsernamePasswordAuthenticationToken에 담는다. (인증 객체 생

whymecoding.tistory.com

AccessToken & RefreshToken

JWT 의 특징 중에는 무상태성(Stateless) 가 있습니다. 이는 서버가 토큰을 사용자에게 발급하고 나서 사용자의 상태를 저장하지 않음을 말합니다. 서버가 상태 관리를 하지 않기 때문에 서버의 부하가 줄어들고 확장성에 용이하다는 장점이 있지만 반대로 사용자의 토큰이 탈취당했을때 서버가 관리하지 않기때문에 대처를 할수 없다는 단점이 있습니다. 

이 점을 보완하기 위해 AccessToken 과 RefreshToken 두개를 발급 하는 방법을 사용하였습니다.

 

사용자가 로그인 시 AccessTokenRefreshToken 이 두 토큰이 발급된다.

 

● AccessToken 

- 발급되면 사용자의 로컬 스토리지에 저장 된다 ( 사용자가 갖고 있음 )

- 유효시간이 짧다 

- 이후 사용자가 서버로 요청을 보낼때 마다 토큰을 header에 담아서 함께 보낸다

  (이를 서버에서 받아 유효성을 검사 한다 ) 

 

RefreshToken

-  AccessToken이 만료되었을때 재발급을 위한 토큰

- 유효시간이 길다

- 서버 측에서 저장하고 있음 ( 제 경우에는 Redis에 저장하였습니다)

 

*Redis를 사용한 이유

redis는 데이터를 RAM에 저장하기 때문에 데이터를 가져오는 속도가 엄청나게 빠릅니다. 

데이터를 키-값 쌍의 형태로 저장하여 직관적이고 또 만료시간을 설정할 수 있는 기능이 있어 토큰 저장에 적합하다고 생각되었습니다.

 

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
	
    //redis관련 의존성을 추가해 주었습니다
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    }

redis관련 의존성을 추가해 주었습니다

 

UserController

    @PostMapping("/renew")
    public ResponseEntity renewToken(@RequestHeader(value = "Authorization") String acTokenRequest,
                                  @RequestHeader(value = "RefreshToken") String rfTokenRequest){
        String accessToken = acTokenRequest.substring(7);
        String refreshToken = rfTokenRequest.substring(7);

        TokenResponse tokenResponse = userService.renewToken(accessToken, refreshToken);

        return new ResponseEntity(tokenResponse, HttpStatus.OK);
    }

@RequestHeader 어노테이션을 이용하여 요청받은 헤더에서 "Authorization" 과 "RefreshToken" 값을 추출합니다.

추출한 값을 userService의 renewToken 매개변수에 담아 보내줍니다

 

UserService

    public TokenResponse renewToken(String accessToken, String refreshToken) {
        // 1. 검증
        if (!jwtProvider.validateToken(refreshToken)) {
            throw new CustomIllegalStateException(ErrorCode.INVALID_JWT);
        }

        // 2. AccessToken 에서 UserId 가져오기
        Authentication authentication = jwtProvider.getAuthentication(accessToken);
        String userId = authentication.getName();

        // 3. Redis에서 Key값이 UserId인 RefreshToken 값 가져오기
        String redisRefreshToken = redisUtil.getData(userId);
        if (redisRefreshToken == null) {
            throw new CustomIllegalStateException(ErrorCode.INVALID_JWT);
        }

        // 4. 두 Refresh Token 일치하는지 확인
        if (!refreshToken.equals(redisRefreshToken)) {
            throw new CustomIllegalStateException(ErrorCode.NO_MATCHES_INFO);
        }

        // 5. 새로운 토큰 생성
        TokenResponse tokenResponse = jwtProvider.generateTokenDto(authentication);

        // 6. 저장소 정보 업데이트
        redisUtil.setDataExpire(userId, tokenResponse.getRefreshToken(), 1000 * 60 * 60 * 24 * 7);

        return tokenResponse;
    }

1. 전달받은 RefreshToken의 유효성을 검증한다

2. 검증이 통과하면 AccessToken에서 userId 값을 가져온다

3. Redis 에서 Key 값이 userID인 Value 값을 (RefreshToken) 가져와서 redisRefreshToken에 담는다

4. 1번에서 전달받았던 RefreshToken과 redisRefreshToken이 일치하는지 검증한다

5. 위에서 두 토큰의 값이 일치하면 새로운 토큰(AccessToken , RefreshToken)을 발급한다

6. Redis에 다시 userId를 Key값, 새로운 refreshToken을 Value 값으로 하고 유효기간을 다시 설정하여 저장한다

   이후 발급받은 토큰을 tokenResponse에 담아 사용자에게 전달한다

 

 JwtProvider

@Component
@Log4j2
public class JwtProvider {
    // application.yml 에 정의한 만료시간 가져오기
    @Value("${spring.jwt.token.access-expiration-time}")
    private long ACCESS_TOKEN_EXPIRE_TIME;

    @Value("${spring.jwt.token.refresh-expiration-time}")
    private long REFRESH_TOKEN_EXPIRE_TIME;

    private static final String AUTHORITIES_KEY = "auth";

    private final UserDetailsService userDetailsService;

    private final Key key;

    @Autowired
    // application.yml 에 정의된 jwt.secret 값을 가져와 JWT 를 만들 때 사용하는 암호화 키값을 생성
    public JwtProvider(@Value("${spring.jwt.secret}") String secretKey, UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        log.info("key : {}", key);
    }

    // 토큰 생성
    public TokenResponse generateTokenDto(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())           // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)            // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)            // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)        // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenResponse.builder()
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    // 인증 정보 추출
    public Authentication getAuthentication(String token) {
        String username = Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody().getSubject();
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // http 헤더에서 bearer 토큰 추출
    public String resolveToken(HttpServletRequest req) {
        String bearerToken = req.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    // Access 토큰을 검증
    public boolean validateToken(String token){
        try {
            Jwts.parser().setSigningKey(key).parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            // MalformedJwtException | ExpiredJwtException | IllegalArgumentException
            throw new IllegalArgumentException("Error on Token");
        }
    }

 application.yml

spring: 
    jwt:
        token:
          access-expiration-time: 43200000    # 12시간
          refresh-expiration-time: 604800000   # 7일
        secret: #문자열을 Base64로 인코딩한 값을 넣어준다 (64글자 이상으로 넣어줘야한다)

 RedisUtil

@Service
@RequiredArgsConstructor
@Transactional
public class RedisUtil {
    private final StringRedisTemplate redisTemplate;

    public String getData(String key){
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        return valueOperations.get(key);
    }

    public void setData(String key, String value){
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        valueOperations.set(key, value);
    }

    public void setDataExpire(String key, String value, long duration){
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        Duration expireDuration = Duration.ofSeconds(duration);
        valueOperations.set(key, value, expireDuration);
    }

    public void deleteData(String key){
        redisTemplate.delete(key);
    }
}

 

 TokenResponse

public class TokenResponse {
    private String accessToken;
    private String refreshToken;
    private Long accessTokenExpiresIn;
}

 

이번에도 잘못된게 있다면 알려주시면 정말 감사하겠습니다 !!