[밍글] JWT + Redis를 이용한 로그인 및 토큰 재발급 구현하기

KIM TAEHYUN·2023년 6월 3일
0

Spring Security

목록 보기
2/3
post-thumbnail

저번 글에서 4번의 수정을 걸쳐 API 요청 및 토큰 재발급 로직을 개선해보았습니다.

이번 글에서는 4차 모델을 토대로 JWT를 활용해 로그인 API에서 access, refresh token을 발급하고,

재발급 API로 access token을 재발급하는 과정까지 적어보고자 합니다.

발급된 토큰을 검증하고 스프링 시큐리티를 통해 유저를 인증하는 과정은 다음 글에 적겠습니다.


JWT 발급 및 검증

JWT을 사용하기 위해서 build.gradle에 다음과 같은 dependency를 추가합니다.

implementation 'io.jsonwebtoken:jjwt:0.9.1'

이를 이용하여 토큰을 발급하고 검증할 수 있습니다.

TokenHelper

  • access, refresh token을 발급하고 검증하기 위한 클래스
  • 토큰을 검증하기 위한 코드는 일단 제외 (다음 글에 적을 예정)

주석 1~4번의 설정 값과 JwtHandler를 이용하여 accessToken과 refreshToken을 발급해줍니다.

@Service
@RequiredArgsConstructor
public class TokenHelper {

    @Value("${jwt.max-age.access}") // 1
    private Long accessTokenMaxAgeSeconds;
    @Value("${jwt.max-age.refresh}") // 2
    private Long refreshTokenMaxAgeSeconds;
    @Value("${jwt.key.access}") // 3
    private String accessKey;
    @Value("${jwt.key.refresh}") // 4
    private String refreshKey;

    private final JwtHandler jwtHandler; //5
    private final RedisService redisService; //6

    private static final String ROLE_TYPES = "ROLE_TYPES";
    private static final String MEMBER_ID = "MEMBER_ID";

    @Getter
    @AllArgsConstructor
    public static class PrivateClaims { //7
        private String memberId;
        private UserRole roleTypes;
    }

		//PrivateClaims으로 Access Token 생성
    public String createAccessToken(PrivateClaims privateClaims) {
        return jwtHandler.createToken(accessKey,
                Map.of(MEMBER_ID, privateClaims.getMemberId(), ROLE_TYPES, privateClaims.getRoleTypes()),
                accessTokenMaxAgeSeconds);
    }

		//PrivateClaims으로 Refresh Token 생성 및 Redis에 토큰 유효기간동안 {email, Refresh Token} 형태로 토큰 저장
    public String createRefreshToken(PrivateClaims privateClaims, String email) {
        String refreshToken = jwtHandler.createToken(refreshKey,
                Map.of(MEMBER_ID, privateClaims.getMemberId(), ROLE_TYPES, privateClaims.getRoleTypes()),
                refreshTokenMaxAgeSeconds);
        redisService.setValues(email, refreshToken, Duration.ofDays(refreshTokenMaxAgeSeconds));
        return refreshToken;
    }
		//토큰 재발급에서 쓰임 - Refresh Token이 유효한지 확인
    public Optional<PrivateClaims> parseRefreshToken(String token, String email) throws BaseException {
        return jwtHandler.checkRefreshToken(refreshKey, token, email).map(claims -> convert(claims));
    }

		 //Claims을 우리가 구현한 PrivateClaims으로 반환해준다. 
    private PrivateClaims convert(Claims claims) {
        return new PrivateClaims(claims.get(MEMBER_ID, String.class), claims.get(ROLE_TYPES, UserRole.class));
    }
}

각 메소드의 설명은 주석으로 적어놓았습니다.

// 1~4. @Value 어노테이션을 이용하여 설정 파일에 작성된 내용을 가져옵니다.

//5. TokenHelper는 JWT 파싱, 검증 등 로직을 담당하는 JwtHandler를 주입받고 있습니다

//6: Refresh Token을 발급하고 Redis에 저장하기 위해 필요한 의존성 입니다.

//7 PrivateClaims은 직접 정의하여 사용하는 Claim 입니다.
JWT의 body에는 String이나 Claims 인스턴스를 담을 수 있습니다.
인증/인가에 필요한 memberId와 UserRole을 저장하기 위해 직접 정의한 PrivateClaims을 만들어 JWT의 body에 넣어주었습니다.
이에 나중에 토큰을 검증할 때 PrivateClaim에 저장된 memberId와 UserRole을 이용해 DB에 접근 할 필요 없이 인증에 필요한 정보를 추출할 수 있습니다.

위와 같이 @Value로 설정 값들을 가져오기 위해 다음과 같이 application.yml을 수정해주겠습니다.

  • application.yml (.gitignore 추가 필수)
jwt:
  key:
    access: ****
    refresh: ****

  max-age:
    access:  1800 // 30분
    refresh: 604800 // 1주일

각 토큰 종류마다 key와 토큰 유효시간을 입력해줍니다.


JwtHandler

TokenHelper에서 의존하는 JwtHandler 클래스를 살펴보겠습니다.

JWT 파싱, 검증 등 직접 JWT를 다루는 로직을 담당하고 있습니다.

여기도 토큰을 검증하기 위한 코드는 나중에 보고, 일단 생성을 위한 코드만 적어놓았습니다.

Refresh Token 저장을 위해 RedisService를 주입받습니다.

  • unique한 key인 email을 통해 refresh token을 Redis에서 조회하고, 찾을 수 없다면
    • Refresh Token이 만료됐거나,
    • 유효하지 않은 Refresh Token임을 검증할 수 있습니다.
@Component
@RequiredArgsConstructor
public class JwtHandler {

  private String type = "Bearer";
  private final RedisService redisService;

	/**
   * PrivateClaims으로 토큰 생성
   */
  public String createToken(String key, Map<String, Object> privateClaims, long maxAgeSeconds) {
      Date now = new Date();
      return type + " " + Jwts.builder()
              .setIssuedAt(now)
              .setExpiration(new Date(now.getTime() + maxAgeSeconds * 1000L))
              //Adds all given name/value pairs to the JSON Claims in the payload
              .addClaims(privateClaims) //param: JWT claims to be added to the JWT body :<String memberId, UserRole roleTypes> 
              .signWith(SignatureAlgorithm.HS256, key.getBytes()) 
              .compact();
  }

  /**
   * refresh Token 재발급 시 검증: Redis에 해당 토큰이 있는지 & valid한 토큰인지 확인 
   */
  public Optional<Claims> checkRefreshToken(String key, String refreshToken, String email) throws BaseException {
      String redisRefreshToken = redisService.getValues(email);
      if (!refreshToken.equals(redisRefreshToken)) {
          throw new BadRequestException("토큰 재발급에 실패하였습니다.");
      }
      try {
          return Optional.of(Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(untype(refreshToken)).getBody());
      } catch (BadRequestException e) {
          throw new BaseException(DATABASE_ERROR);
      }
  }

	public String untype(String token) throws BadRequestException{
      if (token.length() < 6) {
          throw new BadRequestException("토큰을 입력해주세요.");
      }
      return token.substring(type.length());
  }

	//validateToken 등 생략 

Redis 설정

Redis(Remote Dictionary Server)는 NOSQL, 비 관계형 데이터베이스입니다.  KEY와 VALUE 구조로 이루어져 있으며 처리가 빠르고 유효 시간을 정해 데이터가 남지 않도록 할 수 있습니다.

Refresh token을 {email, refresh token}의 key, value 형태로 저장하고, 만료시간을 설정해 refresh token의 만료기간이 지났다면 Redis에서도 자동으로 삭제되게 하기 위해 Redis를 도입했습니다.

  • Redis를 사용하기 위해 application.yml 에 아래 코드를 추가해줍니다.
  • 기존에는 ec2에 서버를 올려 ec2에 Redis를 설치해서 쓸 수 있었지만, ECS Fargate로 전환하면서 AWS에서 제공하는 ElastiCache(Redis)를 도입했습니다.
  • ECS Fargate와 같은 VPC 내부에 AWS ElastiCache(Redis)를 만들어 연결해주었습니다. (ElastiCache는 같은 VPC 내부에서만 접속이 가능합니다.)
redis:
    host: AWS ElastiCache Redis 클러스터 리더 엔드포인트 //127.0.0.1 (local)
    port: 6379

RedisService

  • Redis 설정을 마쳤으니, JwtHandler에서 의존하고있는 RedisService를 살펴보겠습니다.

Redis 에는 다양한 메서드가 있지만, Refresh Token을 저장하고 불러오기 위한 주요 메소드들만 보겠습니다.

@Service
@RequiredArgsConstructor
public class RedisService {
    private final RedisTemplate<String, String> redisTemplate;
    
    public void setValues(String key, String data, Duration duration) { 
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    public String getValues(String key) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key); //return key or null when key does not exist
    }
}
  • ValueOperations<K,V>: Redis operations for simple (or in Redis terminology 'string') values; Redis Operations들을 정의해놓은 인터페이스 입니다.
  • values.set(K key, V value, Duration timeout) : Set the value and expiration timeout for key: {key, value}가 특정 유효 시간 동안만 저장되도록 합니다.
  • values.get(K Key): Get the value of key. Return null when key does not exist.
    Key인 email이 있다면 value인 Refresh Token을 리턴하고, 없다면 null을 반환합니다.

로그인 API - AuthService

이제 로그인에 필요한 토큰 관련 로직들을 다 작성했으니, 서비스 로직을 작성해보겠습니다.

위에 작성했던 TokenHelper 를 주입받아 로그인 API를 작성했습니다.

TokenHelper를 이용해 access, refresh token을 발급해주고, 로그인 성공 시 반환해줍니다.

public class PostLoginResponse { 
    private Long userId; //유저 식별
    private String email; //refresh token과 함께 토큰 재발급 API에 필요
    private String jwt; //access token - 모든 API 요청 헤더에 필요
    private String refreshJwt; //refresh token - 토큰 재발급에 필요
}
  • AuthService.java - 로그인 API 부분을 살펴보겠습니다. 불필요한 부분은 생략했습니다.
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class AuthService {
	private final AuthRepository authRepository;
	private final TokenHelper accessTokenHelper;
  	private final TokenHelper refreshTokenHelper;

   /**
   * 1.9 로그인 api
   */
  @Transactional
  public PostLoginResponse logIn (PostLoginRequest postLoginRequest) throws BaseException {
      //이메일, 비밀번호 단방향 암호화 
      Member member = authRepository.findMemberByEmail(postLoginRequest.getEmail()); //이메일로 유저 찾기
      if (member == null) { //없는 유저
          throw new BaseException(FAILED_TO_LOGIN); //1
      } 
			//비밀번호 불일치
      if (!(member.getPwd().equals(encryptPwd))) { //Member 에게 받아온 비밀번호와 방금 암호화한 비밀번호를 비교
          throw new BaseException(FAILED_TO_LOGIN); //1
      }
      //탈퇴 유저 재로그인 방지 
      if (member.getStatus().equals(UserStatus.INACTIVE)) { //2
          throw new BaseException(USER_DELETED_ERROR); 
      } //신고 유저 로그인 방지
      if (member.getStatus().equals(UserStatus.REPORTED)) { //2
          throw new BaseException(USER_REPORTED_ERROR);
      }
      try { //토큰 발급
          Long memberId = member.getId();
          UserRole memberRole = member.getRole();
          TokenHelper.PrivateClaims privateClaims = createPrivateClaims(memberId, memberRole);
          String accessToken = accessTokenHelper.createAccessToken(privateClaims);
          String refreshToken = refreshTokenHelper.createRefreshToken(privateClaims, postLoginRequest.getEmail());
		// 비교해서 이상이 없다면 jwt를 발급
          return new PostLoginResponse(memberId, postLoginRequest.getEmail(), member.getNickname(),member.getUniv().getUnivName().substring(0,3) ,accessToken, refreshToken);
      } catch (Exception e) {
          throw new BaseException(FAILED_TO_CREATEJWT);
      }
  }

익명 커뮤니티기에 DB에 이메일과 비밀번호를 모두 암호화한 채로 저장이 되어있습니다.

이에 request로 전달받은 이메일과 비밀번호를 암호화 한 뒤, 암호화 된 email로 Member를 조회합니다.

멤버 조회 및 비밀번호 검증이 통과되었다면 토큰에 넣어줄 PrivateClaims을 생성해줍니다. PrivateClaims는 TokenHelper에 정의된것처럼 memberId와 Role을 가지고 있습니다.

TokenHelper를 이용하여 PrivateClaims을 인자로 전달해 Access Token과 Refresh Token을 발급해줍니다.


토큰 재발급 API

이제 access token이 만료되었을 경우 호출하는 재발급 API를 살펴보겠습니다.

  • AuthService.java - AccessToken 재발급 API
    • 클라이언트에게 refresh Token과 email을 전달받고, TokenHelper(refreshTokenHelper) 를 통해 Refresh Token을 검증합니다. 검증에 성공한다면 토큰 안에 있던 PrivateClaims을 리턴 받습니다.
    • 받은 PrivateClaims를 이용해 access token과 refresh token을 둘 다 재발급 한 후 클라이언트에게 리턴해줍니다.
    • createRefreshToken은 refresh token을 Redis에 저장하는 로직을 포함하기에, Refresh Token이 재발급 될 때 Redis에 저장된 refresh token값도 새로운 토큰으로 덮어씌워지게 됩니다.
/**
 * 1.12 refresh token으로 access Token 발급
 */
public ReissueAccessTokenDTO reissueAccessToken(String rToken, String email) throws BaseException{
    TokenHelper.PrivateClaims privateClaims = refreshTokenHelper.parseRefreshToken(rToken, email).orElseThrow();
    String accessToken = accessTokenHelper.createAccessToken(privateClaims);
    String refreshToken = refreshTokenHelper.createRefreshToken(privateClaims, email); //refreshToken도 재발급
    return new ReissueAccessTokenDTO(accessToken, refreshToken);
}
  • TokenHelper.java - refreshTokenHelper.parseRefreshToken(String token, String email)
    • JwtHandler의 checkRefreshToken을 호출해 반환된 Claims 형태를 PrivateClaims 형태로 반환합니다.
public Optional<PrivateClaims> parseRefreshToken(String token, String email) throws BaseException {
    return jwtHandler.checkRefreshToken(refreshKey, token, email).map(claims -> convert(claims));
}

private PrivateClaims convert(Claims claims) {
      return new PrivateClaims(claims.get(MEMBER_ID, String.class), claims.get(ROLE_TYPES, UserRole.class));
  }
  • JwtHandler.java - checkRefreshToken(String key, String refreshToken, String email)
    • 위에서 작성한 redisService를 통해 Refresh Token을 찾고, 없다면 null을 반환합니다.
    • 찾은 Refresh Token을 파싱해 body에 담겨있는 Claims을 반환합니다.
 public Optional<Claims> checkRefreshToken(String key, String refreshToken, String email) throws BaseException {
      String redisRefreshToken = redisService.getValues(email);
      if (!refreshToken.equals(redisRefreshToken)) {
          throw new BadRequestException("토큰 재발급에 실패하였습니다.");
      }
      try {
          return Optional.of(Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(untype(refreshToken)).getBody());
      } catch (BadRequestException e) {
          throw new BaseException(DATABASE_ERROR);
      }
  }

마무리

이번 글에서는 JWT를 활용해 Access, Refresh Token을 발급하는 로그인 API를 구현해보았습니다.

TokenHelper, JwtHandler를 작성하여 access, refresh token을 발급하고,
RedisService를 이용한 재발급 API로 access, refresh token을 재발급하는 과정을 살펴보았습니다.

다음 글에서는, 발급된 토큰을 검증하고 스프링 시큐리티를 통해 유저를 인증하는 과정을 살펴보겠습니다.

감사합니다.

0개의 댓글