Spring

Redis를 활용한 Jwt 발급 (refreshtoken, rotate)

개바리여리 2025. 4. 10. 18:23

 

 

- 개발환경

* JAVA 17

* Spring Boot 3.3.10

* IDE : Intellij

 

- 의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'

    //jwt 의존성
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'


    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

- application.properties

 

spring.application.name=SpringJWT
server.port=8088


spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/jwt?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=123456

spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

spring.data.redis.host=localhost  //레디스가 설치된 주소
spring.data.redis.port=6379 // 레디스 기본포트가 6379

// 아무글자나 넣어도 무방 어느정도 길이만되면 ok,
// 해당 키는 서버만 들고있어여 한다. 개인키 같은 역할을 함.
spring.jwt.secret=randomStringlaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

 

 

우선 로그인 기능을 위해 Spring Security를 설정해준다.

@Configuration
@EnableWebSecurity
public class SecurityConfg {

    private final AuthenticationConfiguration authenticationConfiguration;
    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;
    private final JwtTokenReadisService jwtTokenReadisService;

    public SecurityConfg(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil, RefreshRepository refreshRepository, JwtTokenReadisService jwtTokenReadisService) {

        this.authenticationConfiguration = authenticationConfiguration;
        this.jwtUtil = jwtUtil;
        this.refreshRepository = refreshRepository;
        this.jwtTokenReadisService = jwtTokenReadisService;
    }
    //AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

        return configuration.getAuthenticationManager();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf((auth) -> auth.disable());
        http
                .formLogin((auth) -> auth.disable());

        http
                .httpBasic((auth) -> auth.disable());

        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/login", "/", "join", "/user", "/error", "/logout").permitAll()
                        .requestMatchers("/admin", "/superuser").hasRole("ADMIN")
                        .requestMatchers("/reissue").permitAll()
                        .anyRequest().authenticated());

        //커스텀 필터 등록
        http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, jwtTokenReadisService), UsernamePasswordAuthenticationFilter.class);

        http
                .addFilterBefore(new JwtFilter(jwtUtil, jwtTokenReadisService), UsernamePasswordAuthenticationFilter.class);
        http
                .addFilterBefore(new CustomLogoutFilter(jwtUtil, jwtTokenReadisService), LogoutFilter.class);

        //세션 설정 (stateless 상태)
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();

    }
}

 

그리고 Refresh 토큰을 DB가 아닌 Redis를 통하여 제어할 것이므로 Redis 설정을 해준다.

 

@Configuration
public class RedisConfig {


    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory();
    }

    //Redis는 내부적으로 데이터를 바이트 배열(byte[]) 형태로 저장
    //자바객체나 문자열을 Redis에 저장하려면 먼저 바이트로 변환하고 가져올때는 역직렬화 해야 함.
    //기본적으로 스프링에서 제공해주기는 함. 없어도되지만 값에 대한 커스텀이 필요할 경우 등록해서 사용, 여기서는 기본 스프링이 제공하는 템플릿사용
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        //모든 키와 값이 UTF-8 문자열로 직렬화 된다.
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        return template;
    }
}

 

이번엔 데이터를 반환해주는 api 및 화면 매핑을 위한 컨트롤러 생성

 

@RestController
public class AdminController {


    @GetMapping("/admin")
    public String admin(){
        return "admin";
    }
}

@Controller
public class MainController {

    @GetMapping("/")
    @ResponseBody
    public String mainController(){
        return "Main Controller";
    }

    @GetMapping("/user")
    public String userPage(){
        return "/user";
    }


}

 

- Redis 기능 함수 모음 클래스들

@Service
@RequiredArgsConstructor
public class JwtTokenReadisService {

    private final RedisTemplate<String, String> redisTemplate;

    // refresh 토큰 저장 (유효 시간 설정)
    public void saveRefreshToken(String userId, String refreshToken, long expirationMills){
        redisTemplate.opsForValue().set(userId, refreshToken, expirationMills, TimeUnit.MILLISECONDS);
    }

    //token 조회
    public String getRefreshToken(String userId){
        return redisTemplate.opsForValue().get(userId);
    }

    //  Refresh 토큰 삭제
    public void deleteRefreshToken(String userId) {
        redisTemplate.delete(userId);
    }

}

 

아래는 리프레쉬 토큰을 통해 Access토큰을 재발급해주는 API 및 리프레쉬 토큰 또한 재발급을 해주는 rotate 기능을 담당한다.

 

@RestController
public class ReissueController {

    private final JWTUtil jwtUtil;
    private final JwtTokenReadisService jwtTokenReadisService;

    public ReissueController(JWTUtil jwtUtil, JwtTokenReadisService jwtTokenReadisService) {
        this.jwtUtil = jwtUtil;
        this.jwtTokenReadisService = jwtTokenReadisService;
    }

    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response){

        String refresh = null;
        Cookie[] cookies = request.getCookies();

        //리프레쉬 톤큰을 쿠키에서 추출,
        for(Cookie cookie : cookies){
            if(cookie.getName().equals("refresh")){
                refresh = cookie.getValue();
            }
        }
        //리프레쉬 토큰 없으면 재로그인을 해야하므로 400 리턴
        if(refresh == null){
            System.out.println("리프레쉬 토큰 없어");
            return new ResponseEntity<>("리프뤠시 토큰 없어", HttpStatus.BAD_REQUEST);

        }

        //리프레쉬 토큰 만료되면 재로그인
        try{
            jwtUtil.isExpired(refresh);
        }catch(ExpiredJwtException e){
            System.out.println("리프레쉬 토큰 만료됨");
            return new ResponseEntity<>("리프레쉬 토큰 만료됨", HttpStatus.BAD_REQUEST);
        }

        // 해당 토큰이 access 인지 refresh 인지 구별
        String category = jwtUtil.getCategory(refresh);

        if(!category.equals("refresh")){
            return new ResponseEntity<>("리프레쉬 토큰 아닌데?", HttpStatus.BAD_REQUEST);
        }

        //refresh 토큰에서 username 추출 , 페이로드에 명시되어 있기에 가능
        String username = jwtUtil.getUsername(refresh);

        //레디스 저장소에 해당 리프레쉬토큰 있는지 검사, 없다면 재로그인을 해야함
        String refreshToken = jwtTokenReadisService.getRefreshToken(username);

        if(refreshToken.isEmpty()) {
            return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
        }


        String role = jwtUtil.getRole(refresh);

        // accesstoken 재발급해주고, refresh 토큰도 재발급 해준다. (rotate)
        String newAccess = jwtUtil.createJwt("access", username, role, 120000L); //600000L
        String newRefresh = jwtUtil.createJwt("refresh", username, role, 300000L); //86400000L

        // 기존 리프레쉬 토큰은 삭제 (계속 남겨두면 탈취 위험있음)
        jwtTokenReadisService.deleteRefreshToken(username);

        //새로만든 리프레쉬 토큰 저장
        jwtTokenReadisService.saveRefreshToken(username, newRefresh, 300000L);


        // 쿠키 만들어서 클라이언트에게 반환 , HttpOlny 옵션은 클라이언트에서 js를 통해 쿠키의 접근을 막는 속성임.
        //true로 해도 쿠키는 서버로 자동으로 전송되어서 true로 하는게 보안상 적절
        response.addCookie(createCookie("access", newAccess));
        response.addCookie(createCookie("refresh", newRefresh));
        return new ResponseEntity<>(HttpStatus.OK);


    }

    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(600);
        cookie.setHttpOnly(true);
        return cookie;
    }

}

 

 

Spring Security가 인증된 사용자 정보를 저장하고 활용할 수 있도록 사용자 정보를 감싸는 래퍼 클래스를 아래와 같이 선언

UserDetails을 구현함으로써 Spring Security의 인증 시스템과 우리 서비스의 사용자 정보를 연결

 

security 내부 필터중에 UserDetailsService.loadUserByUsername() 해당 함수 호출을 하는데 이 메서드는 UserDetails를 반환해야 한다. 그래서 UserDetails에 맞춰서 사용자 정보를 매핑해주기 위해 작성

 

public class CustomUserDetails implements UserDetails {

    private final UserEntity userEntity;

    public CustomUserDetails(UserEntity userEntity) {
        this.userEntity = userEntity;

    }

    //권한을 가져오는 부분이다.
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(userEntity.getRole()));
    }

    //비밀번호 getter
    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    //username getter
    @Override
    public String getUsername() {
        return userEntity.getUsername();
    }

    //기한이 만료되었는가
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //잠겨있는 사용자 인가
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //비밀번호가 만료되었는지 여부
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    //계정이 활성화되었는지 여부
    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

아래는 커스텀한 logoutFilter이다. 로그아웃 시에는 이것을 사용할 것이다.

전체적인 기능은 로그아웃 요청이 오면 리프레쉬 토큰을 레디스 저장소에서 날린다.

블랙리스트로 관리해도 되지만 그냥 삭제하고 jwt필터쪽에서 해당 리프레쉬 토큰이 없으면 접근제한을 하는 형식으로 했다.

//GenericFilterBean: Spring의 Filter 구현을 단순하게 도와주는 추상 클래스 (서블릿 필터를 만들 때 편하게 쓰라고 만든 템플릿)
//OncePerRequestFilter을 이용한 커스텀 로그가웃 필터도 가능함.
public class CustomLogoutFilter extends GenericFilterBean {


    private final JWTUtil jwtUtil;
    private final JwtTokenReadisService jwtTokenReadisService;

    public CustomLogoutFilter(JWTUtil jwtUtil, JwtTokenReadisService jwtTokenReadisService) {
        this.jwtUtil = jwtUtil;
        this.jwtTokenReadisService = jwtTokenReadisService;
    }


    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        doFilter((HttpServletRequest) request,(HttpServletResponse) response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        //url은 자원이 실제로 존재하는 위치, uri는 자원의 위치뿐만 아니라 자원에 대한 고유 식별자

        //url이 logout이면서 POST 방식이 아니면 아무것도 안하고 다음필터로 넘긴다.
        String requestUri = request.getRequestURI();
        if (!requestUri.equals("/logout")) {

            filterChain.doFilter(request, response);
            return;
        }
        String requestMethod = request.getMethod();
        if (!requestMethod.equals("POST")) {

            filterChain.doFilter(request, response);
            return;
        }

        //get refresh token
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {

            if (cookie.getName().equals("refresh")) {

                refresh = cookie.getValue();
            }
        }

        //refresh null check
        if (refresh == null) {

            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        //expired check
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {

            //response status code
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(refresh);
        if (!category.equals("refresh")) {

            //response status code
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        String username = jwtUtil.getUsername(refresh);

        String refreshToken = jwtTokenReadisService.getRefreshToken(username);
        if (refreshToken.isEmpty()) {

            //response status code
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        //로그아웃 진행
        //Refresh 토큰 제거
        jwtTokenReadisService.deleteRefreshToken(username);

        //Refresh 토큰 Cookie 값 0
        Cookie cookie = new Cookie("refresh", null);
        cookie.setMaxAge(0);
        cookie.setPath("/");

        response.addCookie(cookie);
        response.setStatus(HttpServletResponse.SC_OK);
    }
}

 

아래는 JWT 생성 및 토큰에서 사용자 정보를 추출하는 메서드로 구성되어 있다.

 

@Component
public class JWTUtil {

    private final SecretKey secretKey;

    public JWTUtil(@Value("${spring.jwt.secret}") String secret){
        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());

    }

    public String getUsername(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);

    }

    public String getRole(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public String getCategory(String token){

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);

    }

    public Boolean isExpired(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    public String createJwt(String  category,String username, String role, Long expiredMs){

        return Jwts.builder()
                .claim("category", category)
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }
}

 

아래는 커스텀한 로그인 필터이다. jwt 발급하여 클라이언트에게 반환해준다

//security에서 로그인 인증 관련 담당하는 UsernamePasswordAuthenticationFilter를 상속하여
//커스텀 로그인 필터를 구성한다.
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JWTUtil jwtUtil;


    private final JwtTokenReadisService jwtTokenReadisService;


    public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil, JwtTokenReadisService jwtTokenReadisService) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
        this.jwtTokenReadisService = jwtTokenReadisService;
    }

    //username과 password를 담고 UsernamePasswordAuthenticationToken으로 만들어야
    //authenticationManager에서 인증이 가능하다.
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        //authenticationManager한테 던져줘서 인증을 받는다. (DTO 활용)
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        //Manager에서 사용할 토큰(DTO)
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

        //manager한테 전달 (검증을 위해)
        return authenticationManager.authenticate(authToken);
    }

    //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨) 토큰을 쿠키로 반환
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

        String username = authentication.getName();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        //access, refresh 토큰 발급
        String access = jwtUtil.createJwt("access",username, role, 120000L); //600000L
        String refresh = jwtUtil.createJwt("refresh",username, role, 300000L);

        //refresh토큰은 레디스에서 따로 관리, --> jwt기본적으로 stateless로 관리하기 때문에 서버에서 제어권을 가지기 위해 저장한다.
        jwtTokenReadisService.saveRefreshToken(username, refresh, 300000L);

        response.addCookie(createCookie("access", access));
        response.addCookie(createCookie("refresh", refresh));

        response.setStatus(HttpStatus.OK.value());
    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        response.setStatus(401);
    }


    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(480); //TODO: 24*60*60
        cookie.setHttpOnly(true);
        return cookie;
    }
}

 

아래는 jwt를 검증하기 위한 필터 클래스다. 중복 실행 방지를 위해 OncePerRequestFilter를 상속한다.

전체적으로 access 토큰의 유효성을 판단하고 refresh토큰이 없으면 에러를 반환한다.

// filter chain에서 설정한 jwt 검증을 위한 커스텀 필터를 등록해야 한다.
//해당 필터를 통해 요청 헤더 Authorization 키에 JWT가 존재하는 경우 JWT를 검증하고 강제로 시큐리티컨텍스트홀더에 세션을 생성
// 세션은 statleless이기 때문에 끝나면 소멸
public class JwtFilter extends OncePerRequestFilter {


    private final JWTUtil jwtUtil;
    private final JwtTokenReadisService jwtTokenReadisService;

    public JwtFilter(JWTUtil jwtUtil,  JwtTokenReadisService jwtTokenReadisService) {
        this.jwtUtil = jwtUtil;
        this.jwtTokenReadisService = jwtTokenReadisService;
    }


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String uri = request.getRequestURI();
        if (uri.equals("/") || uri.equals("/login") || uri.equals("/user") || uri.equals("/join") || uri.equals("/reissue")) {
            filterChain.doFilter(request, response);
            return;
        }


        String accessToken = request.getHeader("access");


        if (accessToken == null) {
            Cookie[] cookies = request.getCookies();
            if (cookies != null) {
                for (Cookie cookie : cookies) {
                    if ("access".equals(cookie.getName())) {
                        accessToken = cookie.getValue();
                        break;
                    }
                }
            }
        }


       if(accessToken == null){ filterChain.doFilter(request, response); return; }

       try{
           jwtUtil.isExpired(accessToken);
       }catch (ExpiredJwtException e) {
           System.out.println(" 엑세스 토큰이 만료됨");
           response.sendError(HttpServletResponse.SC_FORBIDDEN); // 403 강제 에러 전송
           return;
       }

       String category = jwtUtil.getCategory(accessToken);

       if(!category.equals("access")){
           PrintWriter writer = response.getWriter();
           writer.print("Invalid access token");
           System.out.println("엑세스 토큰이 올바르지 않은데?");
           response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
           return;
       }
        String username = jwtUtil.getUsername(accessToken);

        String refreshToken = jwtTokenReadisService.getRefreshToken(username);
        //리프레쉬 토큰이 없다 ---> 로그아웃 or 시간이 오래지난 사용자
        if(refreshToken.isEmpty()){
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }


        String role = jwtUtil.getRole(accessToken);

        UserEntity userEntity = new UserEntity();
        userEntity.setUsername(username);
        userEntity.setRole(role);
        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);


        //SecurityContextHolder는 단순히 현재 요청을 처리하는 동안에만 인증 정보를 저장하는 공간일 뿐,
        // 이 정보를 서버가 세션에 저장하거나, 다음 요청까지 기억하는 건 아님
        //그건 요청 스코프(단일 요청 내에서만 존재)**이므로 stateless 인증 방식이다.
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());

        System.out.println(" 인증 객체: " + authToken.getName());
        System.out.println(" 권한들: " + authToken.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);


    }
}

 

 

formLogin을 쓰지 않거나, 혹은 커스텀 로그인 로직(JWT 기반 등)을 사용할 때 직접 UserDetailsService를 구현해야 하기 때문에 만든 클래스 (거의 대부분은 직접 구현해서 만듬)

@Service
public class CustomUserDetailsService implements UserDetailsService  {

    private final UserEntityRepository userEntityRepository;

    public CustomUserDetailsService(UserEntityRepository userEntityRepository) {
        this.userEntityRepository = userEntityRepository;
    }


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        UserEntity userData = userEntityRepository.findByUsername(username);

        if(userData != null){
            return new CustomUserDetails(userData);
        }

        return null;
    }
}

 

그리고 아래는 명시적으로 결과값을 확인하기 위한 html 페이지. (postman으로 그냥 api 테스트도 가능함.)

 

- user.html

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1>로그인이나하셈</h1>
    <button id="loginbtn" type="button">로그인딸깍</button>
    <button id="reissueBtn">Access 토큰 재발급</button>
    <button id="logoutBtn">로그아웃 딸깍</button>
</body>
<script>
    document.getElementById('loginbtn').addEventListener('click', function () {
        fetch("http://localhost:8088/login", {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: new URLSearchParams({
                username: "admin",
                password: "1234"
            }),
            credentials: "include"  //쿠키 받아오기 위해 필요
        })
            .then(response => {
                if (response.ok) {
                    console.log("로그인 성공!");
                    window.location.href = "/admin";  // 로그인 성공 후 바로 이동
                } else {
                    alert("로그인 실패!");
                }
            })
            .catch(err => console.error("로그인 실패:", err));
    });
    document.getElementById("reissueBtn").addEventListener("click", function () {
        fetch("http://localhost:8088/reissue", {
            method: "POST",
            credentials: "include" // refresh 쿠키 포함 필수!
        })
            .then(res => {
                if (res.ok) {
                    alert(" Access 토큰 재발급 완료!"); // 쿠키에 자동 저장됨
                } else {
                    alert("재발급 실패! 다시 로그인 필요");
                }
            })
            .catch(err => {
                console.error("재발급 요청 중 에러 발생:", err);
                alert("서버 오류!");
            });
    });

    document.getElementById('logoutBtn').addEventListener('click', function (){
        fetch("http://localhost:8088/logout", {
            method: 'POST',
            credentials: 'include'
        })
            .then(res => {
                if(res.ok) window.location.href = '/';
                else alert('로그아웃실패함 ㅋ');
            })
            .catch(err => {
                alert('에러발생');
            })
    })
</script>
</html>

 

이러한 rotate방식은 리프레쉬 토큰의 유효기간이 1주일로 잡혀있다고 해도 1주일 안에 재로그인을 계속 해준다면 계속 로그인상태가 지속될 수 있다.

 

그리고 리프레쉬 토큰과 엑세스 토큰은 어디에 저장해야 좋은가에 대해서 많은 의견이 있는데 

보통은 Refresh Token => HttpOnly 쿠키 , AccessToken => 메모리 or HttpOnly 쿠키에 저장을 많이 한다.

하지만 쿠키로 저장할 경우 CSRF에 대한 주의가 필요하다 (CSRF 토큰을 사용하자)

메모리 방식은 새로고침하면 사라지기에 잦은 로그인 시도가 필요하다.

보안성과 편의성에 따라 어디에 두는지 다르다.

 

 

<결과>

 

- admin 페이지에 접근하였을 때 (ADMIN 권한이 필요)

 

로그인을 안하고 권한이 없는 상태에서는 에러가 나온다.

 

 

- 로그인 및 토큰 재발급 , 로그아웃일 위한 페이지

로그인버튼을 누르면 자동 로그인이 된다. 그러면서 admin 페이지로 이동이 가능하다.

 

 

하지만 내가 설정한 2분의 시간이 지나면 다시 접근이 불가능하다.

그럴땐 user페이지에서 토큰 재발급 버튼을 눌러주면 다시 Access 토큰이 발급되어서 접근이 가능해진다.

이때 /reissue  API 에 의해서 refresh token 또한 재발급이 된다.

 

5분이 지나서 재발급 할 경우 발급이 안된다. 리프레쉬 토큰의 기한이 5분이기 때문이다.

 

이 기능은 유튜버 '개발자 유미' 님의 영상을 참고하여 만들었습니다.

https://www.youtube.com/@xxxjjhhh

 

개발자 유미

백엔드 개발자 유미 - 실습 위주 진행 (개념적인 부분은 공식 Docs 참조 및 개인 학습 바랍니다!) - 간혹 댓글 알림이 안오는 경우가 있습니다.

www.youtube.com

 

근데 위 기능은 DB가 아닌 REDIS를 활용하고 싶어 간단하게만 변경을 해주었습니다.