[ 13주차 - 1112 ]
금일 커리큘럼
├ 09:00 ~ 12:00 backend 프로그래밍 (JWT 인증 흐름 심화 학습, JwtAuthenticationToken 구현)
└ 13:00 ~ 18:00 backend 프로그래밍 (JwtTokenizer, JwtAuthenticationFilter, SecurityConfig, UserApiController)
JWT 인증 흐름 심화 학습
- 전일에 다룬 RefreshToken 저장 기능에서 확장
- 실제 JWT 기반 인증 절차의 전 과정(토큰 생성 → 검증 → 인증객체 등록 → 필터체인)을 구현하고 검증 학습
디렉토리 구조
src/main/java/org/example/jwtexam
├── domain
│ ├── User.java
│ ├── Role.java
│ └── RefreshToken.java
│
├── dto
│ ├── UserLoginDto.java
│ ├── UserLoginResponseDto.java
│ └── UserResponseDto.java
│
├── repository
│ ├── UserRepository.java
│ ├── RoleRepository.java
│ └── RefreshTokenRepository.java
│
├── security
│ ├── CustomUserDetails.java
│ └── CustomUserDetailsService.java
│
├── service
│ ├── UserService.java
│ └── RefreshTokenService.java
│
├── config # 이번 학습 작업
│ └── SecurityConfig.java
│
├── controller # 이번 학습 작업
│ └── UserApiController.java
│
└── jwt
├── exception
│ ├── CustomAuthenticationEntryPoint.java
│ └── JwtExceptionCode.java
│
├── token # 이번 학습 작업
│ └── JwtAuthenticationToken.java
│
├── util # 이번 학습 작업
│ └── JwtTokenizer.java
│
└── filter # 이번 학습 작업
└── JwtAuthenticationFilter.java
주요 작업 내용
| 구분 | 학습 내용 |
|---|---|
| JwtAuthenticationToken | Spring Security의 인증 객체를 JWT 기반으로 구현 |
| JwtTokenizer | 토큰 생성, 파싱, 만료 검증 처리 |
| JwtAuthenticationFilter | 요청마다 토큰 검증 및 인증객체 등록 |
| SecurityConfig | JWT 필터를 시큐리티 체인에 통합, 세션 제거 |
| UserApiController | 로그인 및 토큰 재발급 API 구현 |
해당 JWT 인증 흐름
1) 로그인 요청 및 토큰 발급 흐름
UserApiController- /login API 호출 시- 클라이언트는 username, password를 포함하여 요청
sequenceDiagram
participant C as Client
participant UC as UserApiController
participant US as UserService
participant JT as JwtTokenizer
participant RT as RefreshTokenService
C->>UC: (1) 로그인 요청<br>(username / password)
UC->>US: (2) 사용자 조회<br>(findByUsername)
US-->>UC: (3) UserResponseDto 반환
UC->>UC: (4) PasswordEncoder.matches()<br>→ 비밀번호 검증
alt 비밀번호 불일치 또는 사용자 없음
UC-->>C: (5) 401 Unauthorized<br>("아이디 또는 비밀번호가 올바르지 않음")
else 로그인 성공
UC->>JT: (6) AccessToken / RefreshToken 생성
JT-->>UC: (7) JWT 토큰 문자열 반환
UC->>RT: (8) 토큰 추가<br>(addRefreshToken)
RT-->>UC: (9) RefreshToken DB 저장 완료
UC->>UC: (10) 쿠키 생성<br>(accessToken / refreshToken)
UC-->>C: (11) 응답<br>(UserLoginResponseDto : 토큰 쿠키)
end
2) 인증 필터 흐름
JwtAuthenticationFilter- jwt 인증 관련 모든 요청에 대해 동작SecurityConfig의 시큐리티 필터 체인에JwtAuthenticationFilter인스턴스 등록상태
sequenceDiagram
participant C as Client
participant F as JwtAuthenticationFilter
participant JT as JwtTokenizer
participant CU as CustomUserDetails
participant SC as SecurityContextHolder
C->>F: (1) 요청 전송<br>(Authorization 헤더 또는 Cookie 포함)
F->>F: (2) getToken()<br>→ 토큰 추출
alt 토큰 없음
F-->>C: (3) 필터 통과 (비인증 요청)
else 토큰 존재
F->>JT: (4) parseAccessToken()<br>→ 토큰 파싱
JT-->>F: (5) Claims 반환
F->>CU: (6) CustomUserDetails 생성
F->>F: (7) JwtAuthenticationToken 생성<br>(authorities, CustomUserDetails)
F->>SC: (8) 인증 객체 등록<br>(SecurityContextHolder.setAuthentication)
end
F-->>C: (9) 다음 필터로 요청 전달<br>(filterChain.doFilter)
3) 토큰 재발급 흐름 (/api/refreshToken)
UserApiController- /refreshToken API 호출 시- 클라이언트는 쿠키에 저장된 refreshToken을 포함하여 요청
sequenceDiagram
participant C as Client
participant UC as UserApiController
participant JT as JwtTokenizer
participant RS as RefreshTokenService
participant US as UserService
C->>UC: (1) 토큰 재발급 요청<br>(Cookie: refreshToken 포함)
UC->>UC: (2) getRefreshTokenFromCookies()<br>→ 쿠키에서 refreshToken 추출
alt refreshToken 없음
UC-->>C: (3) 400 Bad Request<br>("리프레시 토큰이 없음")
else refreshToken 존재
UC->>JT: (4) parseRefreshToken()<br>→ 토큰 유효성 검사 및 Claims 파싱
JT-->>UC: (5) Claims 반환<br>
UC->>RS: (6) DB에서 refreshToken 조회<br>(findRefreshToken)
RS-->>UC: (7) 저장된 토큰 비교
alt DB 토큰 불일치
UC-->>C: (8) 401 Unauthorized<br>("리프레시 토큰이 잘못됨")
else 유효한 토큰
UC->>US: (9) 사용자 정보 조회<br>(getUser)
US-->>UC: (10) UserResponseDto 반환
UC->>JT: (11) 새 AccessToken 생성
JT-->>UC: (12) JWT 문자열 반환
UC->>UC: (13) 새 AccessToken 쿠키 추가
UC-->>C: (14) 200 OK
end
end
1. JwtAuthenticationToken 구현
JWT 인증 객체 모델 (Authentication 구현체)
- AbstractAuthenticationToken : 스프링 시큐리티에서 제공하는 추상 클래스
- 인증 토큰의 기본 기능을 제공
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private Object principal; // 사용자 정보 (UserDetails)
private Object credentials; // 자격 증명 (보통 - 비밀번호, JWT 기반에서는 null)
// 인증 완료 후 호출되는 생성자
public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credentials) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(true); //인증 완료상태
}
// 인증 전 호출되는 생성자 (JWT만 존재하는 상태)
public JwtAuthenticationToken(String token) {
super(null); //권한없음
this.principal = null; //아직 사용자 정보 없음
this.credentials = token;
setAuthenticated(false); //인증 전 상태
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
핵심 포인트
- Spring Security의 AbstractAuthenticationToken을 상속받아 JWT 기반 인증 모델로 커스터마이징
- 증 전/후의 두 가지 상태를 생성자 분리로 관리
- 번호 기반 인증이 아니라, JWT를 검증하여 신뢰하기 때문에
credentials는 주로null처리
짚고 넘어가기
credentials: 인증에 사용되는 자격 증명 정보- 일반적으로 비밀번호를 의미하지만, JWT 기반 인증에서는 토큰 자체가 자격 증명이므로
null로 처리
- 일반적으로 비밀번호를 의미하지만, JWT 기반 인증에서는 토큰 자체가 자격 증명이므로
principal: 인증된 사용자의 정보- UserDetails 객체가 주로 사용되며, 인증 후에 사용자 정보를 담음
authorities: 사용자의 권한 정보- 인증 후에 사용자에게 부여된 권한 목록을 담음
SecurityContextHolder: 스프링 시큐리티의 컨텍스트 저장소- 현재 인증된 사용자 정보를 보관하는 역할
setAuthentication()메서드를 통해JwtAuthenticationToken객체를 저장
해당 로직의 역할
JwtAuthenticationToken은 스프링 시큐리티에서 인증 정보를 담는 객체- 즉, JWT 기반 로그인 이후
SecurityContextHolder에 저장되는 인증 객체의 형태를 정의 - 이 객체는 시큐리티 필터(JwtAuthenticationFilter)에서 생성되어,
- 사용자의 권한(authorities)과 정보(principal) 를 담고 인증 컨텍스트에 등록됨
인증 전 / 후 상태 구분
- 인증전 :
JwtAuthenticationToken(String token)- 로그인 요청 후, 아직 검증되지 않은 JWT를 가질 때
- 자격 증명(credentials)만 존재, 사용자 정보(principal) 없음
- 인증후 :
wtAuthenticationToken(Collection<? extends GrantedAuthority> ...)- JWT 유효성 검증 후, 사용자 정보 확인 완료 시
CustomUserDetails등 사용자 정보 + 권한 정보 포함
sequenceDiagram
participant F as JwtAuthenticationFilter
participant JT as JwtTokenizer
participant CU as CustomUserDetails
participant AT as JwtAuthenticationToken
participant SC as SecurityContextHolder
F->>JT: (1) parseAccessToken()<br>JWT 파싱 및 검증
JT-->>F: (2) Claims 반환<br>(userId, roles 등)
F->>CU: (3) CustomUserDetails 생성<br>(사용자 정보 세팅)
F->>AT: (4) JwtAuthenticationToken 생성<br>(principal, authorities)
AT->>SC: (5) SecurityContextHolder의<br>setAuthentication() 메서드<br>→ 인증 완료 상태 등록
2. JwtTokenizer 구현
AccessToken과 RefreshToken을 각각 생성하고, 검증 및 파싱 기능을 제공
@Component
@Slf4j
public class JwtTokenizer {
private final byte[] accessSecret;
private final byte[] refreshSecret;
private final Long accessTokenExpiration;
private final Long refreshTokenExpiration;
public JwtTokenizer(
@Value("${jwt.secretKey}") String accessSecret,
@Value("${jwt.refreshKey}") String refreshSecret,
@Value("${jwt.access-expiration-ss}") long accessExpSeconds,
@Value("${jwt.refresh-expiration-ss}") long refreshExpSeconds
) {
this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8);
this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8);
this.accessTokenExpiration = accessExpSeconds * 1000L;
this.refreshTokenExpiration = refreshExpSeconds * 1000L;
}
// 시크릿 키
private SecretKey getSignatureKey(byte[] secretKey) {
return Keys.hmacShaKeyFor(secretKey);
// return new SecretKeySpec(secretKey, "HmacSHA256");
}
// 토큰 파싱
private Claims parseToken(String token, byte[] secret) {
return Jwts.parser()
.verifyWith(getSignatureKey(secret))
.build()
.parseSignedClaims(token)
.getPayload();
}
// 액세스 토큰
public String createAccessToken(
Long id,
String email,
String name,
String username,
List<String> roles
) {
return createToken(id,email,name,username,roles,accessTokenExpiration,accessSecret);
}
// 리플래시 토큰
public String createRefreshToken(
Long id,
String email,
String name,
String username,
List<String> roles
) {
return createToken(id,email,name,username,roles,refreshTokenExpiration,refreshSecret);
}
// 토큰 생성
public String createToken(
Long id,
String email,
String name,
String username,
List<String> roles,
Long expire,
byte[] secret
) {
Date now = new Date();
Date exp = new Date(now.getTime() + expire);
return Jwts.builder()
.subject(username)
.claim("id", id)
.claim("email", email)
.claim("name", name)
.claim("username", username)
.claim("roles", roles)
.issuedAt(now)
.expiration(exp)
.signWith(getSignatureKey(secret))
.compact();
}
// 액세스 파싱
public Claims parseAccessToken(String token) {
return parseToken(token, this.accessSecret);
}
// 리플래시 파싱
public Claims parseRefreshToken(String token) {
return parseToken(token, this.refreshSecret);
}
// username 파싱
public String getUserNameFromToken(String token) {
if(token == null || !token.startsWith("Bearer ")) {
throw new IllegalArgumentException("[getUserNameFromToken] 잘못된 형식임");
}
try {
String jwt = token.substring(7);
Claims claims = parseToken(jwt, accessSecret);
return claims.get("username", String.class);
} catch (ExpiredJwtException e) {
log.warn("[getUserNameFromToken] 만료된 access 토큰 :: {}", e.getMessage());
throw new RuntimeException(JwtExceptionCode.EXPRIED_TOKEN.getMessage());
} catch (SignatureException | MalformedJwtException e) {
log.warn("[getUserNameFromToken] 유효하지 않은 토큰 :: {}", e.getMessage());
throw new RuntimeException(JwtExceptionCode.INVALID_TOKEN.getMessage());
} catch (Exception e) {
log.warn("[getUserNameFromToken] JWT 파싱중 그외 error :: {}", e.getMessage());
throw new RuntimeException(JwtExceptionCode.UNKNOWN_ERROR.getMessage());
}
}
public Long getAccessTokenExpiration() {
return accessTokenExpiration;
}
public Long getRefreshTokenExpiration() {
return refreshTokenExpiration;
}
}
핵심 포인트
- JWT 생성과 검증(파싱)을 담당하는 유틸리티 클래스
- AccessToken / RefreshToken을 분리 관리
- Claim에 사용자 정보를 담고 HMAC-SHA256으로 서명
- subject는 username으로 설정해 인증 주체를 명확히 함
ExpiredJwtException,MalformedJwtException등 예외에 따른 로그 및 커스텀 처리 구현
짚고 넘어가기
createAccessToken(): 단기 인증용 토큰 생성createRefreshToken(): 재발급용 장기 토큰 생성 (DB 저장)parseToken(): 서명 검증 및 Payload(Claims) 추출getUserNameFromToken(): JWT 헤더에서 username 추출 (Bearer 제거 후 처리)getSignatureKey(): SecretKey를 바이트 배열로 변환하여 키 생성
해당 로직의 역할
- JWT 기반 인증에서 핵심적인 역할을 하는 토큰 발급기 (Token Provider)
- 컨트롤러나 서비스 단에서 인증 요청 시 AccessToken / RefreshToken을 발급
- 필터(
JwtAuthenticationFilter)에서 토큰 유효성 검증 시 이 유틸리티를 사용 - Stateless 환경(세션 미사용)에서 사용자 식별과 권한 부여의 근거로 활용됨
sequenceDiagram
participant UC as UserApiController
participant JT as JwtTokenizer
participant DB as RefreshTokenService
participant F as JwtAuthenticationFilter
UC->>JT: (1) createAccessToken() <br> createRefreshToken() 호출
JT-->>UC: (2) JWT 문자열 반환
UC->>DB: (3) RefreshToken 저장
DB-->>UC: (4) 저장 완료
F->>JT: (5) parseAccessToken()<br>→ JWT 유효성 검증
JT-->>F: (6) Claims 반환 (username, roles)
3. JwtAuthenticationFilter 구현
모든 요청에 대해 JWT의 유효성을 검증하고, 인증 정보를 SecurityContextHolder에 등록하는 필터
OncePerRequestFilter: 스프링 시큐리티에서 제공하는 추상 필터 클래스- 요청당 한 번만 실행되도록 보장
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter { // OncePerRequestFilter : 전체요청 중 한번만
private final JwtTokenizer jwtTokenizer;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
log.info("[JwtAuthenticationFilter] ======= start : url = {}", request.getRequestURI());
// 토큰 얻어서 파싱 후 필요한정보 찾아서 authentication 객체를 시큐리티컨텍스트홀더에 담아야함
String token = getToken(request);
if(StringUtils.hasText(token)) {
try {
getAuthentication(token);
} catch (ExpiredJwtException e) {
request.setAttribute("exception", JwtExceptionCode.EXPRIED_TOKEN.getCode());
log.error("[Exception] expired Token :: {}", e.getMessage());
throw new BadCredentialsException(JwtExceptionCode.EXPRIED_TOKEN.getMessage(), e);
} catch (UnsupportedJwtException e) {
request.setAttribute("exception", JwtExceptionCode.UNSUPPORTED_TOKEN.getCode());
log.error("[Exception] unsupported Token :: {}", e.getMessage());
throw new BadCredentialsException(JwtExceptionCode.UNSUPPORTED_TOKEN.getMessage(), e);
} catch (MalformedJwtException e) {
request.setAttribute("exception", JwtExceptionCode.INVALID_TOKEN.getCode());
log.error("[Exception] invalid Token :: {}", e.getMessage());
throw new BadCredentialsException(JwtExceptionCode.INVALID_TOKEN.getMessage(), e);
} catch (IllegalArgumentException e) {
request.setAttribute("exception", JwtExceptionCode.NOT_FOUND_TOKEN.getCode());
log.error("[Exception] not found Token :: {}", e.getMessage());
throw new BadCredentialsException(JwtExceptionCode.NOT_FOUND_TOKEN.getMessage(), e);
} catch (Exception e) {
log.error("[Exception] jwt filter error :: {} ", e.getMessage());
throw new BadCredentialsException("jwt filter Exception", e);
}
}
filterChain.doFilter(request,response);
log.info("[JwtAuthenticationFilter] ======= end");
}
// 토큰 Get
private String getToken(HttpServletRequest request) {
// header에서 액세스 토큰 찾아봄
String authorization = request.getHeader("Authorization");
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
// 액세스 토큰이 쿠키를 통해서 요청했을 때
Cookie[] cookies = request.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
// 어센티케이션 객체
private void getAuthentication(String token) {
// Cookie[] cookies = request.getCookies();
Claims claims = jwtTokenizer.parseAccessToken(token);
String subject = claims.getSubject();
String username = claims.get("username", String.class);
String name = claims.get("name", String.class);
String email = claims.get("email", String.class);
List<GrantedAuthority> authorities = getAuthorities(claims);
// 정보를 유저디테일 & 어센티케이션 생성
CustomUserDetails customUserDetails = new CustomUserDetails(
username, name, email,
authorities.stream()
.map(GrantedAuthority::getAuthority)
.map(auth-> auth.replace("ROLE_", ""))
// .toList()
.collect(Collectors.toList())
);
Authentication authentication =
new JwtAuthenticationToken(authorities, customUserDetails, null);
// 시큐리티컨텍스트홀더에 담기
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 권한정보 변환 (string -> 시큐리티형태)
private List<GrantedAuthority> getAuthorities(Claims claims) {
List<String> roles = (List<String>) claims.get("roles");
List<GrantedAuthority> authorities = new ArrayList<>();
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority(role));
}
return authorities;
}
}
핵심 포인트
- OncePerRequestFilter를 상속받아 요청마다 단 한 번 실행
- 토큰을 Header(Authorization) 또는 Cookie(accessToken) 에서 추출
- JWT 유효성 검증 → Claims 파싱 → 사용자 정보(CustomUserDetails) 구성
SecurityContextHolder에JwtAuthenticationToken을 등록하여 인증 상태로 전환- 예외 상황(만료, 위조, 포맷 오류)에 따른 세부 로그 출력 및 예외 코드 전달
짚고 넘어가기
getToken(): 헤더 또는 쿠키에서 AccessToken 추출getAuthentication(): JWT 파싱 → 사용자 정보 변환 → 인증 객체 등록getAuthorities(): JWT 내부 roles → GrantedAuthority 변환SecurityContextHolder: 인증 정보를 전역 Context에 저장
해당 로직의 역할
- 스프링 시큐리티의 필터 체인 중 UsernamePasswordAuthenticationFilter 앞단에 삽입
- 클라이언트 요청이 들어올 때마다 토큰을 확인하고 인증 여부를 결정
- 인증 완료 시, 이후 컨트롤러에서
@AuthenticationPrincipal등으로 사용자 정보 접근 가능
4. SecurityConfig 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/login", "/api/refreshToken").permitAll()
.anyRequest().authenticated()
)
// JwtAuthenticationFilter는 new로 주입하는 이유?
// 이미 필터체인이 @Bean으로 주입되고 있기에 인스턴스로 주입하여 작동
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenizer), UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안함
)
.csrf(csrf -> csrf.disable())
// CORS 설정 활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 예외 처리 설정
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint)
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// CORS 설정
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true); // 쿠키 허용
// 허용할 출처 설정
configuration.setAllowedOriginPatterns(List.of("http://localhost:8080", "http://localhost:3000"));
configuration.setAllowedHeaders(List.of("*")); // 모든 헤더 허용
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); // 모든 메서드 허용
configuration.setMaxAge(3600L); // 캐싱 시간 설정
// CORS 설정 소스
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
핵심 포인트
- JwtAuthenticationFilter를 시큐리티 필터체인에 등록
- SessionCreationPolicy.STATELESS → 세션 저장 X
- formLogin() / csrf() 비활성화 (REST 환경에 맞게)
- CustomAuthenticationEntryPoint를 통해 인증 예외 처리
- CORS 설정으로 프론트엔드(3000번 포트) 허용
짚고 넘어가기
- JWT 기반 인증은 세션을 사용하지 않는 구조이므로 STATELESS로 설정
.addFilterBefore()로 등록된 JwtAuthenticationFilter는 모든 요청 전 실행.exceptionHandling()으로 인증 예외 발생 시 CustomAuthenticationEntryPoint에서 처리
5. UserApiController 설정
로그인 요청 및 토큰 재발급 처리 설정
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
@Slf4j
public class UserApiController {
private final UserService userService;
private final RefreshTokenService refreshTokenService;
private final JwtTokenizer jwtTokenizer;
private final PasswordEncoder passwordEncoder;
// 로그인 API
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody @Valid UserLoginDto userLoginDto,
BindingResult result,
HttpServletResponse response
) {
if (result.hasErrors()) {
return ResponseEntity.badRequest().body(result.getAllErrors());
}
// 사용자 조회
UserResponseDto user = userService.findByUsername(userLoginDto.getUsername()).orElse(null);
// 아이디 또는 비밀번호 검증
if (user == null || !passwordEncoder.matches(userLoginDto.getPassword(), user.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("아이디 또는 비밀번호가 올바르지 않음");
}
// 토큰 생성
String accessToken = jwtTokenizer.createAccessToken(
user.getId(), user.getEmail(), user.getName(), user.getUsername(), user.getRoles());
String refreshToken = jwtTokenizer.createRefreshToken(
user.getId(), user.getEmail(), user.getName(), user.getUsername(), user.getRoles());
// 리프레시 엔티티 생성 후 토큰 저장
RefreshToken refreshTokenEntity = new RefreshToken();
refreshTokenEntity.setToken(refreshToken);
refreshTokenEntity.setUserId(user.getId());
refreshTokenService.addRefreshToken(refreshTokenEntity);
// 쿠키에 토큰 저장 방식
addTokenCookie(response, "accessToken", accessToken, jwtTokenizer.getAccessTokenExpiration());
addTokenCookie(response, "refreshToken", refreshToken, jwtTokenizer.getAccessTokenExpiration());
// 토큰 응답 DTO 생성
UserLoginResponseDto userLoginResponseDto = UserLoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.userId(user.getId())
.name(user.getName())
.build();
return ResponseEntity.ok(userLoginResponseDto);
}
// 토큰 재발급 API
@PostMapping("/refreshToken")
public ResponseEntity<?> refreshToken(HttpServletRequest request, HttpServletResponse response) {
String token = getRefreshTokenFromCookies(request);
if (token == null) {
return ResponseEntity.badRequest().body("리프레시 토큰이 없음");
}
try {
// 토큰 파싱
Claims claims = jwtTokenizer.parseRefreshToken(token);
// DB 토큰과 비교
RefreshToken dbToken = refreshTokenService.findRefreshToken(token)
.orElseThrow(() -> new IllegalArgumentException("토큰이 없음"));
if (!token.equals(dbToken.getToken())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("리프레시 토큰이 잘못됨");
}
// 사용자 정보 조회
Long userId = claims.get("id", Long.class);
UserResponseDto user = userService.getUser(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾지 못함"));
List<String> roles = claims.get("roles", List.class);
// 새 액세스 토큰 생성
String accessToken = jwtTokenizer.createAccessToken(
userId, user.getEmail(), user.getName(), user.getUsername(), roles);
// 새 액세스 토큰 쿠키에 추가
addTokenCookie(response, "accessToken", accessToken, jwtTokenizer.getAccessTokenExpiration());
// 응답 DTO 생성
UserLoginResponseDto responseDto = UserLoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(token)
.name(user.getName())
.userId(user.getId())
.build();
return ResponseEntity.ok(responseDto);
} catch (Exception e) { // 토큰 파싱 오류 등
log.error("Refresh token error: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh token invalid or expired");
}
}
// 쿠키에 토큰 추가 메서드
private void addTokenCookie(HttpServletResponse response, String name, String value, Long expireCount) {
Cookie cookie = new Cookie(name, value);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(Math.toIntExact(expireCount / 1000));
response.addCookie(cookie);
}
// 쿠키에서 리프레시 토큰 추출 메서드
private String getRefreshTokenFromCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
핵심 포인트
- /api/login : 사용자 검증 후 AccessToken + RefreshToken 동시 발급
- /api/refreshToken : RefreshToken 검증 후 새 AccessToken 재발급
- RefreshToken은 DB에 저장하여 위조·탈취 대비
- JWT는 쿠키(HttpOnly)로 응답 → XSS 방지 강화
- 응답 시 AccessToken은 짧게, RefreshToken은 장기로 설정
짚고 넘어가기
- AccessToken은 빠르게 만료되므로 주기적 재발급 구조 필요
- RefreshToken은 DB와 쿠키에 동시에 존재해야 정상 인증
- PasswordEncoder.matches()는 평문 비밀번호와 암호화 비밀번호 비교
- JWT 재발급 시 기존 RefreshToken은 재사용하거나 갱신 정책에 따라 변경 가능
최종 정리
UserApiController (로그인 및 발급)
→ JwtAuthenticationFilter (요청 검증)
→ JwtAuthenticationToken (인증 저장)
→ SecurityContextHolder (인증 유지)
→ SecurityConfig (환경 구성)
6. JWT 인증 플로우의 검증용 HTTP 테스트
### 로그인 성공 (토큰 발급)
# - 예상결과: 200 OK
POST http://localhost:8080/api/login
Content-Type: application/json
{
"username": "star1431",
"password": "star1431"
}
> {%
client.global.set("accessToken", response.body.accessToken);
client.global.set("refreshToken", response.body.refreshToken);
%}
### 로그인 실패 (비밀번호 오류)
# - 예상결과: 401 Unauthorized
POST http://localhost:8080/api/login
Content-Type: application/json
{
"username": "star1431",
"password": "wrong"
}
### 쿠키로 인증 요청
# - 예상결과 : 200 OK
GET http://localhost:8080/api/welcome
Cookie: accessToken={{accessToken}}
### 헤더로 인증 요청
# - 예상결과 : 200 OK
GET http://localhost:8080/api/welcome
Authorization: Bearer {{accessToken}}
### 인증 없이 접근
# - 예상결과 : 401 Unauthorized
GET http://localhost:8080/api/welcome
### RefreshToken으로 재발급 요청
# - 예상결과 : 200 OK
POST http://localhost:8080/api/refreshToken
Cookie: refreshToken={{refreshToken}}
### ---------------------------------------
### AccessToken 만료 후 요청
# 필수! => access-expiration-ms를 10초(10000L)로 설정 후 테스트
# - 예상결과 : 401 Unauthorized
GET http://localhost:8080/api/welcome
Cookie: accessToken={{accessToken}}
### RefreshToken으로 재발급 요청
# - AccessToken 만료상태에서 진행
# - 예상결과 : 200 OK
# - 새로운 accessToken 쿠키가 갱신됨
POST http://localhost:8080/api/refreshToken
Cookie: refreshToken={{refreshToken}}
> {%
client.global.set("accessToken", response.body.accessToken);
client.global.set("refreshToken", response.body.refreshToken);
%}
### 재발급 받은 AccessToken으로 API 요청
# - 예상결과 : 200 OK
# - 정상적으로 "Hello World" 응답
GET http://localhost:8080/api/welcome
Cookie: accessToken={{accessToken}}