백/spring boot

spring security2 - refresh토큰 서버측 저장

남승현 2024. 8. 16. 01:09

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토큰이 저장되었음