spring security2 - refresh토큰 서버측 저장
JWT를 발급하여 클라이언트측으로 전송하기만 하면 -> 인증/인가에 대한 주도권 자체가 클라이언트측에 맡겨짐
따라서 JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기까지 서버측에서는 그것을 막을 수 없으며, 프론트측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제가 되었다면 피해를 입는다
-> sol) 생명주기가 긴 Refresh 토큰은 발급시 서버측 저장소에 기억 후 기억되어 있는 Refresh 토큰만 사용할 수 있도록 서버측에서 주도권 가져야 함
<구현 방법>
발급시 Refresh토큰을 서버측 저장소에 저장하고, 갱신시 기존 Refresh 토큰을 삭제하고 새로 발급한 Refresh토큰을 저장해야 한다
-> refresh 토큰 저장소 : MySQL / Redis
두 가지 모두 사용해볼것이다!
1) MySQL
사용자가 pc, 핸드폰 등등 다중 로그인을 진행할 경우, 하나의 계정으로 동시 로그인이 발생할 수 있는데 기기가 바뀔때마다 다시 로그인하는 게 귀찮을 수있으므로 로그인 시 발생하는 successfulAuthentication에서는 기존 refresh 토큰 삭제하지 않고 저장만 함
-> 사용자가 다수의 디바이스(예: 모바일, 태블릿, 데스크탑 등)에서 동시에 로그인할 수 있도록 허용
<Refresh 엔티티>
@Entity
@Getter
@Setter
public class Refresh {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private Long user_id;
private String refresh;
private String expiration;
}
<Refresh Repository>
public interface RefreshRepository extends JpaRepository<Refresh, Long> {
Boolean existsByRefresh(String refresh);
@Transactional
void deleteByRefresh(String refresh);
}
<LoginFilter> -> 로그인 성공시 refresh토큰 만든 후 repository에 저장
//로그인 성공시, jwt 토큰 만들어 반환
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Long userId=userDetails.getId();
String username=authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth=iterator.next();
String role=auth.getAuthority();
//토큰 생성
String access=jwtUtil.createJwt("access", username, role, userId,600000L);
String refresh=jwtUtil.createJwt("refresh", username, role, userId,86400000L);
//refresh 토큰 저장
addRefreshEntity(username, userId, refresh, 86400000L);
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
private void addRefreshEntity(String username, Long userId, String refresh, Long expiredMs){
Date date=new Date(System.currentTimeMillis()+expiredMs);
Refresh refreshEntity=new Refresh();
refreshEntity.setUsername(username);
refreshEntity.setUser_id(userId);
refreshEntity.setRefresh(refresh);
refreshEntity.setExpiration(date.toString());
refreshRepository.save(refreshEntity);
}
<Reissue Service> -> refresh 토큰 생성 후 기존 refresh 토큰 삭제하고 새 refresh 토큰을 서버에 저장
//refresh 토큰이 정상이라면
String username=jwtUtil.getUsername(refresh);
String role=jwtUtil.getRole(refresh);
Long userId=jwtUtil.getUserId(refresh);
//새 JWT토큰 생성
String newJwt=jwtUtil.createJwt("access",username, role, userId, 600000L);
String newRefresh=jwtUtil.createJwt("refresh", username, role,userId, 86400000L);
refreshRepository.deleteByRefresh(refresh);
addRefreshEntity(username,userId,newRefresh, 8640000L);
//response
response.setHeader("access", newJwt);
response.addCookie(createCookie("refresh", newRefresh));
return ResponseEntity.ok("새 access 토큰 발급");
}
2) Redis
Redis란?
인메모리 DB라 조회성능이 매우 빠름
cmd창 관리자 모드에 다음 명령어 입력
//설치
docker pull redis
//실행
docker run --name redis -p 6379:6379 -d redis
//Redis-cli 접속
docker exec -it redis redis-cli
cf) 설치 후 나중에 접근하려면 docker desktop에서 redis실행시킨 후 redis-cli에 접근해야 한다
<build.gradle>
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
<Redis 설정>
Spring Data Redis에서 사용할 수 있는 Redis Client 구현체 : Lettuce와 Jedis
cf) Redis Client란? : Redis 데이터베이스와 통신하기 위해 사용하는 라이브러리나 도구
클라이언트는 Redis 서버로 명령을 보내고, 데이터를 저장, 조회, 수정하는 등의 작업을 수행할 수 있게해줌
spring-boot-starter-data-redis를 사용하면 의존성 설정 없이 Lettuce를 사용할 수 있고 성능도 좋아서 Lettuce를 사용 !!
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
<RedisConfig>
@Configuration
public class RedisConfig {
//application.properties에서 정의한 값 가져옴
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
}
Redis Server에는 Entity가 아닌 클라이언트에게 전송할 DTO를 저장한다.
따라서 refresh토큰 Dto에 해당하는 RefreshToken 구현하기
@Getter
@RedisHash(value="refreshToken", timeToLive=86400)
public class RefreshToken {
@Id
private String refreshToken; //value
private Long userId;
public RefreshToken(String refreshToken, Long userId){
this.refreshToken = refreshToken;
this.userId = userId;
}
}
- @RedisHash를 통해 Caching할 DTO를 지정한다.
- value : Key의 접두사
- timeToLive : Redis Server 내에서의 만료 시간( Redis에서 해당 객체를 저장할 때 해당 객체의 생존 기간)
- @Id : Redis Server에서 해당 DTO를 해싱할 Key를 지정한다.
유저 id 같은 식별자를 키로 -> 한 유저가 여러 토큰을 가질 수도 있어서(ex) 여러 디바이스로 접속하는 경우) 토큰 관리가 복잡
토큰 값 자체를 데이터베이스 키로 사용 -> 토큰 값이 유일하기에 각 토큰을 직접 참조가능
따라서 난 refreshToken을 id로 지정함
redis 저장소의 key로 {value}:{@Id 어노테이션을 붙여준 값}이 저장됨
-> refreshToken:refreshToken값
RedisRepository 구현 방법
1) RedisTemplate 이용
2) Repository 이용
-> Repository는 일반적으로 쓰는 JpaRepository와 사용법이 유사 (but JpaRepository를 상속받지 않음!!)
-> CrudRepository를 상속받으며 별도의 Configuration 의존성 추가가 필요하지 않고 Redis Template 방식보다 훨씬 구현이 간편
<RefreshTokenRepository>
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
}
<LoginFilter 내 successfulAuthentication>
//로그인 성공시, jwt 토큰 만들어 반환
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Long userId=userDetails.getId();
String username=authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth=iterator.next();
String role=auth.getAuthority();
//토큰 생성
String access=jwtUtil.createJwt("access", username, role, userId,600000L);
String refresh=jwtUtil.createJwt("refresh", username, role, userId,86400000L);
//redis RefreshToken 생성
RefreshToken redis=new RefreshToken(refresh, userId);
System.out.println("userID :"+userId);
//RedisRepository에 RefreshToken 저장
refreshTokenRepository.save(redis);
/*
//refresh 토큰 저장
addRefreshEntity(username, userId, refresh, 86400000L);
*/
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
-> 생성한 refresh 토큰을 이용해 RefreshToken Dto 객체를 생성하고 이를 refreshTokenRepository에 저장
로그인 후 keys * 명령어로 모든 키 조회 결과 refreshToken이 잘 저장됨
<Reissue Service>
: access 만료시 사용. refresh token으로 새로운 access토큰과 refresh 토큰 발급
@Service
public class ReissueService {
private final JWTUtil jwtUtil;
//private final RefreshRepository refreshRepository;
private final RefreshTokenRepository refreshTokenRepository;
public ReissueService(JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository) {
this.jwtUtil = jwtUtil;
//this.refreshRepository = refreshRepository;
this.refreshTokenRepository = refreshTokenRepository;
}
public ResponseEntity<String> reissue(HttpServletRequest request, HttpServletResponse response) {
//refresh 토큰 얻기
String refresh=null;
Cookie[] cookies=request.getCookies();
for(Cookie cookie:cookies){
if(cookie.getName().equals("refresh")){
refresh=cookie.getValue();
}
}
//refresh 토큰 존재여부 체크
if(refresh==null){
return ResponseEntity.badRequest().body("Refresh token is empty");
}
//refresh 토큰 데이터베이스에 저장여부 체크
RefreshToken refreshToken=refreshTokenRepository.findById(refresh).orElse(null);
if(refreshToken==null){
return ResponseEntity.badRequest().body("Refresh token is invalid");
}
//refresh 토큰 만료 여부 체크
try{
jwtUtil.isExpired(refresh);
}catch(ExpiredJwtException e){
return ResponseEntity.badRequest().body("refresh token expired");
}
//토큰이 refresh인지 확인(발급시 페이로드에 명시)
String category=jwtUtil.getCategory(refresh);
if(!category.equals("refresh")){
return ResponseEntity.badRequest().body("invalid refresh token");
}
//refresh 토큰이 정상이라면
String username=jwtUtil.getUsername(refresh);
String role=jwtUtil.getRole(refresh);
Long userId=jwtUtil.getUserId(refresh);
//새 JWT토큰 생성
String newJwt=jwtUtil.createJwt("access",username, role, userId, 600000L);
String newRefresh=jwtUtil.createJwt("refresh", username, role,userId, 86400000L);
//redis 사용시
refreshTokenRepository.delete(refreshToken);
RefreshToken newRefreshToken=new RefreshToken(newRefresh, userId);
refreshTokenRepository.save(newRefreshToken);
/* mySQL 사용시
refreshRepository.deleteByRefresh(refresh);
addRefreshEntity(username,userId,newRefresh, 8640000L);
*/
//response
response.setHeader("access", newJwt);
response.addCookie(createCookie("refresh", newRefresh));
return ResponseEntity.ok("새 access 토큰 발급");
}
private Cookie createCookie(String key,String value){
Cookie cookie=new Cookie(key,value);
cookie.setMaxAge(24*60*60);
//cookie.setSecure(true);
//cookie.setPath("/");
cookie.setHttpOnly(true);
return cookie;
}
/*
//mySQL refreshRepository 사용시
private void addRefreshEntity(String username,Long userId,String newRefresh,Long expiredMs){
Date date = new Date(System.currentTimeMillis() + expiredMs);
Refresh refresh=new Refresh();
refresh.setUsername(username);
refresh.setUser_id(userId);
refresh.setRefresh(newRefresh);
refresh.setExpiration(date.toString());
refreshRepository.save(refresh);
}
*/
}
-> refresh토큰이 refreshTokenRepository (redis)에 저장되어있나 검증
-> 모든 게 정상이면 기존에 저장되어있던 refreshToken을 delete하여 repository에서 삭제하고, 새로 생성한 refresh토큰을 refreshToken 객체로 만들어 repository에 저장
-> /reissue를 통해 새 access토큰과 refresh 토큰을 발급 받은 후, 로그인해서 생겨 저장된 ~//wPE refresh토큰이 지워지고 새로 발급받은 ~tws refresh토큰이 저장되었음