반응형
오늘은 어제 알아본 JWT(JSON Web Token) 토큰 인증에 대해 조금 더 살펴보겠습니다.
1. Token 검증 및 요청방식, 처리 방법
- 클라이언트는 매 요청마다 Access Token을 보냅니다.
- 일반적으로 Authorization 헤더:
Authorization: Bearer <access_token>.
- 일반적으로 Authorization 헤더:
- Refresh Token은 매 요청마다 보내지지 않습니다.
- 오직 Access 만료 시(또는 재발급 필요 시)에만 사용.
- Refresh Token 전송 방식
- 웹 브라우저:
HttpOnly,Secure,SameSite쿠키 권장(자동 전송). - 모바일/네이티브: 안전한 기기 저장소(키체인 등)에 보관하고, 갱신 시 API 호출으로 전송.
- 웹 브라우저:
- 서버는 보호된(인증/인가 필요한) 모든 API에서 Access Token을 검증해야 합니다.
- 중앙 인증 미들웨어(필터/인터셉터)에서 서명·만료·권한(claims) 검증.
- 만료된 Access 처리 흐름
- 클라이언트가 API 호출 → 서버가 Access 만료 응답(401) 또는 명시 코드 반환.
- 클라이언트는 Refresh 엔드포인트 호출(Refresh Token 자동 쿠키 전송 또는 바디 포함).
- 서버는 Refresh Token 서버측 저장소(예: Redis/DB)에서 조회/검증 → 유효하면 새 Access(및 필요하면 새 Refresh) 발급.
- 서버는 이전 Refresh를 무효화(rotate)하거나 블랙리스트 처리.
- Refresh 토큰 탈취(재사용) 방지
- Refresh 토큰은 회전(Rotation): 재발급 시 기존 토큰 즉시 무효화.
- 재사용(Replay)이 감지되면 해당 세션/모든 세션 강제 로그아웃.
- 토큰에
jti(id)나 device id, 발급 IP 등을 바인딩해 이상 징후 탐지.
- 로그아웃/강제 무효화
- 클라이언트 로그아웃 → 서버에서 Refresh 토큰 삭제/블랙리스트 등록.
- Access는 만료될 때까지 쓸 수 있지만 단시간이므로 허용(혹은 즉시 블랙리스트 처리).
2. 인증 시나리오
- Access: JWT (짧은 만료), 클라이언트가 매 요청에
Authorization헤더로 보냄 - Refresh: 길게 만료, 서버에 저장(UID→tokenId 맵, Redis), 쿠키(HttpOnly)로 전달 → 회전 전략 사용
3. 구현 예시
3-1. 시나리오 흐름
- 로그인 → 서버: Access JWT + Refresh Token 절차(Refresh는 DB/Redis에 저장)
- 클라이언트 → 모든 보호 API 요청 시
Authorization: Bearer <access>전송 - 서버 인증 필터 → 서명·만료·권한 검증
- 유효 → 정상 처리
- 만료 → 401 응답 (또는 특정 error code)
- 클라이언트는
/auth/refresh호출(브라우저면 쿠키가 자동 포함) - 서버는 Refresh 토큰 검증(서버 저장소 조회) → 유효하면
- 새 Access JWT 발급
- (권장) 새 Refresh 발급 → 저장소에 새 토큰 저장 & 이전 토큰 삭제(회전)
- 응답: 새 Access(+Set-Cookie 새 Refresh) 반환
- 클라이언트는 새 Access로 재시도
3-2. Spring Boot: Access 검증 필터 예시
// 스프링 시큐리티 필터 체인에 이 필터를 추가
// JwtAuthenticationFilter: Authorization 헤더 검증 -> SecurityContext 세팅
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtUtil.validateAccessToken(token); // 서명/만료 체크
String userId = claims.getSubject();
List<GrantedAuthority> auth = // claims에서 권한 추출
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userId, null, auth);
SecurityContextHolder.getContext().setAuthentication(authToken);
} catch (ExpiredJwtException e) {
// 토큰 만료: SecurityContext에 아무 것도 안 넣고 다음으로 (컨트롤러/예외핸들러에서 401 처리)
req.setAttribute("expiredToken", e.getClaims());
} catch (JwtException e) {
// 서명 오류 등: 바로 401
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
chain.doFilter(req, res);
}
}
3-3. Refresh 엔드포인트(회전 + 서버저장 예제)
기본 아이디어: Refresh는 임의 토큰 문자열(UUID 등)을 발급하고 Redis에 refresh:{tokenId} -> userId, deviceId, expiry 형태로 저장. Refresh 요청 시 저장소 확인 후 새 Access 발급하고, 새 Refresh 생성→저장→이전 토큰 삭제.
@RestController
@RequestMapping("/auth")
public class AuthController {
private final JwtUtil jwtUtil;
private final RedisService redis; // Redis에 tokenId -> 세션정보 저장
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest req, HttpServletResponse res) {
// 1) 브라우저: HttpOnly cookie에서 refresh token 꺼내기
String refreshToken = CookieUtil.getCookie(req, "refreshToken");
if (refreshToken == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("no refresh token");
}
// 2) Redis에서 조회 (tokenId로 저장했다고 가정)
RefreshSession session = redis.getRefreshSession(refreshToken);
if (session == null || session.isExpired()) {
// 재사용/탈취 의심 -> 전체 세션 로그아웃 등 추가 조치 가능
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("invalid refresh");
}
// 3) 발급: 새 Access JWT
String newAccess = jwtUtil.generateAccessToken(session.getUserId());
// 4) Rotate refresh token: 새 토큰 생성, DB에 저장, 이전 토큰 제거
String newRefreshToken = UUID.randomUUID().toString();
redis.saveRefreshSession(newRefreshToken, session.getUserId(), /*expiry*/);
redis.deleteRefreshSession(refreshToken);
// 5) Set HttpOnly Secure Cookie에 새 refreshToken 설정
Cookie cookie = new Cookie("refreshToken", newRefreshToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
cookie.setMaxAge((int) Duration.ofDays(14).getSeconds());
res.addCookie(cookie);
// 6) 응답: 새 Access 반환(클라이언트는 Authorization 헤더에 설정)
return ResponseEntity.ok(Map.of("accessToken", newAccess));
}
}
주의사항/확장:
- 토큰 재사용(이전 refresh가 이미 삭제되어 있는데 누군가 제출하면) 감지 시 해당 사용자 전체 세션 무효화(보안 사고 대응).
- deviceId/ip/userAgent 바인딩으로 이상 징후 탐지.
3-3. 탈취 대비 — 표준 대응 패턴
- Refresh 탈취 의심(같은 토큰이 다른 IP/디바이스에서 사용) → 즉시 토큰 모두 무효화 + 사용자 알림 + 강제 로그아웃
- Refresh 회전 전략을 쓰면 탈취자는 기존 토큰을 재사용 못함(재발급하면 기존 것은 삭제됨). 재사용이 감지되면 공격 시도 처리.
4. 추가 팁
- 토큰 저장 권장 정리
- Browser SPA: Access → 메모리(또는 short-lived in-memory), Refresh → HttpOnly Secure Cookie
- Native App: Access/Refresh 모두 OS 보안 저장소 사용(키체인 등)
- Refresh 토큰 회전(Rotating Refresh Tokens)
- 매번 재발급하면서 이전 토큰 즉시 무효화 → 재사용(replay) 시 탐지.
- 서버측 저장 + 블랙리스트
- Access는 JWT로 stateless 인증하되, 필요시(강제 로그아웃 등) 블랙리스트로 차단(보통 짧은 기간에만 유지).
- Refresh는 반드시 서버 저장(토큰ID + 유저 + 디바이스 정보).
- 단계적 검증(Defense in Depth)
- JWT 서명·만료 검증
- jti / tokenId를 서버 저장소에서 검증(특히 Refresh)
- device fingerprint / IP / UA 체크로 의심 세션 차단
- Rate-limit / Brute-force 방지
/auth/refresh,/auth/login등 민감 엔드포인트에 대해 rate-limit 적용
- 모니터링과 탐지
- Refresh 재사용, 비정상적 발급 빈도, 지역 변경 등 이벤트를 모니터링해 자동 차단 또는 알림.
- 로그아웃 처리
- 클라이언트 로그아웃 → 서버에서 Refresh 삭제 + 응답으로 refresh cookie 만료 처리. (Access는 곧 만료)
- 만료 정책 권장값(예시)
- Access: 5–15분
- Refresh: 7–30일 (서비스 성격에 따라)
- 너무 길면 탈취 위험↑, 너무 짧으면 UX↓.
반응형
'Backend > Study' 카테고리의 다른 글
| [Study] Docker에서 MySQL 연결 안 될 때 해결법 (0) | 2025.10.10 |
|---|---|
| [Study] JWT 활용 및 예제 코드 (0) | 2025.10.03 |
| [Study] JWT 토큰 인증 방식과 보안 고려사항 (0) | 2025.10.01 |
| [Study] 서버 다운 없이 배포하는 방법 (무중단 배포) (0) | 2025.09.30 |
| [Study] Nginx 리버스 프록시 설정 예제 (502/504 에러 해결) (0) | 2025.09.29 |