1. 환경변수 설정 (CMD)
$b = New-Object 'System.Byte[]' 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
$base64 = [Convert]::ToBase64String($b)
// 키 이름: 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에 인증을 주입하여 컨트롤러가 인증된 사용자 컨텍스트로 실행되도록 하는 전처리 컴포넌트
- 요청이 서블릿 필터 체인에 들어오면 필터가 Authorization 헤더에서 Bearer 토큰을 파싱
- JWS 서명/만료/issuer 등을 검증
- 유효하면 UsernamePasswordAuthenticationToken을 만들어 SecurityContextHolder에 설정
- 이후 DispatcherServlet → HandlerMapping이 컨트롤러를 찾고, 인가 규칙에 따라 접근 허용/거부
- 토큰이 없거나 무효면 인증 없이 진행되고, 인가 단계에서 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가 관리하는 엔티티
@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회성 소비
- revoked = false(아직 안 쓴 토큰)
- exp > CURRENT_TIMESTAMP(DB 시계 기준으로 아직 유효)
- 위 조건을 만족하는 행을 딱 1개만 갱신하도록 기대 → 반환값이 1이면 최초 사용 성공, 0이면 재사용/만료/존재X
- 한 쿼리로 원자적으로 처리 → 동시요청 레이스 컨디션 방지
- 사용자 전량 폐기
- 소프트 삭제(폐기)
- 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();
}
...
}