저번 글에서 4번의 수정을 걸쳐 API 요청 및 토큰 재발급 로직을 개선해보았습니다.
이번 글에서는 4차 모델을 토대로 JWT를 활용해 로그인 API에서 access, refresh token을 발급하고,
재발급 API로 access token을 재발급하는 과정까지 적어보고자 합니다.
발급된 토큰을 검증하고 스프링 시큐리티를 통해 유저를 인증하는 과정은 다음 글에 적겠습니다.
JWT을 사용하기 위해서 build.gradle에 다음과 같은 dependency를 추가합니다.
implementation 'io.jsonwebtoken:jjwt:0.9.1'
이를 이용하여 토큰을 발급하고 검증할 수 있습니다.
주석 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을 수정해주겠습니다.
jwt:
key:
access: ****
refresh: ****
max-age:
access: 1800 // 30분
refresh: 604800 // 1주일
각 토큰 종류마다 key와 토큰 유효시간을 입력해줍니다.
TokenHelper에서 의존하는 JwtHandler 클래스를 살펴보겠습니다.
JWT 파싱, 검증 등 직접 JWT를 다루는 로직을 담당하고 있습니다.
여기도 토큰을 검증하기 위한 코드는 나중에 보고, 일단 생성을 위한 코드만 적어놓았습니다.
Refresh Token 저장을 위해 RedisService를 주입받습니다.
@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(Remote Dictionary Server)는 NOSQL, 비 관계형 데이터베이스입니다. KEY와 VALUE 구조로 이루어져 있으며 처리가 빠르고 유효 시간을 정해 데이터가 남지 않도록 할 수 있습니다.
Refresh token을 {email, refresh token}의 key, value 형태로 저장하고, 만료시간을 설정해 refresh token의 만료기간이 지났다면 Redis에서도 자동으로 삭제되게 하기 위해 Redis를 도입했습니다.
redis:
host: AWS ElastiCache Redis 클러스터 리더 엔드포인트 //127.0.0.1 (local)
port: 6379
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
}
}
이제 로그인에 필요한 토큰 관련 로직들을 다 작성했으니, 서비스 로직을 작성해보겠습니다.
위에 작성했던 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 - 토큰 재발급에 필요
}
@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을 발급해줍니다.
이제 access token이 만료되었을 경우 호출하는 재발급 API를 살펴보겠습니다.
/**
* 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);
}
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));
}
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을 재발급하는 과정을 살펴보았습니다.
다음 글에서는, 발급된 토큰을 검증하고 스프링 시큐리티를 통해 유저를 인증하는 과정을 살펴보겠습니다.
감사합니다.