Spring Security와 JWT 토큰
Spring Security
Spring Security는 인증, 권한 관리, 데이터 보호 등의 사용자 관리 기능을 구현하는데 도움을 주는 프레임워크이다.
자세한 내용은 아래 링크를 통해 확인할 수 있다.
실습을 통해서 알아보는 Spring Security 기초
Spring Security란Spring Security는 인증, 인가뿐만 아니라 일반적인 공격으로부터 서비스를 보호하기 위해 사용하는 프레임워크이다. Spring Security는 자바 17버전 이상의 런타임 환경을 요구한다. Spri
kym8821.tistory.com
JWT 토큰
JWT 토큰은 Json Web Token의 약자로, 인증에 필요한 정보(claims)를 담은 후 비밀키로 서명한 토큰이다.
인터넷 표준 인증 방식으로 인증과 인가(권한설정)에 사용한다.
Spring Filter Chain 동작방식과 JWT 토큰 인증 과정
Spring Filter Chain 동작방식
Spring Filter Chain의 인증 방식을 아주 간단하게 정리했다.
- AuthenticationFilter에서 인증을 위한 정보 생성(Authentication)
- AuthenticationFilter에서 AuthenticationManager로 인증정보 전달
- AuthenticationManager에서 인증을 진행
- AuthenticationFilter는 인증이 성공했으면 SecurityContext에 인증정보를 저장한다.
- SecurityContext에 저장된 인증 정보를 기반으로 인증 여부와 권한을 확인한다.
즉, 정리하자면 AuthenticationFilter는 주로 로그인을 처리하는 필터로, 사용자 인증에 필요한 정보를 생성한다.
또한, 최종적으로 SecurityContextHolder의 인증 정보를 기반으로 인증 여부와 권한을 확인한다.
JWT 토큰을 활용한 인증 과정 (간략화)
우리는 JWT 토큰의 유효성 여부를 검사하는 필터를 AuthenticationFilter 앞에 설정한다.
JWT 토큰을 생성하는 이유는 이미 로그인된 사용자의 JWT 토큰을 통한 인증 및 인가를 위함이다.
즉, JWT 필터가 AuthenticationFilter(로그인 필터)전에 토큰 유효성을 검사하고, 만약 토큰이 유효하다면 AuthenticationFilter는 건너 뛰는 개념이라고 보면 된다.
이제 간단한 JWT 토큰 인증 과정을 정리해보자
- 로그인 시 사용자에게 JWT 토큰 발급
- 사용자 요청 시, JWT 필터가 헤더에 있는 토큰의 유효성을 검사
- 만약 토큰이 유효하다면 SecurityContext에 인증정보를 저장
- AuthenticationFilter는 인증정보가 SecurityContext에 존재한다면 다음 단계로 이동
- 최종적으로 SecurityContext의 인증정보를 기반으로 인증 여부와 권한을 확인
이처럼 JWT 인증 필터를 AuthenticationFilter 앞에 설정하여 불필요한 단계를 건너뛸 수 있도록 했다.
Spring Filter Chain에서의 예외 처리와 permitAll()
Spring Filter Chain에서의 예외 처리
Spring Filter Chain에서 발생한 예외들은 아래 조건에 따라 처리된다.
- AuthenticationException, AccessDeniedException : ExceptionTranslationFilter에서 처리
- 그 외 예외 : @ControllerAdvice와 같은 Global Exception 처리 과정을 거침
Spring Filter Chain의 예외와 permitAll()
본격적인 JWT 토큰 인증 방식을 구현해보기 전에 Spring Filter Chain의 permitAll에 대해서 알아보도록 하자.
permitAll 메서드는 인증이 필요하지 않은 경로의 모든 요청을 허용하기 위해 사용하는 메서드이다.
그렇다면, permitAll 메서드로 지정한 경로들은 Spring Filter Chain을 거칠까?
결론부터 말하자면, permitAll로 지정한 경로들도 Spring Filter Chain을 거친다.
즉, permitAll을 지정한 경로에서도 동일하게 예외가 발생할 수 있다는 것이다.
이는 모든 요청을 허용한 경로가 필터 체인을 통과하면서 예외로 인해 정상적으로 동작하지 못하는 문제를 야기할 수 있다.
이를 해결하기 위한 여러 방법이 있지만, 일단은 이 두 가지 방법에 대해서 알아보자.
- 필터 내부에서 특정 경로에 대한 요청을 받으면, 예외가 발생하지 않도록 바로 다음 필터로 넘어감
- 예외 발생 시, 예외를 던지는 대신 조건문 등을 활용하여 예외를 회피함.
이번 구현에서는 예외를 던지는 대신 조건문으로 예외를 회피하는 방법을 채택할 것이다.
JWTAuthFilter (JWT 필터) 동작 방식
이번에는 JWT 인증 과정이 아닌 이번에 구현할 JWT 필터의 동작 방식에 대해서 알아보자.
1. 사용자 요청(HttpServletRequest)에서 JWT 토큰을 추출
2. JWT 토큰이 유효하다면 JWT 토큰에서 사용자 식별자 추출
3. 추출한 사용자 식별자를 활용하여 저장소(DB 등)에 저장된 사용자 정보 조회
4. 조회한 사용자 정보가 유효하다면 인증정보(AuthenticationToken)을 생성한다.
5. 생성된 AuthenticationToken을 SecurityContext에 저장한다.
6. 현재 필터 체인의 다음 단계로 이동
의존성 준비하기
시작하기 전에 실습에 필요한 의존성을 준비했다.
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
//Jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
Spring Security와 JWT 토큰 관련 의존성들이다.
JwtUtils (JWT 토큰 유효성 검사, 생성 및 조회) 구현
JWTAuthFilter에서는 JWT이 유효하다면 SecurityContext에 인증 정보를 저장하는 책임만 갖도록 한다.
그 외, 헤더에서 토큰 추출, 토큰 생성 및 유효성 검사 등의 책임은 JWTUtils라는 클래스에서 관리한다.
코드는 아래와 같다.
@Component
@Slf4j
public class JwtUtils {
private final Key SECRET_KEY;
@Value("${env.jwt.access-token-exp}")
private Long ACCESS_TOKEN_EXP_TIME;
@Value("${env.jwt.refresh-token-exp}")
private Long REFRESH_TOKEN_EXP_TIME;
public static final String ACCESS_TOKEN_HEADER_KEY = "authorization";
public static final String ACCESS_TOKEN_PREFIX = "Bearer ";
public JwtUtils(@Value("${env.jwt.secret-key}") String secret_key){
byte[] keyBytes = Decoders.BASE64.decode(secret_key);
this.SECRET_KEY = Keys.hmacShaKeyFor(keyBytes);
}
/**
* generate jwt token
* @param dto UserAuthInfoDto(email, password, role)
* @param zoneId timeZone ZoneId
* @return JwtToken - String, not-null
*/
public String generateJwtToken(UserAuthInfoDto dto, ZoneId zoneId, Long exp_time){
Claims claims = Jwts.claims();
claims.put("email", dto.getEmail());
claims.put("role", dto.getRole().name());
ZonedDateTime issuedDate = ZonedDateTime.now(zoneId);
ZonedDateTime expireDate = issuedDate.plusSeconds(exp_time);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(issuedDate.toInstant()))
.setExpiration(Date.from(expireDate.toInstant()))
.signWith(this.SECRET_KEY)
.compact();
}
/**
* @param dto UserAuthInfoDto(email, password, role)
* @return accessToken - String, not-null
*/
public String generateAccessToken(UserAuthInfoDto dto){
ZoneId zoneId = ZoneId.systemDefault();
return this.generateJwtToken(dto, zoneId, this.ACCESS_TOKEN_EXP_TIME);
}
/**
* @param dto UserAuthInfoDto(email, password, role)
* @return refreshToken - String, not-null
* */
public String generateRefreshToken(UserAuthInfoDto dto){
ZoneId zoneId = ZoneId.systemDefault();
return this.generateJwtToken(dto, zoneId, this.REFRESH_TOKEN_EXP_TIME);
}
/**
* @param token jwtToken String
* @return isValidate - boolean
* */
public boolean validateToken(String token){
try {
Jwts
.parserBuilder()
.setSigningKey(this.SECRET_KEY)
.build()
.parseClaimsJws(token);
return true;
}catch (Exception e){
System.out.println(e.getMessage());
}
return false;
}
/**
* @param token jwtToken String
* @return JwtToken's Claims - Claims, nullable
* */
private Claims getClaims(String token){
try {
return Jwts
.parserBuilder()
.setSigningKey(this.SECRET_KEY)
.build()
.parseClaimsJws(token)
.getBody();
}catch (Exception e){
return null;
}
}
/**
* @param token jwtToken String
* @return userEmail - String, nullable
* */
public String getUserEmail(String token){
Claims claims = getClaims(token);
if(claims == null){
log.info("JwtUtils : Claims object is null on token");
return null;
}
return claims.get("email", String.class);
}
/**
* get access token from HttpServletRequest object
* @param request HttpServletRequest
* @return accessToken - String, nullable */
public String getTokenFromRequest(HttpServletRequest request){
String authorization = request.getHeader(ACCESS_TOKEN_HEADER_KEY);
if(authorization==null || !authorization.startsWith(ACCESS_TOKEN_PREFIX)){
log.info(String.format("JwtUtils : authentication is null or invalid prefix : %s", authorization));
logRequestHeader(request);
return null;
}
return authorization.substring(ACCESS_TOKEN_PREFIX.length());
}
public void logRequestHeader(HttpServletRequest request){
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
String headerValue = request.getHeader(headerName);
log.info("header => " + headerName + ": " + headerValue);
}
}
}
}
각 메서드의 역할에 대해서 알고 넘어가도록 하자.
- generateJwtToken : JWT 토큰을 생성하는 역할을 한다.
- UserAuthInfo는 사용자 인증 정보를 담기 위해 직접 생성한 커스텀 클래스이다.
- generateAccessToken : generateJwtToken을 활용하여 access token을 생성한다.
- generateRefreshToken : generateJwtToken을 활용하여 refresh token을 생성한다.
- validateToken : 토큰의 유효성 여부를 검사한다.
- getClaims : 토큰에서 claim( JWT 토큰의 payload에 포함된 json 데이터 )을 추출한다.
- getUserEmail : 토큰의 claim에서 사용자 이메일을 추출한다.
- getTokenFromRequest : 요청에서 JWT 토큰을 추출한다.
아래와 같은 값들은 환경변수로 지정하여 사용하거나 상수로 지정했다.
- 암호화 키 (SECRET_KEY)
- ACESS TOKEN 만료시간 (ACCESS_TOKEN_EXP_TIME)
- REFRESH TOKEN 만료시간 (REFRESH_TOKEN_EXP_TIME)
- 헤더에 있는 토큰의 키 (ACCESS_TOKEN_HEADER_KEY)
- 헤더에 있는 토큰의 접두사 (ACCESS_TOKEN_PREFIX)
JwtAuthFilter (JWT 인증 필터) 구현하기
이번에는 JwtAuthFilter를 구현한 코드이다.
해당 필터는 JwtUtils로 토큰을 추출 및 인증하고, 유효하다면 SecurityContext에 인증정보를 저장한다.
@Slf4j
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final CustomUserDetailService customUserDetailService;
public JwtAuthFilter(JwtUtils jwtUtils, CustomUserDetailService customUserDetailService){
this.jwtUtils = jwtUtils;
this.customUserDetailService = customUserDetailService;
}
/*
* 여기서 예외를 던져버리면 permitAll()로 허용해준 경로에서도 예외가 발생할 수 있음
* => 따라서, Authentication 을 설정하지 않고 넘어가고(미인증 상태로 pass),
* 인증여부에 따라서 추후 인증 실패 예외를 처리해줌
* */
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String authentication = jwtUtils.getTokenFromRequest(request);
if(authentication!=null && jwtUtils.validateToken(authentication)){
// get email and principal( = UserDetails )
String email = jwtUtils.getUserEmail(authentication);
UserDetails userDetails = email!=null ?
customUserDetailService.loadUserByUsername(email) : null;
if(userDetails==null){
log.info(String.format("JwtAuthFilter : UserDetails is null (email:%s)", email));
filterChain.doFilter(request, response);
return;
}
// generate Authentication (UsernamePasswordAuthenticationToken)
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// set Authentication on SecurityContextHolder
SecurityContextHolder
.getContext()
.setAuthentication(usernamePasswordAuthenticationToken);
}
filterChain.doFilter(request, response);
}
}
JwtAuthFilter는 OncePerRequestFilter를 상속하기에 한 번의 요청에서 한 번만 호출된다.
이제 JwtAuthFilter가 어떻게 동작하는지 확인해보자.
- 요청에서 토큰을 추출
- 만약 토큰이 유효하다면 토큰에서 식별자(email)을 추출하고, 해당 식별자로 사용자 정보(UserDetails) 추출
- 만약 사용자 정보가 유효하다면 인증정보(UsernamePasswordAuthenticationToken)을 SecurityContext에 저장
여기서 UserDetails 클래스는 Spring Security에서 사용자의 정보를 담기 위해 사용하는 클래스이다.
해당 클래스의 객체는 토큰 생성(principals) 및 사용자 인증 등에서 사용될 수 있다.
CustomUserDetails와 CustomUserDetailService 구현하기
CustomUserDetails 구현하기
우선 Spring Security에서 제공하는 UserDetails 객체에 대해서 알아보자.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
default boolean isAccountNonExpired() {
return true;
}
default boolean isAccountNonLocked() {
return true;
}
default boolean isCredentialsNonExpired() {
return true;
}
default boolean isEnabled() {
return true;
}
}
이처럼 UserDetails에는 Username와 Password만을 담을 수 있다.
따라서 아래와 같이 CustomUserDetails를 구현하여 추가적인 정보를 담을 수 있도록 했다.
public class CustomUserDetails implements UserDetails {
private final UserAuthInfoDto userAuthInfoDto;
public CustomUserDetails(UserAuthInfoDto userAuthInfoDto){
this.userAuthInfoDto = userAuthInfoDto;
}
@Builder
public CustomUserDetails(String email, String password, UserRole role){
this.userAuthInfoDto = UserAuthInfoDto.builder()
.email(email)
.password(password)
.role(role)
.build();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<String> roles = new ArrayList<>();
roles.add(userAuthInfoDto.getRole().name());
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return null;
}
}
CustomUserDetails를 통해 UserDetails 인터페이스에 있는 메서드와 멤버들을 모두 구현해주었다.
이제 UserDetails는 Username, Password뿐만 아니라 role을 담을 수 있고, 필요에 따라서 보다 다양한 정보를 담을 수 있는 객체가 되었다.
CustomUserDetailService 구현하기
다음으로 CustomUserDetails 객체를 조회하기 위한 CustomUserDetailService를 구현한다.
@Service
@Slf4j
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailService(UserRepository userRepository){
this.userRepository = userRepository;
}
/**
* load UserDetails by email
* @param username email, String
* @return UserDetails - UserDetails, nullable
* */
@Override
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByEmail(username).orElse(null);
return (user==null) ? null : CustomUserDetails
.builder()
.email(user.getEmail())
.password(user.getPassword())
.role(user.getRole())
.build();
}
}
마찬가지로 UserDetailsService 인터페이스의 loadUserByUsername을 구현했다.
이젠 이메일을 통해 사용자를 조회하고, 이를 기반으로 CustomUserDetails 객체를 생성해준다.
SecurityFilterChain을 빈으로 등록하기
JWT 인증을 위한 마지막 단계이다.
이번에는 SecurityFilterChain을 빈으로 등록하기 위한 설정을 진행한다.
코드는 아래와 같다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final String[] WHITE_LIST = {
"/auth/**",
"/h2-console/**",
"/favicon.ico",
"/error",
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**"
};
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfig(JwtAuthFilter jwtAuthFilter){
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults()) // use corsConfigurationSource
.sessionManagement(session -> session.disable())
.addFilterBefore(
jwtAuthFilter,
UsernamePasswordAuthenticationFilter.class
)
.authorizeHttpRequests((auth) -> auth
.requestMatchers(WHITE_LIST).permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("*"));
config.setAllowedOrigins(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
filterChain 빈에 대해서 알아보자
- filterChain : SecurityFilterChain을 빈으로 등록
- cors : Customizer.withDefault() 을 통해 corsConfigurationSource 빈을 사용하도록 했다.
- sessionManageMent : session을 사용하지 않기에 disable했다.
- addFilterBefore : JwtAuthFilter이 UsernamePasswordAuthenticationFilter 전에 실행되도록 했다.
- authorizeHttpRequests : 두 개의 분기로 나뉜다.
- WHITE_LIST에 있는 경우 : swagger나 로그인 관련 경로에 대해서는 permitAll을 했다.
- 그 외 : 인증이 된 경우에만 허용하도록 했다.
Swagger를 통해서 정상 작동 여부 확인하기
마지막으로 테스트를 위해 swagger 환경 설정을 진행한다.
아래는 Swagger에 필요한 의존성이다.
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
아래와 같이 swagger 환결설정을 진행했다.
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
String name = "Bearer Token";
SecurityRequirement securityRequirement = new SecurityRequirement().addList(name);
Components components = new Components().addSecuritySchemes(name, new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER)
.name(JwtUtils.ACCESS_TOKEN_HEADER_KEY)
.scheme("bearer")
.bearerFormat("JWT")
);
return new OpenAPI()
.components(new Components())
.info(apiInfo())
.addSecurityItem(securityRequirement)
.components(components);
}
private Info apiInfo() {
return new Info()
.title("알려드럼 API 테스트")
.description("간단한 설명")
.version("1.0.0");
}
}
이를 통해서 swagger에서 테스트 시 토큰을 헤더에 넣을 수 있도록 했다.
테스트 결과
마지막으로 토큰 유무에 따라서 인증이 필요한 경로에서 어떤 응답을 하는지 확인해보자.
JWT 토큰을 등록하지 않은경우 :
아래와 같이 403 코드를 반환했다.
{
"timestamp": "2025-01-26T16:40:32.244+00:00",
"status": 403,
"error": "Forbidden",
"path": "/user/test"
}
JWT 토큰을 등록한 경우 :
아래와 같이 정상 작동하는 것을 확인했다.
{
"message": null,
"body": "success"
}
마무리
이번에는 spring security에서 어떤 방식으로 인증이 진행되는지 알아보았다.
또한, Spring Security을 활용한 JWT 인증 방식에 대해서 알아보았다.
'Spring > 기초 개념' 카테고리의 다른 글
| Access Token와 Refresh Token을 활용한 인증과 인가 (0) | 2025.02.07 |
|---|---|
| Spring Boot에서 테스트코드 작성하기 (0) | 2025.02.01 |
| 실습을 통해서 알아보는 Spring Security 기초 (0) | 2025.01.25 |
| 예시를 통해 알아보는 Spring Boot의 의존성 주입(DI) (1) | 2025.01.19 |
| Spring의 제어역전(IoC) (0) | 2024.12.26 |