jwt 토큰 필터는 Http요청이 들어오면 토큰이 유효한지 확인한다.
유효 하지 않다면 401 error를 띄워준다.
@Data
@AllArgsConstructor
public class TokenBody {
private Long memberId;
private String role;
}
jwt 토큰에서 memberId와 권한을 가져오기 위한 dto
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
// jwt 토큰을 발급, 검증, 파싱 하는 클래스
public class JwtTokenProvider {
// access Token과 Refresh 토큰의 재발급 정보를 담당
private final JwtConfiguration configuration;
// Refresh Token의 발급, 조회, 블랙리스트 등록을 담당
private final TokenRepository refreshTokenRepositoryAdapter;
// 시크릿 키 생성
private SecretKey getSecretKey() {
// JJWT 라이브러리의 유틸 클래스인 io.jsonwebtoken.security.Keys에서 제공하는 메서드로,HMAC 방식 서명을 위한 시크릿 키를 생성
return Keys.hmacShaKeyFor(configuration.getSecret().getAppKey().getBytes());
}
//jwtToken 생성
private String issue(Long memberId, String role, Long validTime) {
// Payload = subject, claim("role"), issuedAt(iat), expiration(exp)
// Signature = signWith를 통해 생성
// 아래의 코드에는 header에 해당되는 코드가 없는데 자동적으로 생성된다. (alg: HS256, typ: JWT)
String jwtToken = Jwts.builder()
.setSubject(memberId.toString()) // subject: 사용자 ID
.claim("role", role) // 사용자 역할(권한)을 추가
.issuedAt(new Date()) // 발급 시간 (iat) 현재 시간
.expiration(new Date(new Date().getTime() + validTime)) // 만료 시간 (ext) 현재 시간 + yml 설정 시간
.signWith(getSecretKey(), Jwts.SIG.HS256) // 시크릿 키로 서명하여 Signature 생성
.compact(); // Header + Payload + Signature 결합 → 최종 JWT 문자열 반환
return jwtToken;
}
// Accesss 토큰 생성
public String issueAccessToken(Long memberId, String role) {
return issue(memberId, role, configuration.getValidation().getAccess());
}
// Refresh 토큰 생성
public String issueRefreshToken(Long memberId, String role) {
return issue(memberId, role, configuration.getValidation().getRefresh());
}
// 토큰 두개를 묶는다.
public KeyPair generateKeyPair(Member member) {
String accessToken = issueAccessToken(member.getId(),member.getRole().name());
String refreshToken = issueRefreshToken(member.getId(),member.getRole().name());
refreshTokenRepositoryAdapter.save(member, refreshToken);
KeyPair jwtTokens = KeyPair.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.memberId(member.getId().toString())
.build();
return jwtTokens;
}
//특정 사용자의 유효한 RefreshToken이 DB에 있는지 확인
public RefreshToken validateRefreshToken(Long memberId) {
Optional<RefreshToken> validRefTokenOptional = refreshTokenRepositoryAdapter.findValidRefTokenByMemberId(memberId);
return validRefTokenOptional.orElse(null);
}
////추가 되는 부분 ↓////
////추가 되는 부분 ↓////
// 클라이언트가 보낸 JWT의 유효성을 서명(Signature) 기반으로 검증한다.
// - parser(): JWT 문자열을 파싱할 준비를 한다.
// - verifyWith(): 서명 검증에 사용할 SecretKey를 설정한다. (구버전은 setSigningKey() 사용 → 허용 타입 제한적)
// - parseSignedClaims(): 토큰을 header.payload.signature로 분리하고, 서명이 유효한지 확인한다.
public boolean validate(String token) {
try {
Jwts.parser()
.verifyWith(getSecretKey())
.build()
.parseSignedClaims(token);
return true;
// JWT 관련 최상위 예외 만료, 위조, 포맷 문제 등 대부분의 JWT 검증 실패 시 발생
} catch (JwtException e) {
log.info("JWT 토큰에 문제가 있습니다. = {}", e.getMessage());
log.info("TOKEN : {}", token);
// Java 기본 예외 null이거나 빈 토큰이 들어온 경우
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 Null입니다. = {}", e.getMessage());
// 모든 나머지 예외 예상 못 한 오류 (시스템 오류 등)
} catch (Exception e) {
log.info("JWT 토큰 검증 중 예상치 못한 예외가 발생 했습니다. = {}", e.getMessage());
}
return false;
}
//토큰 내부 정보를 파싱해서 사용자 ID (sub)와 역할 (role)을 꺼냄
public TokenBody parseJwt(String token) {
Jws<Claims> parsed = Jwts.parser()
.verifyWith(getSecretKey())
.build()
.parseSignedClaims(token);
return new TokenBody(
// 토큰을 만들때 payload의 Subject 에 setter 로 memeberId를 넣었음, memberId 반환 받기
Long.parseLong(parsed.getPayload().getSubject()),
// role이라는 커스텀 Claim을 가져온다.
parsed.getPayload().get("role").toString()
);
}
}
기존 코드에서 추가 된 부분이 validate랑 parseJwt이가 있다.
@Slf4j
@Component
@RequiredArgsConstructor
//OncePerRequestFilter는 매 http요청 마다 한번만 실행되는 필터
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
// jwt 인증의 핵심 메소드 이며 토큰 추출, 유효성 검사, 사용자 정보 추출, DB에서 사용자 조회,SecurityContext에 등록할 인증 객체 생성 및 인증 객체 설정을 해야한다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("jwt 필터 도착");
// 토큰 추출
String realToken = resolveToken(request);
if (realToken == null || !jwtTokenProvider.validate(realToken)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Access token invalid or expired");
return;
}
// 유효성 검사
if (realToken != null && jwtTokenProvider.validate(realToken)) {
// 사용자 정보 추출
TokenBody tokenBody = jwtTokenProvider.parseJwt(realToken);
// DB에서 사용자 조회
Member member = memberRepository.findById(tokenBody.getMemberId())
.orElseThrow(() -> new MemberNotFound(ExceptionMessage.MEMBER_NOT_FOUND));
//SecurityContext에 등록할 인증 객체 생성
//Spring Security의 인증 처리 규칙에 따라, SecurityContext에는 반드시 UserDetails 또는 OAuth2User를 구현한 인증된 사용자 객체가 들어가야한다.
//attributes란? OAuth2 로그인 시, 제공자로부터 받은 사용자 정보 (JSON)
//JWT 기반 인증에서는 클라이언트(브라우저 등)에서 이미 인증이 끝난 후, JWT만 주고받기 때문에 attributes는 필요 없다.
CustomUserPrincipal customUserPrincipal = CustomUserPrincipal.from(member,null);
log.info("customUserPrincipal.getId() = {}", customUserPrincipal.getId());
// Spring Security는 JWT 내부 정보를 자동으로 인식하지 못하기 때문에 파싱한 사용자 정보 및 권한을 직접 Authentication 객체에 담아서 알려줘야 한다.
// SecurityContext에 인증 객체 설정 (유저 정보, jwt토큰, 권한)
Authentication authentication = new UsernamePasswordAuthenticationToken(customUserPrincipal, realToken, customUserPrincipal.getAuthorities());
log.info("authentication.getPrincipal() = {}", ((CustomUserPrincipal) authentication.getPrincipal()).getEmail());
log.info("authentication.getPrincipal() = {}", ((CustomUserPrincipal) authentication.getPrincipal()).getId());
// SecurityContextHolder에 인증 정보를 넣어 줌으로써 현재 요청을 보낸 사용자가 인증된 사용자임을 Spring Security에게 알려준다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
log.info("jwt 필터 성공");
// 필터 체인 계속 진행
filterChain.doFilter(request, response);
}
// http 요청에서 토큰만 추출 한다.
private String resolveToken(HttpServletRequest request) {
// 요청 헤더 중 Authorization 값을 가져온다
// 요청 헤드의 생김새 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0...
String bearerToken = request.getHeader("Authorization");
// Bearer로 시작하는지 확인
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
log.info("resolvToken");
log.info("bearerToken.substring(7) = {}", bearerToken.substring(7));
// Bearer 이후 실제 토큰 문자열만 잘라서 반환
return bearerToken.substring(7);
}
return null;
}
}
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final JwtTokenFilter jwtTokenFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
//cors 기본 설정 활성화
.cors(Customizer.withDefaults())
//csrf 비활성화
.csrf(csrf -> csrf.disable())
// form 로그인 비활성화
.formLogin(formLogin -> formLogin.disable())
//인증 권한 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
.requestMatchers("/api/auth/logout", "/api/auth/reissue").permitAll()
.requestMatchers("/api/member").authenticated()
.anyRequest().authenticated()
)
// Oauthlogin 설정
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
//로그인이 성공 했을 때 작동하는 핸들러
.successHandler(oAuth2SuccessHandler)
)
// jwt 필터 설정
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
이렇게 Security Config 까지 설정을 해주면 jwtTokenFilter에 대한 설정이 끝난다
현재는 Access 코드를 재발급 하고 검사하는 코드가 작성되지 않았으며, ogout을 했을 때 Refresh 토큰을 블랙 리스트로 등록 하는 코드가 없다.
다음편에는 재발급 코드와 Logout 코드를 만들어보자.