프로젝트에 들어가기전에 공부한 JWT토큰 사용법과 엑세스,리프레쉬토큰 관리의 지식을 가지고 프로젝트에 구현한 내용을 정리하려고 한다.
사실 JWT토큰을 이용할때 리프레쉬토큰 관리를
DB
을 사용하지 않고Redis
를 사용하여 구현하는 것이 성능면에서 더 좋다.
프로젝트에 해당 토큰 관리를 구현할때Redis
에 대해서 공부하지 않았었고 사용하는 법을 제대로 알지 못하여DB
에 저장하여 관리하는 방법으로 리프레쉬 토큰을 구현하였다.프로젝트 후반부에
JWT
,Redis
를 이용한 로그아웃 기능을 구현하면서Redis
에 대해서 공부하였기 때문에 지금와서Redis
로 바꾸기에는 무리가 있다고 생각이 들어서 수정하지 않고 계속 진행하였다.
하지만DB
을 사용하여 구현하였어도 토큰관리가 왜 필요한지, 어떻게 구현하면되는지를 공부하여 연습했다는것에 만족한다.
AccessToken만 사용하는 경우 만약 토큰의 유효기간을 24시간으로 설정하게 되면 로그인을 한 24시간후 사용자는 로그아웃 되면서 잦은 로그인을 시도해야 되는는 경우가 발생하게 된다.
그렇다고 유효기간을 늘리게 되면 보안문제가
가 발생하여 무작정 늘릴수가 없다.
보안문제
로는 세션
과 다르게 토큰
인증방식은 stateless 하다. 즉 서버가 상태를 보관하지않아 한번 발급한 토큰에 대한 제어권은 서버가 가지고있지 않다.
그렇기 때문에 이런 토큰이 탈취당한다면 해커가 제어권을 마음대로 사용할수 있고 서버측에서도 토큰이 만료되때까지 기다리는것 말고는 따른 방법이 없게 된다.
앞서 설명한 AccessToken만 사용하는 경우의 문제를 해결하기 위함이다.
RefreshToken의 비해 AccessToken의 유효기간을 아주 짧게 설정하여 자주 재발급 받도록하여 토큰 탈취와 같은 보안문제를 해결한다.
RefreshToken은 안전한곳에 저장시킨뒤, AccessToken을 header에 실어 인증이 필요한 api에 요청을 보내게 된다.
만약 AccessToken의 유효기간이 종료되게 되면 저장하고 있던 RefreshToken을 가지고 AccessToken을 재발급 요청과정을 거치게 된다.
RefreshToken의 유효기간이 남아있다면 AccessToken을 새로 발급하여 잦은 로그아웃을 해결한다.
만약 RefreshToken 또한 유효기간이 종료되게 되면 재로그인을 통해 RefreshToken, AccessToekn을 모두 새로 발급받게 된다.
@Data
@AllArgsConstructor
@NoArgsConstructor
public class JwtToken {
private String Authorization; //엑세스 토큰 헤더 key
private String RefreshToken; //리프레쉬 토큰 헤더 key
}
2개의 토큰을 하나의 객체로 구성하였다.
application.yml
build.gradle
/**
* 토큰과 관련된 기능
*/
@Slf4j
@Component //빈으로 등록
public class JwtProvider {
public static final long ACCESSTOKEN_TIME = 1000 * 60 * 30; // 30분
public static final long REFRESHTOKEN_TIME = 1000 * 60 * 60 * 24 * 7; //7일
public static final String ACCESS_PREFIX_STRING = "Bearer ";
public static final String ACCESS_HEADER_STRING = "Authorization";
public static final String REFRESH_HEADER_STRING = "RefreshToken";
private final Key key;
private final RedisTemplate<String,String> redisTemplate;
private final UserRepository userRepository;
public JwtProvider(@Value("${jwt.secret}")String secret, UserRepository userRepository, RedisTemplate<String,String> redisTemplate) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.userRepository = userRepository;
this.redisTemplate = redisTemplate;
}
//토큰 생성
public JwtToken createJwtToken(Long userId, String nickname ,String role) {
//엑세스 토큰
String accessToken = ACCESS_PREFIX_STRING + Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("userId",userId)
.claim("nickName", nickname)
.claim("role", role)
.setExpiration(new Date(System.currentTimeMillis() + ACCESSTOKEN_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
//리프레시 토큰
String refreshToken = Jwts.builder()
.setSubject(String.valueOf(userId) + "_refresh")
.claim("userId", userId)
.setExpiration(new Date(System.currentTimeMillis() + REFRESHTOKEN_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return new JwtToken(accessToken, refreshToken);
}
//access token 만 생성 -> refresh 토큰 요청이 왔을때 사용됨
public String createAccessToken(Long user_id, String nickname, String role) {
return ACCESS_PREFIX_STRING + Jwts.builder()
.setSubject(String.valueOf(user_id))
.claim("userId",user_id)
.claim("nickName", nickname)
.claim("role", role)
.setExpiration(new Date(System.currentTimeMillis() + ACCESSTOKEN_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
//엑세스 토큰에서 인증 정보 객체(Authentication) 생성
public Authentication getAuthentication(String token){
//이전에 토큰 검증은 끝냈으니 Claims 를 받아와도 에러가 발생하지 않는다.
Claims claims = parseClaims(token);
//1. 토큰안에 필요한 Claims 가 있는지 확인
if(claims.get("userId")==null && claims.get("nickName")==null && claims.get("role")==null)
return null;
//2. DB 에 사용자가 있는지 확인 -> 탈퇴했을 경우를 위해서
Long userId = Long.valueOf(claims.get("userId").toString());
Optional<User> findUser = userRepository.findByUserId(userId);
if(findUser.isEmpty())
return null;
UserDetails userDetails = new PrincipalDetails(findUser.get());
//추후에 권한 검사를 하기 때문에 credentials 는 굳이 필요없음
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
//토큰 유효성 검사
public Boolean validationToken(String token){
try{
Jwts.parserBuilder().setSigningKey(key)
.build().parseClaimsJws(token).getBody();
}catch (SignatureException e ){
log.error("SignatureException", e.getMessage());
return false;
}catch (ExpiredJwtException e) {
log.error("ExpiredJwtException", e.getMessage());
return false;
} catch (MalformedJwtException e) {
log.error("MalformedJwtException", e.getMessage());
return false;
}catch (IllegalArgumentException e) {
log.error("IllegalArgumentException", e.getMessage());
return false;
}catch (Exception e ){
log.error("Exception", e.getMessage());
return false;
}
return true;
}
//엑세스 토큰의 만료시간
public Long getExpiration(String accessToken){
Date expiration = Jwts.parserBuilder().setSigningKey(key)
.build().parseClaimsJws(accessToken).getBody().getExpiration();
long now = new Date().getTime();
return expiration.getTime() - now;
}
//토큰 Claims 가져오기
public Claims parseClaims(String accessToken){
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
}
//로그아웃된 엑세스 토큰인지 검증
public Boolean validBlackToken(String accessToken) {
//Redis 에 있는 엑세스 토큰인 경우 로그아웃 처리된 엑세스 토큰임.
String blackToken = redisTemplate.opsForValue().get(accessToken);
if(StringUtils.hasText(blackToken))
return false;
return true;
}
}
createJwtToken
: 엑세스토큰, 리프레쉬 토큰을 생성할때 사용되는 메서드(로그인 시)
createAcessToken
: 토큰 재발급 요청이 왔을때 엑세스 토큰을 재발급하는 메서드
getAuthentication
: 스프링 시큐리티 인증 필터에서 JWT토큰 인증이 정상적으로 이루어졌을 때,
시큐리티 인가 검증 필터를 위해서 인증객체(Authentication)을 생성하는 메서드.
validationToken
: 토큰 유효성 검사하는 메서드
getExpiration
Redis
에 엑세스 토큰을 담기 위해 사용됨 validBlackToken
: 로그아웃된 토큰인지 검증
parseClaims
: JWT 토큰의 Claims을 가져오는 메서드
로그인
, 토큰 재발급
시 사용되는 서비스
/**
* jwt 토큰과 관련된 기능을 포함한 서비스
*/
@Service
@RequiredArgsConstructor
public class JwtService {
private final RefreshTokenService refreshTokenService;
private final JwtProvider jwtProvider;
private final UserService userService;
//토큰 생성 및 저장
public JwtToken login(Long userId, String nickName, String role) {
//토큰 생성
JwtToken createToken = jwtProvider.createJwtToken(userId, nickName, role);
//리프레시 토큰이 있는 사용자인지 없는 사용자인지 판별
Optional<RefreshToken> findRefreshToken = refreshTokenService.findRefreshToken(userId);
if(findRefreshToken.isEmpty()) { //회원가입하고 처음 로그인 하는 사용자, 로그아웃한 사용자
RefreshToken refreshToken = new RefreshToken(createToken.getRefreshToken());
refreshTokenService.saveRefreshToken(userId, refreshToken);
} else { //기존 이용자(회원수정을 한 사용자인 경우)
refreshTokenService.updateRefreshToken(userId, createToken.getRefreshToken());
}
return createToken;
}
//리프레쉬토큰을 통해 accessToken, refreshToken 재발급
public JwtToken reissue(String token) {
//리프레쉬 토큰에서 userId 추출
Long userId = jwtProvider.getUserId(token);
//User 검색
User findUser = userService.findUserByUserId(userId);
//엑세스 토큰 재생성
String accessToken = jwtProvider.createAccessToken(userId, findUser.getNickname(), findUser.getRole().name());
return new JwtToken(accessToken, token);
}
}
@Entity @Getter @Setter
@Table(name = "refresh_token")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long token_id;
private String value;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
//생성자
public RefreshToken() {}
public RefreshToken(String refresh_token) {this.value = refresh_token;}
//setter
public void setUser(User user){
this.user = user;
}
public void setRefreshToken(String refreshToken){
this.value = refreshToken;
}
}
사용자들의 refresh 토큰을 따로 관리하기 위해서 별도의 DB를 구성하여 관리하였다.
사용자 별로 refresh 토큰은 1:1로 매핑되어있다.
💡여기서 하나를 짚고 넘어가자면
User
와RefreshToken
의 관계는일대일
로 관계가 매핑되어있다.
일대일
연관관계 매핑에서 외래키를 관리하는 곳은대대일,일대다
처럼 보통 정해져 있지 않고 프로젝트의 성격과 상황에 따라서 달라진다.만약 프로젝트가 진행되면서 리프레쉬 토큰이 여러개(다)가 될수 있다면
RefreshToken
에 외래키를 두는것이 확작성,유연성면에서 더 좋을 것이다.그렇다고
RefreshToken 엔티티
에 두는것이 항상 좋지는 않을 것이다. 왜냐하면User
에 외래키 주인을 둔다면User
을 조회할때 이미 RefreshToken의 참조를 이미 가지고 있기 때문에 상황에 따라 성능상 이점이 있을수 있다.프로젝트를 진행하면서 판단한 것은
User
의 속성 정보들을 조회할때RefreshToken
의 정보가 필요한 경우가 없었다.
즉RefreshToken
의 정보가 필요할때는 순수 리프레쉬 토큰 정보만 필요한 경우 밖에 없었다.추가로
User
에 외래키 주인을 두는것보다RefreshToken
에 외래키를 두는것이
조회,추가,업데이트,삭제 면에 쿼리 성능면에서 더 좋을거 같다고 판단하였다.
public interface RefreshTokenRepository {
//사용자 리프레쉬 토큰 조회
public Optional<RefreshToken> find(Long userId);
//리프레시 토큰 저장
public void save(RefreshToken refreshToken);
//리프레쉬 토큰 삭제
public void delete(Long userId);
}
@Repository
public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
@Autowired
private EntityManager em;
private JPAQueryFactory query;
public RefreshTokenRepositoryImpl(EntityManager em) {
query = new JPAQueryFactory(em);
}
@Override
public Optional<RefreshToken> find(Long userId) {
RefreshToken refreshToken = query.select(QRefreshToken.refreshToken)
.from(QRefreshToken.refreshToken)
.where(userIdEq(userId))
.fetchFirst();
return Optional.ofNullable(refreshToken);
}
@Override
public void save(RefreshToken refreshToken) {
em.persist(refreshToken);
}
@Override
public void delete(Long userId) {
query.delete(QRefreshToken.refreshToken)
.where(userIdEq(userId))
.execute();
}
private BooleanExpression userIdEq(Long userId) {
return (userId==null)?null: QUser.user.user_id.eq(userId);
}
}
기본적으로 필요한 기능을 구현하였다.
이번에 JPA
와 QueryDsl
을 사용하여 구현하였는데 다음 프로젝트를 진행할때는 Spring JPA
기술을 사용해 보아야겠다.
다음 프로젝트 하기전까지 현재 수강중인 Spiring Data JPA
를 완강해야겠다.😊
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
//사용자의 refreshToken 가져오는 메서드
public Optional<RefreshToken> findRefreshToken(Long userId) {
return refreshTokenRepository.find(userId);
}
//refreshToken 저장 메서드
@Transactional
public RefreshToken saveRefreshToken(Long userId, RefreshToken refreshToken) {
Optional<User> findUser = userRepository.findByUserId(userId);
refreshToken.setUser(findUser.get());
refreshTokenRepository.save(refreshToken);
return refreshToken;
}
//refreshToken 업데이트 메서드
@Transactional
public RefreshToken updateRefreshToken(Long userId, String refreshToken) {
Optional<RefreshToken> findToken = refreshTokenRepository.find(userId);
findToken.get().setRefreshToken(refreshToken);
return findToken.get();
}
//사용자의 리프레쉬 토큰과 일치하지 검증
public void validRefreshTokenValue(Long userId, String refreshToken) {
Optional<RefreshToken> findToken = refreshTokenRepository.find(userId);
String token = findToken.map(t -> new String(t.getValue())).orElseThrow(() -> new TokenValidException());
if(!refreshToken.equals(token))
throw new TokenValidException();
}
}
로그인 시 토큰을 발급하게 되는데,
엑세스 토큰과 더불어 DB에 있는 사용자의 리프레쉬 토큰도 같이 발급하여 업데이트를 하도록 구현하였다.
이에 필요한 리프레쉬 토큰을 저장, 업데이트 서비스 기능을 구현하였다.
토큰을 재발급하기 위해서 사용자는 리프레쉬 토큰을 전달하게 되는데 해당 리프레쉬 토큰이 DB에 저장되어있는
사용자의 리프레쉬 토큰과 일치하는지 확인하기 위한 메서드를 구현해놓았다.
@RequiredArgsConstructor
public class RefreshTokenAuthInterceptor implements HandlerInterceptor {
private final JwtProvider jwtProvider;
private final RefreshTokenService refreshTokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. Request Header 토큰 추출
String token = getToken(request);
//2. 리프레쉬 토큰 유효성 검사
if(!jwtProvider.validationToken(token))
throw new TokenValidException();
//3. 리프레쉬 토큰에서 Claims 검증 -> 위에서 유효성 검사를 했기 때문에 Claims 를 가져오는데는 오류가 발생하지 않음
Claims claims = jwtProvider.parseClaims(token);
if(claims.get("userId")==null)
throw new TokenValidException();
Long userId = Long.valueOf(claims.get("userId").toString());
//4. DB의 리프레쉬 토큰과 일치하는지 확인
refreshTokenService.validRefreshTokenValue(userId, token);
return true;
}
//Request Header 에서 토큰 추출
private String getToken(HttpServletRequest request) {
String token = request.getHeader(jwtProvider.REFRESH_HEADER_STRING);
if(!StringUtils.hasText(token))
throw new TokenValidException();
return token;
}
}
인증이 필요한 리소스 접근에 대한 인증,인가 검증은 스프링 시큐리티
를 통해 이루어진다.
(스프링 시큐리티
기능은 다음 포스터에서 구현되어있다.)
토큰 재발급을 받을 때 사용자는 리프레쉬 토큰을 가지고 요청하게 되는데, 엑세스 토큰과 마찬가지로 리프레쉬 토큰도 검증을 할 필요가 있다고 생각하였다.
그래서 인터셉터
를 사용하여 리프레쉬 토큰 검증을 구현해보았다.
참고로 인터셉터에서 발생한 에러는
ControllerAdvice
를 통해 처리하였다.
@Slf4j
@RestController
@RequestMapping("/codebox")
@RequiredArgsConstructor
@Api(tags = "리프레시 토큰 관련 api")
public class RefreshTokenController {
private final JwtService jwtService;
@PostMapping("/refreshToken") //인증,권한이 필요한 uri
@ApiOperation(value = "토큰 재발급 api", notes = "엑세스 토큰이 만료되었을때 리프레시 토큰으로 요청하게 되면 엑세스,리프레시 토큰을 재발급해줍니다.")
@ApiResponses({
@ApiResponse(code=200, message = "토큰이 재발급되었습니다."),
@ApiResponse(code=401, message = "인증에 실패하였습니다.", response = BaseErrorResult.class),
@ApiResponse(code=500, message = "Internal server error", response = BaseErrorResult.class)
})
@ApiImplicitParam(name = JwtProperties.REFRESH_HEADER_STRING, value = "리프레쉬 토큰",required = true)
public DataResponse<UserTokenDto> recreateToken(@RequestHeader(JwtProperties.REFRESH_HEADER_STRING) String refreshToken) {
JwtToken jwtToken = jwtService.reissue(refreshToken);
UserTokenDto userTokenDto = new UserTokenDto(jwtToken);
return new DataResponse<>("200", "토큰이 재발급되었습니다.",userTokenDto);
}
}
재발급 받기 위한 Controller
를 정의한 모습이다.
해당 컨트롤러에 API 까지 도달하게 되면 리프레쉬 토큰 검증이 완료 되었기 때문에 전달받는 리프레쉬 토큰을 가지고
토큰을 재발급하게 된다.
안녕하세요 덕분에 소셜 로그인까지 구현해보았습니다. 자세한 포스팅 감사드려요.
궁금한 점이 있는데요, Refresh Token을 user에서 분리하여 따로 id로 관리하는 이유가 있을까요?