실질적 코드 작성이 필요한 부분
Security Filter Chain 이 시작 될 때 JWT 사용이라면 JWT Filter 를 추가한다
UserPasswordAuthentication 이나 JWT Filter 에서 UserDetailsService.loadUserByUsername() 을 부른다
SecurityContextHolder 에 인증 객체를 저장한다

스프링 프레임워크의 보안 요소를 위한 별도의 프레임워크
SpringSecurityFilterChain 이라는 특유의 별도 Filter 들을 적용하여
보안 요소를 동작시킨다
UsernamePasswordAuthenticationFilter → 폼 로그인 처리용 필터
ExceptionTranslationFilter → 401, 403 핸들링 (인증/인가의 예외처리 담당 필터)
FilterSecurityInterceptor = AuthoriztionFilter → 권한 거부 처리용 필터
Security Filter Chain 시작
UserPasswordAuthentication Filter 와 커스텀 JWT Filter 작동
AuthenticationManager, Provider 를 거쳐 인증이 통과 된다
UserDetailsService.loadUserByUsername() (DB에서 사용자 조회)
UsernamePasswordAuthenticationToken 객체 생성
SecurityContext 에 저장
Controller 도달
https://lifelogdevlog.inblog.io/96797 를 참조
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
// Security 내 PasswordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// JwtFilter 등록
private final JwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.disable())
.sessionManagement(sessionManagementConfigurer -> sessionManagementConfigurer.disable())
.formLogin(formLoginConfigurer -> formLoginConfigurer.disable())
.httpBasic(httpBasicConfigurer -> httpBasicConfigurer.disable())
.authorizeHttpRequests(authorizeRequests ->
// 인증 통과 URI 작성 또는 권한 명세
authorizeRequests.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
💡
실질적 코드 작성이 필요한 부분
Security Filter Chain 이 시작 될 때 JWT 사용이라면 JWT Filter 를 추가한다
UserPasswordAuthentication 이나 JWT Filter 에서 UserDetailsService.loadUserByUsername() 을 부른다
SecurityContextHolder 에 인증 객체를 저장한다
Controller 레이어
@AuthenticationPrincipal Annotation + UserDetails
@GetMapping("/me")
public ResponseEntity<DataResponse<GetMyProfileResponse>> getMyProfile(
@AuthenticationPrincipal UserDetails loginAdminInfo
) {
...
}
Service
SecurityContextHolder.getContext().getAuthentication()
Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public void createProduct(ProductRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
AdminDetail adminDetail = (AdminDetail) authentication.getPrincipal();
Long adminId = adminDetail.getAdmin().getId();
...
}
Json Web Token
Header, Payload, Signature 세 부분으로 구성
이 세 부분은 Base64로 인코딩 된 JSON 문자열을 . 으로 이은 형태
Header
JWT를 생성하는데 사용한 알고리즘을 명시한 부분
Payload
JWT의 내용이 담긴 부분 (Subject, Claim 등)
Signature
Header 와 Payload 를 서버만 알고 있는 비밀키로 암호화한 부분
서버에 JWT가 도달하면 Signature 와 서버가 자신이 가진 비밀키로 Header 와 Payload를 암호화 하여 비교함
비밀키 내용 가져오기
서명에 사용할 비밀키와 검증을 위한 parser 만들기
JWT builder 통한 토큰 생성하기
parser 가 내부 클레임 정보에 접근 가능한지 확인하기 (검증), 예외처리하기
내부 내용 추출하게 하기
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {
// 만료 시간 가져오기
@Value("${jwt.secret.accessExpire}")
private Long accessTokenExpire;
@Value("${jwt.secret.refreshExpire}")
private Long refreshTokenExpire;
// 비밀키 구문 가져오기
@Value("${jwt.secret.key}")
private String secretKeyStr;
// 서명에 사용할 비밀키와 검증을 위한 Parser
private SecretKey secretKey;
private JwtParser parser;
@PostConstruct
public void init() {
byte[] bytes = Decoders.BASE64.decode(secretKeyStr); // 비밀키의 Base64 변환
this.secretKey = Keys.hmacShaKeyFor(bytes); // HMAC-SHA-256 알고리즘으로 서명에 필요한 비밀키 생성 (공식문서)
this.parser = Jwts.parser().verifyWith(this.secretKey).build(); // 비밀키를 사용한 parser 선언
}
// 토큰 생성
public String createAccessToken(String userUid) {
Date now = new Date();
return Jwts.builder()
.subject(userUid)
.issuedAt(now)
.expiration(new Date(now.getTime() + accessTokenExpire))
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
}
public String createRefreshToken(String userUid) {
Date now = new Date();
return Jwts.builder()
.subject(userUid)
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshTokenExpire))
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
}
// 토큰 검증
public boolean validateToken(String token) {
if(token == null || token.isBlank()) return false;
try {
parser.parseSignedClaims(token);
return true;
} catch (JwtException e) {
log.error("JwtException : {}", e.getMessage());
return false;
}
}
// 토큰 내 클레임 추출
private Claims extractClaimByToken(String token) {
return parser.parseSignedClaims(token).getPayload();
}
// Subject 추출하기
public String extractSubject(String token) {
return parser.parseSignedClaims(token).getPayload().getSubject();
}
}
OncePerRequestFilter 상속받기 (Filter 의 동작을 한번만 받아야 JWT가 올바르게 작동할 가능성이 큼)
doFilterInternal 오버라이드 하기
토큰이 헤더에 Authorization 에 담기므로 확인, 없으면 예외처리
올바른 토큰인지 검증 메서드로 검증하기
올바르면 UserDetailService 에 담아 SecurityContextHolder 에 UsernamePasswordAuthenticationToken 으로 담아주기
인증이 필요없는 URI 명세하기, shouldNotFilter 에 등록하기
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
private final AdminDetailService adminDetailService;
private static final PathPatternParser patternParser = new PathPatternParser();
// 인증 제외 패턴 선언
private static final List<PathPattern> EXCLUDE_PATTERNS = List.of(
patternParser.parse("/api/auth/login")
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청
log.info("JwtAuthFilter IN");
// 올바른 토큰이 있는지?
// Authorization 토큰
String authorization = request.getHeader("Authorization");
if(authorization == null || !authorization.startsWith("Bearer ")) {
MessageResponse errorResponse = MessageResponse.fail(HttpStatus.UNAUTHORIZED.name(), "인증 정보가 없습니다");
String json = objectMapper.writeValueAsString(errorResponse);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(json);
return;
}
// Bearer 검증 잘라내기
String token = authorization.substring("Bearer ".length());
if(!jwtUtil.validateToken(token)) {
MessageResponse errorResponse = MessageResponse.fail(HttpStatus.UNAUTHORIZED.name(), "인증 정보에 대한 확인이 필요합니다");
String json = objectMapper.writeValueAsString(errorResponse);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(json);
return;
} else {
// 토큰 검증 완료 되었으니 정보를 담아서 활용
// 토큰에서 이메일 추출
String email = jwtUtil.getEmail(token);
// UserDetailService로 정보 조회
UserDetails adminDetail = adminDetailService.loadUserByUsername(email);
// 인증 객체 생성
UsernamePasswordAuthenticationToken authentication
= new UsernamePasswordAuthenticationToken(
adminDetail // principal (누구인지)
, null // credentials
, adminDetail.getAuthorities()); // 권한 정보
// SecurityContext에 인증 객체 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
// 응답
log.info("Test Filter2 OUT");
}
// URI가 토큰 체크를 해야하는 URI 인지?
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
log.info("JwtFilter IN - request URI : {}", request.getRequestURI());
PathContainer path = PathContainer.parsePath(request.getRequestURI());
return EXCLUDE_PATTERNS.stream().anyMatch(pattern -> pattern.matches(path));
}
}
[로그인 요청] →
AuthService.login() 호출
AuthenticationManager.authenticate() 호출
AuthenticationManager가 내부적으로 처리:
UserDetailsServiceImpl.loadUserByUsername(email) 호출
DB에서 Member 조회
UserDetails 객체 생성 (email, password, role 포함)
PasswordEncoder로 비밀번호 검증 (입력된 비밀번호 vs UserDetails의 비밀번호)
검증 실패 시: BadCredentialsException 발생
검증 성공 시: Authentication 객체 반환
Authentication에서 UserDetails 추출
Member 정보로 JWT 토큰 생성
RefreshToken 저장
→ [AccessToken 반환]
기본적으로 Login 시 AccessToken 발급과 비슷하다
하지만 Cookie 에 담아 사용하게 하는 것이 다르다
Controller
@PostMapping("/login")
public ResponseEntity<BaseResponse<LoginResponse>> login(
@RequestBody LoginRequest request
, HttpServletResponse response
) {
String email = request.email();
try {
// AuthenticationManager를 통한 인증
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password())
);
// JWT 토큰 생성
TokenInfo tokens = authService.login(request);
// 리프레쉬 토큰은 쿠키로 반환
Cookie refreshTokenCookie = new Cookie("refreshToken", tokens.refreshToken());
refreshTokenCookie.setPath("/");
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setMaxAge(3600 * 24 * 7);
//refreshTokenCookie.setHttpOnly(true); // 현재는 환경상 주석처리
response.addCookie(refreshTokenCookie);
// 액세스 토큰은 헤더로 반환
return ResponseEntity.ok()
.header("Authorization", "Bearer " + tokens.accessToken())
.body(BaseResponse.success(HttpStatus.OK.name(), null, new LoginResponse(true, email)));
} catch (AuthenticationException e) {
log.error("Login Error : {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(BaseResponse.fail(HttpStatus.UNAUTHORIZED.name(), MSG_NOT_MATCH_LOGIN, new LoginResponse(false, MSG_NOT_MATCH_LOGIN)));
}
}
Service
@Transactional
public TokenInfo login(LoginRequest request) {
Member member = memberRepository.findByEmailAndDeletedFalse(request.email()).orElseThrow(() -> new ServiceErrorException(ErrorEnum.ERR_NOT_MATCH_LOGIN));
// JWT 토큰 생성
String accessToken = jwtTokenProvider.createAccessToken(request.email(), member.getMemberUid(), member.getRole());
String refreshToken = jwtTokenProvider.createRefreshToken(request.email(), member.getMemberUid(), member.getRole());
refreshTokenRepository.save(RefreshToken.register(member, refreshToken, jwtTokenProvider.getExpireTime(refreshToken)));
return new TokenInfo(accessToken, refreshToken);
}
public record TokenInfo (
String accessToken
, String refreshToken
) {
}
AccessToken 만료됨
↓
[클라이언트]
↓ API 요청 (만료된 AccessToken)
[서버: JwtAuthenticationFilter]
↓ 토큰 검증 실패 → 401 Unauthorized 응답
[클라이언트]
↓ 401 감지 → RefreshToken으로 갱신 요청
[서버: POST /api/auth/refresh]
↓
RefreshToken 유효성 검증
DB 또는 Redis 등 에서 해당 RefreshToken 존재 여부 확인
(선택) RefreshToken 의 사용여부 확인
만료 시간 확인
↓ 모두 통과 시
새로운 AccessToken 발급
새로운 RefreshToken 발급
↓
[클라이언트]
새 AccessToken으로 재요청
[클라이언트]
↓ POST /api/auth/refresh / Request : { refreshToken: "구 토큰" }
[서버]
↓ 1-3. 검증
↓ 4. 새 AccessToken 발급
↓ 5. 새 RefreshToken 발급
↓ 6. 기존 RefreshToken 삭제
↓ 7. 새 RefreshToken DB 저장
↓ Response : { accessToken: "새 토큰", refreshToken: "새 토큰" }
[클라이언트]
↓ 둘 다 새로 저장
Controller
// Refresh Token 을 통한 재발급
@PostMapping("/refresh")
public ResponseEntity<BaseResponse<RefreshResponse>> refresh(@RequestBody RefreshRequest request) {
RefreshResponse response = authService.refreshToken(request.refreshToken());
return ResponseEntity.status(HttpStatus.OK)
.header("Authorization", "Bearer " + response.accessToken())
.body(BaseResponse.success(HttpStatus.OK.name(), null, response));
}
Service
@Transactional
public RefreshResponse refreshToken(String refreshToken) {
if(jwtTokenProvider.validateToken(refreshToken)) {
RefreshToken existRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken).orElseThrow(() -> new ServiceErrorException(ErrorEnum.ERR_TOKEN_EMPTY));
// 리프레쉬 토큰이 만료 되었는지 확인
if(existRefreshToken.getExpirationAt().isBefore(LocalDateTime.now())) {
refreshTokenRepository.delete(existRefreshToken);
throw new ServiceErrorException(ERR_TOKEN_EXPIRE);
}
// 유저 정보 가져오기
Member member = existRefreshToken.getMember();
if(!memberRepository.existsByEmailAndDeletedFalse(member.getEmail())) {
refreshTokenRepository.delete(existRefreshToken);
throw new ServiceErrorException(ERR_NOT_FOUND_MEMBER);
}
// 기존 토큰 삭제
refreshTokenRepository.delete(existRefreshToken);
// 새 토큰 발급
String newAccessToken = jwtTokenProvider.createAccessToken(member.getEmail(), member.getMemberUid(), member.getRole());
String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getEmail(), member.getMemberUid(), member.getRole());
// 새 리프레쉬 토큰 저장
refreshTokenRepository.save(RefreshToken.register(member, newRefreshToken, jwtTokenProvider.getExpireTime(newRefreshToken)));
return new RefreshResponse(newAccessToken, newRefreshToken);
} else {
log.error("Refresh Tokens Error : {}", "리프레쉬 토큰 올바르지 않음");
throw new ServiceErrorException(ErrorEnum.ERR_TOKEN_INVALID);
}
}
기본적으로 Stateless 라서 로그아웃 시 처리 방안에 대해 고민해야함
(토큰 무효화라는 것이 서버에서 할 수가 없으므로)
로그아웃된 AccessToken 을 블랙리스트 DB/Redis에 저장하여,
매 요청마다 확인하는 것
구현 순서
전용 엔티티 셋업
// AccessToken 블랙리스트 전용 Entity
@Getter
@Entity
@Table(name = "black_access_tokens")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BlackAccessToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long tokenId;
private String accessToken;
private LocalDateTime expirationAt;
private LocalDateTime listedAt;
private BlackAccessToken(String accessToken, LocalDateTime expirationAt) {
this.accessToken = accessToken;
this.expirationAt = expirationAt;
this.listedAt = LocalDateTime.now();
}
public static BlackAccessToken register(String accessToken, LocalDateTime expirationAt) {
return new BlackAccessToken(accessToken, expirationAt);
}
}
로그아웃 시 RefreshToken 삭제 처리,
AccessToken 을 블랙리스트 엔티티에 등록
@PostMapping("/logout")
public ResponseEntity<BaseResponse<Void>> logout(
@AuthenticationPrincipal UserDetails loginMemberInfo
, @RequestHeader(HttpHeaders.AUTHORIZATION) String accessTokenWithBearer
) {
String email = loginMemberInfo.getUsername();
String accessToken = accessTokenWithBearer.substring("Bearer ".length());
authService.logout(accessToken, email);
return ResponseEntity.status(HttpStatus.OK).body(BaseResponse.success(HttpStatus.OK.name(), MSG_LOGOUT, null));
}
JWT 인증 필터에서 블랙리스트 엔티티에 등록되었으면 거부하도록 처리
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
//.....
// AccessToken 블랙리스트 조회
if(blackAccessTokenRepository.existsByAccessToken(token)) {
log.error("블랙 리스트 등록 토큰 사용 감지");
BaseResponse<Void> baseResponse = BaseResponse.fail(HttpStatus.UNAUTHORIZED.name(), MSG_AUTH_WRONG, null);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(objectMapper.writeValueAsString(baseResponse));
return;
}
// 토큰 유효성 검증 및 후속 처리 계속
//.....
}
JWT 인증 필터 풀 코드 (다른 토이프로젝트에서 적용한 코드)
// BaseResponse 는 커스텀 된 공통 응답 코드
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final ObjectMapper objectMapper;
private final UserDetailsServiceImpl userDetailsServiceImpl;
private final BlackAccessTokenRepository blackAccessTokenRepository;
// PathPatternParser 사용하여 인증 제외 URI 검별
private static final PathPatternParser patternParser = new PathPatternParser();
// 인증 제외 패턴 선언
private static final List<PathPattern> EXCLUDE_PATTERNS = List.of(
patternParser.parse(toStaticResources().atCommonLocations().toString()) // 정적 리소스
, patternParser.parse("/**") // 리소스 통과
, patternParser.parse("/pages/**") // 페이지 구성 통과
, patternParser.parse("/api/signup") // 회원 가입
, patternParser.parse("/api/auth/login") // 로그인
);
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
log.info("===JWT FILTER IN===");
try {
// Request Header에서 JWT 토큰 추출
String token = getJwtFromRequest(request);
if(token == null) {
log.error("액세스 토큰 없음");
BaseResponse<Void> baseResponse = BaseResponse.fail(HttpStatus.UNAUTHORIZED.name(), MSG_TOKEN_EMPTY, null);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(objectMapper.writeValueAsString(baseResponse));
return;
}
// AccessToken 블랙리스트 조회
if(blackAccessTokenRepository.existsByAccessToken(token)) {
log.error("블랙 리스트 등록 토큰 사용 감지");
BaseResponse<Void> baseResponse = BaseResponse.fail(HttpStatus.UNAUTHORIZED.name(), MSG_AUTH_WRONG, null);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(objectMapper.writeValueAsString(baseResponse));
return;
}
// 토큰 유효성 검증
if (jwtTokenProvider.validateToken(token)) {
// 토큰에서 사용자 정보 추출
String email = jwtTokenProvider.getEmail(token);
// 인증 객체 생성
UserDetails userDetails = userDetailsServiceImpl.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
// SecurityContext에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("JWT 인증 실패", e);
BaseResponse<Void> baseResponse = BaseResponse.fail(HttpStatus.UNAUTHORIZED.name(), MSG_AUTH_WRONG, null);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(objectMapper.writeValueAsString(baseResponse));
return;
}
filterChain.doFilter(request, response);
log.info("===JWT FILTER OUT===");
}
/**
* Request Header에서 JWT 토큰 추출
* Authorization: Bearer {token}
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
PathContainer path = PathContainer.parsePath(request.getRequestURI());
return EXCLUDE_PATTERNS.stream().anyMatch(pattern -> pattern.matches(path));
}
}
RefreshToken 의 로그아웃 시 Redis 사용이 성능이 더 좋다
추후에 이 부분은 다시 정리하려한다