JWT를 이용할 경우, 다음의 흐름을 통해 구성 한다.
- JWT 검증 필터 -> 로그인 + JWT 발급 (로그인 처리 필터) -> 인가 -> 예외처리
- JWT 발급 및 검증 빈을 이용해 검증 -> 검증 성공시 토큰정보에서 유저 정보 추출
- 추출된 유저정보를 토대로 UserDetailsService를 구현한 구현체와 Dto, Repository 등을 통해 실제 유저 정보 반환
- 실제 유저 정보를 AuthenticationManager 를 통해 저장
- 검증 실패시 이용자가 입력한 아이디 / 패스워드를 AuthenticationManager로 전달하여 검증 -> 성공시 토큰 발급
- JWT 발급 및 검증 빈을 이용해 검증 -> 검증 성공시 토큰정보에서 유저 정보 추출
- UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter 삽입
- 중복로그인 허용 등은 설정으로 할 수 없으므로, JWT 검증 필터에서 필요하다면 수동으로 구현해야 한다.
JWT를 사용하기 위해 다양한 라이브러리가 있는데, numbus-jose 라이브러리를 이용한 예이다. 또한, 단일키 관련 예 이다.
(jjwt 라이브러리가 사용은 가장 쉽다)
build.gradle 에 다음을 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.nimbusds:nimbus-jose-jwt:10.6'
application.yaml 에 항목 추가
...
jwt:
secret: ${JWT_SECRET_KEY}
expiration: 3600000
issuer: "my-server"
...
- secret 은 외부 관리해야 하며, 256bit 이상 되어야 한다.
- 만료시간은 ms 기준이다.
JWTConfiguration 구성
- 이 설정을 추가함으로서, 실제 토큰 생성시 secret 키 값 등 설정값을 활용할 수 있도록 한다.
- 이 부분을 따로 분리한 이유는, (실제 JWT 발급/인증 클래스에서 바로 값을 사용하지 않는 이유는) 기능분리를 통해 의존도를 낮추기 위함이다.
package config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Getter
@Setter
@Configuration
//yaml 에서 jwt 항목을 찾아와서 읽어서 불러옴
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private Long expiration;
private String issuer;
}
- ConfigurationProperties 는 application.yaml 에서 해당 prefix에 맞는 항목을 가져와 반영해 준다.
- 이 어노테이션을 이용하기 위해서는, ConfigurationPropertiesScan 어노테이션을 붙여야만 한다
- 혹은, Configuration 어노테이션 대신 Component 어노테이션을 이용한다.
JWT 토큰 생성 및 검증 코드 예
package utils;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import config.JwtProperties;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
@RequiredArgsConstructor
public class JwtTokenProvider
{
private final JwtProperties jwt;
private JWSSigner jwsSigner;
private JWSVerifier jwsVerifier;
private CustomUserDetailsService userDetailsService;
@PostConstruct
public void init()
{
try
{
byte[] keys = jwt.getSecret().getBytes(StandardCharsets.UTF_8);
jwsSigner = new MACSigner(keys);
jwsVerifier = new MACVerifier(keys);
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
public String generateToken(String username, String role)
{
try
{
Date now = new Date();
Date expirationTime = new Date(now.getTime() + jwt.getExpiration());
JWTClaimsSet claimsSet = new JWTClaimsSet
.Builder()
.issuer(jwt.getIssuer())
.subject(username)
.issueTime(now)
.expirationTime(expirationTime)
.claim("role",role) //private claim
.build();
JWSHeader header = new JWSHeader(JWSAlgorithm.HS256);
SignedJWT signedJWT = new SignedJWT(header, claimsSet);
signedJWT.sign(jwsSigner);
return signedJWT.serialize();
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
public JWTClaimsSet parseToken(String token)
{
try
{
SignedJWT signedJWT = SignedJWT.parse(token);
if(signedJWT.verify(jwsVerifier) == false)
throw new RuntimeException("Invalid token");
JWTClaimsSet set = signedJWT.getJWTClaimsSet();
if(set.getExpirationTime() != null && set.getExpirationTime().before(new Date()))
throw new RuntimeException("Token expired");
return set;
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
private Boolean validateToken(String token)
{
try
{
parseToken(token);
return true;
}
catch (Exception e)
{
return false;
}
}
public Authentication getAuthentication(String token) {
JWTClaimsSet claims = parseToken(token);
String username = claims.getSubject();
CustomUserDetails userDetails =
userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
}
}
- JWTClaimSet과 header 를 통해 실제 token 을 생성하게 된다.
- Claim 은 기본으로 권장되는 항목 외에 커스텀한 항목 (private) 을 추가 할 수 있다.
UserDetailsService 예
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
UserDto dto = userMapper.toDto(user);
return new CustomUserDetails(dto, user.getPassword());
}
}
- 리턴하는 UserDetails 는 CustomUserDetails 클래스를 생성, 프레임워크에 있는 UserDetails를 implements 하여 별도로 구현한다.
JWT 의 커스텀 필터 예
- OncePerRequestFilter 를 상속받아 요청당 한번만 실행되도록 한다.
- 요청으로 온 토큰을 검증하여, 토큰이 없다면 다음 필터를 실행한다.
- 토큰이 있다면, 해당 정보를 SecurityContextHolder 에 저장 하고 필터를 실행한다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication =
jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext()
.setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
로그인시 토큰 생성 부분
- 토큰 검증 실패시, 로그인을 하게되는데, 해당 부분 필터를 커스터마이즈 한다.
- userNamePasswordAuthenticationFilter 를 그대로 쓰지 않고, 수정하여 사용한다.
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final ObjectMapper objectMapper;
public JwtLoginFilter(
AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider,
ObjectMapper objectMapper
) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
this.objectMapper = objectMapper;
setFilterProcessesUrl("/api/auth/login");
}
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) {
try {
LoginRequest loginRequest =
objectMapper.readValue(request.getInputStream(), LoginRequest.class);
UsernamePasswordAuthenticationToken authRequest =
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
);
return authenticationManager.authenticate(authRequest);
} catch (IOException e) {
throw new AuthenticationServiceException("Invalid login request", e);
}
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult
) throws IOException {
String accessToken =
jwtTokenProvider.createAccessToken(authResult);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
objectMapper.writeValueAsString(
Map.of("accessToken", accessToken)
)
);
}
@Override
protected void unsuccessfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed
) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
objectMapper.writeValueAsString(
Map.of(
"error", "UNAUTHORIZED",
"message", failed.getMessage()
)
)
);
}
}
- 만일 AccessToken 외에 RefreshToken 또한 생성하려 한다면, Cookie 에 담아 넘겨주면 된다. 관련 예는 다음과 같다.
...
String refreshToken = jwtTokenProvider.generateToken(user.getUsername(), authorities.get(1));
Cookie cookie = new Cookie("REFRESH_TOKEN", refreshToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(60 * 60 * 24 * 30 );
response.addCookie(cookie);
...
SecurityFilterChain 부분
- 최종적으로 필터 등록을 하는 예는 다음과 같다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationConfiguration authConfig;
/**
* mvcMatcher 사용 시 반드시 필요
*/
@Bean
HandlerMappingIntrospector handlerMappingIntrospector() {
return new HandlerMappingIntrospector();
}
@Bean
SecurityFilterChain filterChain(
HttpSecurity http,
HandlerMappingIntrospector introspector
) throws Exception {
AuthenticationManager authenticationManager =
authConfig.getAuthenticationManager();
JwtLoginFilter jwtLoginFilter =
new JwtLoginFilter(authenticationManager, jwtTokenProvider);
// mvcMatcher 빌더
MvcRequestMatcher.Builder mvc =
new MvcRequestMatcher.Builder(introspector);
http
// 기본 인증 방식 제거
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
// 세션, CSRF
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 권한 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers(
PathRequest.toStaticResources().atCommonLocations()
).permitAll()
.requestMatchers("/login").permitAll()
// 🔐 SpEL ① 본인 리소스 접근만 허용
.requestMatchers("/users/{id}")
.access(new WebExpressionAuthorizationManager(
"#id == authentication.principal.id"
))
// 🔐 SpEL ② ADMIN + 계정 활성화 사용자만 허용
.requestMatchers("/admin/reports/**")
.access(new WebExpressionAuthorizationManager(
"hasRole('ADMIN') and authentication.principal.enabled"
))
// ===============================
// ✅ RegexRequestMatcher 예제
// ===============================
// /api/v1, /api/v2, /api/v3 ... 버전 API
.requestMatchers(
new RegexRequestMatcher(
"^/api/v[0-9]+/.*",
null
)
).hasRole("USER")
// ===============================
// ✅ MvcRequestMatcher 예제
// ===============================
// Spring MVC 매핑 규칙 그대로 사용
.requestMatchers(
mvc.pattern(HttpMethod.GET, "/orders/{orderId}")
).hasRole("USER")
.requestMatchers(
mvc.pattern("/admin/**")
).hasRole("ADMIN")
.anyRequest().authenticated()
)
// 필터 등록 (순서 중요)
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
)
.addFilterAt(
jwtLoginFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- SpEL (Spring Expression Language)를 이용할 경우, 복합 권한을 체크할 수 있다.
- 이 경우에는 .access() 를 이용한다.
- mvc.pattern 을 통해 실제 컨트롤러에 매핑된 url을 이용할 수 있다 (인자값 까지 포함된 경우)
- regex 의 경우, url 경로에 v숫자 가 들어가 있는 경우에 활용 할 수 있다.
'Backend > SpringBoot' 카테고리의 다른 글
| [Spring Security] CSRF 토큰 사용 (+CORS) (0) | 2025.12.16 |
|---|---|
| [Spring Security] MethodSecurity 및 커스텀 핸들러 (0) | 2025.12.16 |
| [Spring Security] 쿠키 및 세션 (0) | 2025.12.12 |
| @SQLRestriction 및 논리 삭제 (0) | 2025.12.11 |
| @Retryable, 낙관적 락 간편 적용하기 (0) | 2025.12.09 |