Spring boot

JWT (JSON Web Token) - 실습

devyelin 2025. 9. 24. 16:14

1. 환경변수 설정 (CMD)

  • 32Byte 랜덤 생성
 $b = New-Object 'System.Byte[]' 32
 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
  • Base64 문자열로 변환
$base64 = [Convert]::ToBase64String($b)
  • 환경변수에 base64 "문자열"을 저장
// 키 이름: JWT_SECRET, 값: $base64(이전 단계에서 변환한 값), User 범위로 사용하겠다는 의미
[Environment]::SetEnvironmentVariable("JWT_SECRET", $base64, "User")
  • 확인 방법
// 저장된 값 bash에 출력
[Environment]::GetEnvironmentVariable("JWT_SECRET","User")
// 길이가 32 이상이어야 함
([Convert]::FromBase64String($base64)).Length
  • 확인 후 컴퓨터 재부팅

2. 바인딩 클래스 정의(JwtProperties.java)

  • application.properties 또는 yml에 사용자 정의 프로퍼티를 등록하기 위해 바인딩 클래스를 정의해줌
@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(
        String secret,
        long accessValidSeconds,
        long refreshValidSeconds,
        String issuer
) {}

3. 실행 구성에 등록

  • JWT 서명용 SecretKey를 한 곳에서 올바르게 만들고(검증 포함), 빈(Bean)으로 주입
  • 안전한 키 생성 + 재사용 + 실수 방지
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {

    @Bean
    public SecretKey jwtSigningKey(JwtProperties prop) {
    	// 1. 설정에서 키 읽기 & Base64 디코딩
        byte[] keyBytes = io.jsonwebtoken.io.Decoders.BASE64.decode(prop.secret());
        
        // 2. 길이 검증(보안)
        if (keyBytes.length < 32) throw new IllegalStateException("jwt.secret must be >= 32 bytes");
        
        // 3. JJWT용 SecretKey 생성, 빈 등록
        return io.jsonwebtoken.security.Keys.hmacShaKeyFor(keyBytes);
    }
}

4. application.properties (또는 yml) 에 사용자 정의프로퍼티 등록

# OS 환경변수 JWT_SECRET 값을 읽음
jwt.secret=${JWT_SECRET}
# 액세스 토큰 만료 시간(초)
jwt.access-valid-seconds=900
#리프레시 토큰 만료 시간(초)
jwt.refresh-valid-seconds=1209600
#JWT의 iss(발급자) 클레임. 발급·검증 시 반드시 일치해야 하며, 위조 토큰 차단에 도움
jwt.issuer=my-app

5. Util (토큰 생성/검증)

  • token_type 분리: 액세스/리프레시 혼용 차단
  • jti 반환: 리프레시 회전/블랙리스트 구현에 유용
  • 중앙 검증 로직: iss/서명/시간 검증을 한 곳에서 책임 → 실수 감소
  • Properties 분리: 만료/issuer를 설정으로 관리 → 환경별 튜닝 쉬움
@Component  // Security Filter에서 매개변수로 찾기 위해 컴포넌트로 읽음
public class JwtProvider {
	// 서명에 사용할 키. JwtConfig에서 Bean으로 만든 걸 주입받음(HS256)
    private final SecretKey secretKey;
    // application.properties의 jwt.* 값들
    private final JwtProperties props;

    public JwtProvider(SecretKey secretKey, JwtProperties props) {
        this.secretKey = secretKey;
        this.props = props;
    }
    
	// (1) 액세스 토큰 발급
    public String createAccessToken(UserDetails user) {
    	// 1. 유효기간
        Instant now = Instant.now();  // 현재 발급 일시
        Instant exp = now.plusSeconds(props.accessValidSeconds());  //현재를 기준으로 만료 일자 지정(환경설정에 저장되어 있는 값 사용)

		// 2. 권한(roles/authorities) 클레임 구성
        String auth = user.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
        
        // 3. 클레임 세팅
        return Jwts.builder()
        		// 3-1. 표준 클레임 세팅
                .setHeaderParam("typ", "JWT")
                .setIssuer(props.issuer())
                .setSubject(user.getUsername())
                .setAudience("your-api")     // 있으면
                .setId(UUID.randomUUID().toString())  // jti
                .setIssuedAt(Date.from(now))
                .setExpiration(Date.from(now.plusSeconds(props.accessValidSeconds())))
                // 3-2. 커스텀 클레임(토큰 타입 구분/검증에 사용)
                .claim("auth", auth)      // 필요 최소만
                .claim("token_type","access")
                // 3-3. 서명
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();

    }

    public record RefreshPair(String token, String jti) {}

	// (2) 리프레시 토큰 발급(+ jti 반환)
    public RefreshPair createRefreshToken(String username) {
        var now = Instant.now();
        // 1. jti 생성 & 반환(DB에 저장해서 회전/폐기(Revocation) 때 식별자)
        var jti = UUID.randomUUID().toString();  // 로테이션을 위한 아이디 부여(db에 저장)
        // 2. 클레임 세팅
        var token = Jwts.builder()
        		// 2-1. 표준 클레임 세팅
                .setHeaderParam("typ","JWT")                 // typ은 선택, alg는 signWith가 채움
                .setIssuer(props.issuer())
                .setId(jti)
                .setSubject(username)
                .setIssuedAt(Date.from(now))
                .setNotBefore(Date.from(now))                // 굳이 과거로 돌릴 필요 X
                .setExpiration(Date.from(now.plusSeconds(props.refreshValidSeconds())))
                // 2-2. 커스텀 클레임 세팅
                .claim("token_type","refresh")
                // 2-3. 서명
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
        return new RefreshPair(token, jti);
    }

	// (3) 리프레시 토큰 파싱/검증
    public Claims parseAndValidateRefreshToken(String refreshToken) {
    	// 1. 표준 검증(서명/iss/시간)
        Claims claim = parseClaims(refreshToken);
        // 2. 추가로 커스텀 클레임 token_type == "refresh"인지 체크
        if(!"refresh".equals(claim.get("token_type", String.class))) throw new JwtException("Invalid refresh token");
        return claim;
    }

    // (4) 토큰 타입 확인(컨트롤러/필터에서 이 메서드를 써서 API 진입 전 검증)
    public Claims parseAndValidateAccess(String token) {
    	// 1. 표준 검증(서명/iss/시간)
        Claims c = parseClaims(token);
        // 2. token_type == "access" 확인
        if (!"access".equals(c.get("token_type", String.class))) {
            throw new io.jsonwebtoken.JwtException("invalid token_type");
        }
        return c;
    }
    
    // (5) 액세스 토큰 파싱/검증(공통 파서)
    private Claims parseClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .requireIssuer(props.issuer())         // 반드시 우리 발급자(우리 서비스가 발급한 토큰만 통과시키려는 핵심 방어선)
                .setAllowedClockSkewSeconds(60)        // 시계 오차 허용
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

}

6. Filter

  • 요청이 DispatcherServlet에 도달하기 전, Authorization: Bearer 토큰을 추출·검증하고, 유효하면 SecurityContext에 인증을 주입하여 컨트롤러가 인증된 사용자 컨텍스트로 실행되도록 하는 전처리 컴포넌트
    1. 요청이 서블릿 필터 체인에 들어오면 필터가 Authorization 헤더에서 Bearer 토큰을 파싱
    2. JWS 서명/만료/issuer 등을 검증
    3. 유효하면 UsernamePasswordAuthenticationToken을 만들어 SecurityContextHolder에 설정
    4. 이후 DispatcherServlet → HandlerMapping이 컨트롤러를 찾고, 인가 규칙에 따라 접근 허용/거부
    5. 토큰이 없거나 무효면 인증 없이 진행되고, 인가 단계에서 401/403 처리
  • Security 설정에서 UsernamePasswordAuthenticationFilter 앞에 등록해줘야 토큰 기반 인증이 먼저 적용됨
// (0) OncePerRequestFilter: 한 요청당 딱 한 번만 실행되는 시큐리티 필터
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    // (1) 정규식 패턴: Authorization: Bearer <토큰> 형식만 인식(대소문자 무시)
    private static final Pattern BEARER = Pattern.compile("^Bearer\\s+(.+)$", Pattern.CASE_INSENSITIVE);
    private final JwtProvider jwtProvider;

    public JwtAuthenticationFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
		
        // 1. 헤더 추출 → Bearer 형식 확인
        String header = req.getHeader(HttpHeaders.AUTHORIZATION);
        // 1-1. 헤더 없거나 
        if (header == null || header.isBlank()) {
            chain.doFilter(req, res);
            return;
        }
		
        // 1-2. 형식이 아니면 그냥 패스(익명 요청으로 처리되도록 다음 필터로 넘어감)
        var m = BEARER.matcher(header);
        if (!m.matches()) {
            chain.doFilter(req, res);
            return;
        }

		// 2. 토큰 검증 (핵심)
        String token = m.group(1).trim();
        try {
            // 2-1. 앞서 Provider에서 정의한 검증 함수
            Claims claims = jwtProvider.parseAndValidateAccess(token);

			// 2-2. 주체/권한 복구
            String username = claims.getSubject();
            String authCsv = claims.get("auth", String.class);

            var authorities = (authCsv == null || authCsv.isBlank())
                    ? java.util.List.<org.springframework.security.core.authority.SimpleGrantedAuthority>of()  // 토큰에 넣어둔 권한 문자열을 GrantedAuthority로 변환
                    : java.util.Arrays.stream(authCsv.split(","))
                    .map(String::trim).filter(s -> !s.isEmpty())
                    .map(org.springframework.security.core.authority.SimpleGrantedAuthority::new)
                    .toList();
			
            // 2-3. Authentication 만들고 컨텍스트 주입(이후 컨트롤러/메서드시큐리티에서 @AuthenticationPrincipal/Authentication로 사용자 식별, 권한 체크 가능)
            var authentication =
                    new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
                            username, null, authorities);

            // 선택: 감사/추적 정보
            authentication.setDetails(new org.springframework.security.web.authentication.WebAuthenticationDetailsSource()
                    .buildDetails(req));

            org.springframework.security.core.context.SecurityContextHolder.getContext()
                    .setAuthentication(authentication);
        } catch (io.jsonwebtoken.JwtException e) {
            // 실패시 인증정보를 비움(익명으로 처리), 응답을 여기서 401로 끝내지 않고 다음 필터/핸들러 흐름에 맡김
            // 결과적으로 인가 규칙(.anyRequest().authenticated())에서 401/403이 결정
            org.springframework.security.core.context.SecurityContextHolder.clearContext();
        }

        chain.doFilter(req, res);
    }
}

7. Security Config

  • 전체적인 Security 관련 설명은 Security 포스트 참고
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
...
    @Bean
    @org.springframework.core.annotation.Order(1)
    public SecurityFilterChain apiChain(HttpSecurity http, JwtProvider jwtProvider) throws Exception {
        http
        		// 1. /api/** 로 들어오는 요청만 이 체인이 처리
                .securityMatcher("/api/**")   
                // 2. 무상태(API) 기본 옵션
                .csrf(AbstractHttpConfigurer::disable)  // 2-1. /api/** : 헤더 Bearer만 → 무상태 + CSRF 끔
                .cors(Customizer.withDefaults())   // 2-2. CORS 적용 (별도 CorsConfigurationSource 필요)
                .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))   // 2-3. 세션 생성 금지. 인증 상태는 JWT로만 판단
                // 3. 인가(Authorization) 규칙
                .authorizeHttpRequests(auth -> auth  
                        .requestMatchers(HttpMethod.OPTIONS, "/api/**").permitAll() // 3-1. /api/** 를 다 인증 요구로 바꾸면 브라우저 preflight(OPTIONS) 가 401/403으로 막히는 경우 방지
                        .requestMatchers(HttpMethod.POST, "/api/refresh").permitAll()  // 3-2. refresh rotation을 위한 경로 열어두기 => 액세스 토큰 없이도 호출
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")  // 3-3. ROLE_ADMIN 권한이 필요
                        .anyRequest().authenticated()  // 3-4. 인증만 있으면 접근 허용
                )
                // 4. 예외 응답(일관된 JSON), (401 → 리프레시/재로그인 || 403 → 권한 안내를 정확히 처리)
                .exceptionHandling(ex -> ex  // 
                        // 4-1. 인증이 없거나 토큰 검증 실패 → 401
                        .authenticationEntryPoint((req, res, e) -> {
                            res.setStatus(401);
                            res.setContentType("application/json;charset=UTF-8");
                            res.getWriter().write("{\"error\":\"unauthenticated\"}");
                        })
                        // 4-2. 인증은 됐는데 권한이 부족 → 403
                        .accessDeniedHandler((req, res, e) -> {
                            res.setStatus(403);
                            res.setContentType("application/json;charset=UTF-8");
                            res.getWriter().write("{\"error\":\"forbidden\"}");
                        })
                )
                // 5. JWT 필터 투입
                // (중요) 디스패처서블릿 전에 돌아가는 시큐리티 필터 체인에서, UsernamePasswordAuthenticationFilter보다 앞에 JWT 인증 필터를 배치
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
...
}

8. Service

  • 로그인 / 리프레시 토큰 회전 / 재사용 감지 구현 / 로그아웃
@Service
@AllArgsConstructor
public class JwtService {
    private final AuthenticationManager authManager;
    private final AuthService uds;
    private final JwtProvider jwt;
    private final boolean secureCookie = false; // dev: false, prod: true
    private final IamportClient iamportClient;
    private final RefreshTokenRepository refreshTokenRepo;

    // (1) 처음 로그인 시 첫 발급
   @Transactional
    public ResponseEntity<?> init(HttpServletRequest request, AuthController.LoginReq req) {
        // 1. 로컬/직접연결일 때 설정. 프록시/로드밸런서 뒤라면 X-Forwarded-For/Forwarded 헤더를 써야 정확
        String clientIp = request.getRemoteAddr();

        // 2. AuthenticationManager로 자격 검증
        try {
            // 2-1. Spring Security가 패스워드 비교(PasswordEncoder) 등의 인증 로직 수행
            authManager.authenticate(new UsernamePasswordAuthenticationToken(req.userId(), req.password()));
        } catch (org.springframework.security.core.AuthenticationException ex) {
            // 2-2. 실패하면 401 반환
            return ResponseEntity.status(401).body(Map.of("error", "Bad credentials"));
        }

        // 3. Access/Refresh 토큰 발급
        // 3-1. 사용자 상세(권한 등) 정보 가져옴
        var user = uds.loadUserByUsername(req.userId());
        // 3-2. 액세스 토큰 생성(Provider)
        var access = jwt.createAccessToken(user);
        // 3-3. 리프레시 토큰 생성(Provider)
        var refresh = jwt.createRefreshToken(user.getUsername()); // token + jti

        // 4. 저장소에 발급받은 리프레시 토큰의 jti(식별자) 저장
        var claims = jwt.parseAndValidateRefreshToken(refresh.token());
        this.save(claims);

        // 5. 리프레시 쿠키 생성
        var cookie = ResponseCookie.from("refreshToken", refresh.token())
                // 5-1. HttpOnly 쿠키로 Refresh 전달(JS에서 접근 불가)
                .httpOnly(true)
                // 5-2. true 권장(HTTPS 필수일 때만 전송)
                .secure(secureCookie)
                // 5-3. 크로스도메인: None+Secure, 로컬: Lax
                .sameSite(secureCookie ? "None" : "Lax")
                // 5-4. 이 경로(/api)로 시작하는 요청에만 리프레시 쿠키 자동 전송
                .path("/api")
                // 5-5. 만료 시각 - 현재시각 계산해서 초 단위 설정
                .maxAge(Duration.ofSeconds(claims.getExpiration().toInstant().getEpochSecond() - Instant.now().getEpochSecond()))
                .build();

        // 6. 응답
        return ResponseEntity.ok()
                // 6-1. 헤더: 리프레시 쿠키
                .header(HttpHeaders.SET_COOKIE, cookie.toString())
                // 6-2. 바디: 액세스 토큰
                .body(new AuthController.Tokens(access, "Bearer"));
    }


    // (2) 리프레시 토큰을 이용해 액세스 토큰을 재발급받기
    @Transactional
    public ResponseEntity<?> refreshToken(String token) {
        try {
            // 1. 검증/파싱
            Claims claims = jwt.parseAndValidateRefreshToken(token);
            var jti = claims.getId();  // 식별자
            var user = claims.getSubject();  // 유저 정보
            // 2. 재사용 감지 + 소모(consume)
            if (!this.useAndRevoke(jti, claims)) { 
            	// 2-1. 모든 세션/refresh 무효화
                this.revokeAll(user);        
                return ResponseEntity.status(401).body(Map.of("error","refresh_reuse_detected"));
            }
            // 3. 새 발급 + 저장
            var details = uds.loadUserByUsername(user);
            var newAccess = jwt.createAccessToken(details);
            var newRefresh = jwt.createRefreshToken(user);
            var newClaims = jwt.parseAndValidateRefreshToken(newRefresh.token());
            // 3-1. 새 jti, exp 저장
            this.save(newClaims);  

			// 4. 쿠키로 새 Refresh 전송
            var cookie = ResponseCookie.from("refreshToken", newRefresh.token())
                    .httpOnly(true)
                    .secure(secureCookie)
                    .sameSite(secureCookie ? "None" : "Lax")
                    .path("/api")                 // 경로(path)는 실제 리프레시 엔드포인트 prefix와 일치시켜야 브라우저가 자동 첨부
                    .maxAge(Duration.between(Instant.now(), newClaims.getExpiration().toInstant()))
                    .build();
			
            // 6. 응답
            return ResponseEntity.ok()
                    .header(HttpHeaders.SET_COOKIE, cookie.toString())
                    .body(new AuthController.Tokens(newAccess,"Bearer"));
        } catch (JwtException e) {
            return ResponseEntity.status(401).build();
        }
    }

	// (3) 리프레시 저장
    @Transactional
    public void save(Claims claims) {
    	// jti, username, 만료시각, 사용여부(used=false)
        refreshTokenRepo.save(new RefreshToken(claims.getId(), claims.getSubject(), claims.getExpiration().toInstant(), false));
    }

    // (4) 원자적 1회 소모(레이스 컨디션을 막아주는 포인트(동시 요청 중복 방지))
    @Transactional
    public boolean useAndRevoke(String jti, Claims claims) {
        // 제출된 리프레시 토큰이 “유효하고 아직 한 번도 안 쓴 것”인지 확인하고, 그 즉시 사용 처리(폐기)
        return refreshTokenRepo.consumeOnce(jti, claims.getSubject()) == 1; // 1: 최초 사용 성공, 0: 재사용/만료/없음
    }

	// (5) 사용자 전부 폐기
    @Transactional
    public void revokeAll(String user) {
    	// 해당 사용자의 모든 refresh 레코드 삭제 → 모든 세션 로그아웃 효과
        refreshTokenRepo.deleteAllByUsername(user);
    }

	// (6) 로그아웃
    @Transactional
    public ResponseCookie logout(String token, boolean secureCookie) {
    	// 1. 리프레시 토큰이 존재하면
        if (token != null) {
        	// 제출된 refresh를 파싱 → 해당 사용자 모든 refresh 폐기
            try {
                var c = jwt.parseAndValidateRefreshToken(token);
                this.revokeAll(c.getSubject());
            } catch (JwtException ignored) {}
        }
        
        // 2. 빈 값 쿠키(maxAge(0))로 클라이언트 측 쿠키 삭제
        return ResponseCookie.from("refreshToken","")
                .path("/api")
                .httpOnly(true)
                .secure(secureCookie)
                .sameSite(secureCookie ? "None" : "Lax")
                .maxAge(0)
                .build();
    }
}

9. Entity

  • 리프레시 토큰 저장소 테이블을 JPA로 매핑
// JPA가 관리하는 엔티티
@Entity  
// Lombok 생성자/빌더
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
// DB 테이블명 지정
@Table(name = "refresh_token") 
public class RefreshToken {
	// 1. 토큰 식별자(고유)
    @Id
    @Column(length = 64)
    private String jti;

	// 2. 소유자
    @Column(nullable = false)
    private String username;

	// 3. 만료 시각
    @Column(nullable = false)
    private Instant exp;

	// 4. 폐기 여부(회전 시 소비되면 true)
    @Column(nullable = false)
    private boolean revoked;
}

10. Repository

  • 리프레시 토큰을 저장하는 DB
    1. 1회성 소비
      • revoked = false(아직 안 쓴 토큰)
      • exp > CURRENT_TIMESTAMP(DB 시계 기준으로 아직 유효)
      • 위 조건을 만족하는 행을 딱 1개만 갱신하도록 기대 → 반환값이 1이면 최초 사용 성공, 0이면 재사용/만료/존재X
      • 한 쿼리로 원자적으로 처리 → 동시요청 레이스 컨디션 방지
    2. 사용자 전량 폐기
      • 소프트 삭제(폐기)
      • clearAutomatically = true, flushAutomatically = true
        • 벌크 업데이트: 영속성 컨텍스트(1차 캐시)를 건너뜀  →  같은 트랜잭션 내에서 앞서 조회해 둔 엔티티 스냅샷이 오염될 수 있음
        • 이 옵션으로 자동 flush/clear → 1차 캐시 불일치 방지
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken,Long> {
    
    // (1) 1회성 소비(재사용 감지의 핵심)
    @Modifying
    @Query("""
        UPDATE RefreshToken r
            SET r.revoked = true
            WHERE r.jti = :jti
                AND r.username = :sub
                AND r.revoked = false
                AND r.exp > CURRENT_TIMESTAMP
    """)
    int consumeOnce(@Param("jti") String jti, @Param("sub") String sub);
    
    // (2) 사용자 전량 폐기(로그아웃·재사용 감지 대응)
    @Modifying(clearAutomatically = true, flushAutomatically = true)  // 벌크 업데이트 후 1차 캐시 불일치로 인한 조회 값 꼬임 방지
    @Query("""
        UPDATE RefreshToken r
        SET r.revoked = true
        WHERE r.username = :u
""")
    void revokeAllByUsername(@Param("u") String username);

}

11. Controller

  • 로그인 경로: "/auth"
  • 리프레시 토큰 로테이션 요청, 로그아웃 경로: "/api"
  • 경로 다른 이유: 경로에 따라 보안 정책을 다르게 적용함(이후 포스팅 Security 참고)
// (0) 컨트롤러의 기본 경로는 /auth
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final JwtService jwtService;
    
    ...
    // (1) userId, password (검증 애노테이션 @NotBlank)
    public record LoginReq(@NotBlank String userId, @NotBlank String password) {}
    // (2) 응답 바디(액세스 토큰과 타입 “Bearer”)
    public record Tokens(String accessToken, String tokenType) {}
    
    // (3) 로그인 API
     @PostMapping("/login")
    public ResponseEntity<?> login(
            HttpServletRequest request,
            @Valid @RequestBody LoginReq req) {
        // 1. 서비스에서 jwt 발급받음
        Map<String, Object> jwt = jwtService.init(request, req);

        // 2. 응답
        return ResponseEntity.ok()
                // 2-1. 헤더: 리프레시 쿠키
                .header(HttpHeaders.SET_COOKIE, jwt.get("refresh").toString())
                // 2-2. 바디: 액세스 토큰
                .body(new Tokens(jwt.get("access").toString(), "Bearer"));
    }
    
	...
}
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController {
    private final JwtService jwtService;
    private final boolean secureCookie = false;
    
    ...
    // (1) 리프레시 토큰으로 액세스 토큰을 재발급
     @PostMapping("/refresh")
    public ResponseEntity<?> refresh(@CookieValue(name = "refreshToken", required = false) String token) {
        // 1. 쿠키가 없으면 401
        if (token == null) return ResponseEntity.status(401).build();
        return jwtService.refreshToken(token);
    }

	// (2) 로그아웃 시 리프레시 쿠키를 폐기
    @PostMapping("/refresh/logout")
    public ResponseEntity<?> logout(@CookieValue(name = "refreshToken", required = false) String token) {
        ResponseCookie del = jwtService.logout(token, secureCookie);
        return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, del.toString()).build();
    }
    ...
}

 

'Spring boot' 카테고리의 다른 글

JWT (JSON Web Token) - 이론  (0) 2025.09.23
[Spring Boot] TDD (Test-Driven Development)  (1) 2025.04.22