스프링

Spring Security에 JWT 적용하기

영범 2024. 11. 2. 00:22

https://kimpossible94.tistory.com/38

 

Spring Security

개인 프로젝트에 Spring Security를 적용하기 위해 스프링 문서를 통해서 공부한 내용 정리를 위한 글이 글은 Spring Security 6.3.3 버전을 기준으로 작성되었습니다.   Spring Security는 인증, 인가 및 

kimpossible94.tistory.com

스프링 시큐리티의 동작방식에 대해서 간략하게 정리한 이 글을 먼저 읽고 오면 좋습니다.

추가로 이 글에서는 JWT를 활용해서 로그인을 구현하는 방식에 대해 말하고 있으므로, JWT에 대한 내용은 추후에 여기에 정리해놓겠습니다.

보안 규칙 생성을 위한 SecurityConfig 파일 작성

@EnableWebSecurity 어노테이션을 붙이면 Spring Security가 활성화되며, 이 클래스 내에서 애플리케이션의 보안 설정을 구성하고 리소스에 대한 접근을 제어하는 보안 규칙을 정의할 수 있습니다.

 

정의방식의 변경

Spring Security Version < 5.7

스프링 시큐리티 5.7 버전 이전까지는 WebSecurityConfigurerAdapter 추상 클래스를 상속받아서 다양한 보안 관련 메서드를 오버라이드하여 사용자 정의 보안 설정을 구현하는 것이 일반적인 방식이었습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 5.7 이전 버전의 보안 설정 방식
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/", "/login").permitAll()
                .antMatchers("/admin").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
            .formLogin();
    }
}

 

Spring Security Version >= 5.7

5.7 버전부터는 WebSecurityConfigurerAdapter 클래스는 deprecate 되고 SecurityFilterChain를 정의하는 방식을 사용합니다.

이로 인해 불필요한 상속 구조를 줄이고, 명시적으로 SecurityFilterChain을 정의하여 보안 설정을 더 간단하고 명확하게 할 수 있습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // 5.7 이후 버전의 보안 설정 방식
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login").permitAll()   // 허용 경로 설정
                .requestMatchers("/admin").hasRole("ADMIN")   // ADMIN 역할만 접근 허용
                .anyRequest().authenticated()                // 나머지 요청은 인증 필요
            )
            .formLogin();  // 폼 기반 로그인 설정

        return http.build();
    }
}
  1. authorizeRequests(), authorizeHttpRequests(): HTTP 요청에 대한 권한 설정을 처리합니다.
    5.7 버전부터 authorizeRequests()는 deprecate 되었습니다.
    이로 인해서 코드의 가독성이 높아졌습니다.
  2. antMatchers(), requestMatchers(): 특정 경로에 대한 보안 규칙을 정의합니다.
    5.7 버전부터 antMatchers()는  deprecate 되었습니다.
  3. permitAll(): 설정한 리소스의 접근을 모두 허용
  4. hasRole(): 특정 역할을 가진 사용자만 접근을 허용
  5. hasAnyRole(): 여러 개의 역할 접근을 허용
  6. denyAll(): 모두 허용 안 함
  7. authenticated(): 인증된 사용자만 허용

 

결과적으로 회원가입을 위한 SpringSecurity 설정은 아래와 같이 했습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) 
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .requestMatchers(HttpMethod.POST, "/users").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

 


Entity, DTO

더보기

User Entity

@Entity
@Table(name = "users")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class UserEntity {
    @Id
    @GeneratedValue(strategy =  GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    public UserEntity(String username, String email, String password, Role role) {
        this.username = username;
        this.email = email;
        this.password = password;
        this.role = role;
    }
}

 

UserAuthDTO

@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public class UserAuthDTO {
    private Long id;
    @Size(max = 20)
    @NotEmpty(message = "사용자명은 필수항목입니다.")
    private String username;

    @NotEmpty(message = "이메일은 필수항목입니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String email;

    @NotEmpty(message = "비밀번호는 필수항목입니다.")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$",
            message = "비밀번호는 최소 8자 이상, 영어와 숫자를 포함해야 합니다.")
    private String password;
}

 

UserDTO

@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public class UserDTO {
    private Long id;
    private String username;
    private String email;
}

 

UserAuthDTO는 유저 회원가입, 로그인등 패스워드가 필요한 작업을 할 때를 위함입니다.

그 외에는 비밀번호가 필요 없으니 보안을 위해서 데이터 전송에서 제외하기 위해 UserDTO와 UserAuthDTO를 구분하였습니다. 


회원가입 구현

1. 회원가입 로직 구현

UserController

@RestController
@RequestMapping("/users")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<UserDTO> registerUser(@Valid @RequestBody UserAuthDTO userAuthDTO) {
        UserDTO createdUser = userService.registerUser(userAuthDTO);
        return ResponseEntity.ok(createdUser);
    }
}

 

UserService

@Service
public class UserServiceImpl implements UserService {
    private final UserRepository userRepository;

    public UserServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDTO registerUser(UserAuthDTO userAuthDTO) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        String encodedPassword = passwordEncoder.encode(userAuthDTO.getPassword());
        Role userRole = Role.USER;

        UserEntity user = new UserEntity(userAuthDTO.getUsername(), userAuthDTO.getEmail(), encodedPassword, userRole);
        UserEntity savedUser = userRepository.save(user);

        return new UserDTO(savedUser.getId(), savedUser.getUsername(), savedUser.getEmail());
    }
}

 

BCryptPasswordEncoder는 Spring Security에서 제공하는 클래스로 비스크립트(BCrypt) 해시 알고리즘을 사용하여 비밀번호를

암호화해 주는 클래스입니다.

또한 비교할 때 해시화된 비밀번호와 사용자가 입력한 비밀번호가 일치하는지 확인하는 기능도 제공합니다.

 

2. 코드 개선

PasswordEncoder Bean 생성

BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

 

BCryptPasswordEncoder를 위 서비스 코드처럼 new로 메서드 내에서 생성하는 방식보다

PasswordEncoder를 빈으로 등록하는 방식이 더 좋은 설계방식입니다.

 

직접 BCryptPasswordEncoder를 생성해서 사용하는 경우, 암호화 방식을 변경해야 할 때 모든 코드에서 일일이 찾아서 수정해야 합니다.

예를 들어, BCryptPasswordEncoder에서 다른 암호화 방식으로 변경하려면 각 서비스나 클래스에서 new BCryptPasswordEncoder()가 호출된 모든 부분을 수정해야 해서 많은 시간이 소요됩니다.

 

반면, 빈(bean)으로 등록하여 의존성 주입을 통해 사용하면, 암호화 방식을 변경할 때 빈을 설정하는 부분만 수정하면 되므로

유지보수와 확장성 면에서 훨씬 유리합니다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 보안 설정들...
}

 

SecurityConfig 클래스에 Bean으로 등록해 줍니다.

 

@Service
public class UserServiceImpl implements UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public UserServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public UserDTO registerUser(UserAuthDTO userAuthDTO) {
        String encodedPassword = passwordEncoder.encode(userAuthDTO.getPassword());
        Role userRole = Role.USER;

        UserEntity user = new UserEntity(userAuthDTO.getUsername(), userAuthDTO.getEmail(), encodedPassword, userRole);
        UserEntity savedUser = userRepository.save(user);

        return new UserDTO(savedUser.getId(), savedUser.getUsername(), savedUser.getEmail());
    }
}
 

Service클래스에서 생성자 주입을 통해 의존성을 주입받도록 수정해 줍니다.


로그인 구현

1. formlogin 방식에 대해서

formlogin 방식에서는 UsernamePasswordAuthenticationFilter라는 클래스에서 회원 검증을 진행하는데

흐름은 아래와 같습니다.

 

이미지 출처 : https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html

 

  1. 사용자가 아이디와 비밀번호를 제출하면 UsernamePasswordAuthenticationFilter는 사용자의 요청에서 아이디(username)와 비밀번호(password)를 추출하여 UsernamePasswordAuthenticationToken을 생성합니다.
  2. UsernamePasswordAuthenticationToken은 AuthenticationManager에 전달되어 인증을 진행합니다.
    AuthenticationManager는 Authentication객체를 검증하고 이를 처리하는 AuthenticationProvider에게 인증 작업을 위임합니다. 여기서 기본적으로 DaoAuthenticationProvider가 사용됩니다
  3. DaoAuthenticationProvider는 UserDetailsService를 사용하여 데이터베이스에서 사용자 정보를 가져오고, 이를 UserDetails 객체에 담아둡니다.
    UserDetails 객체에는 사용자 이름, 비밀번호, 권한 등의 정보가 포함됩니다.
  4. AuthenticationManager는 UserDetails 객체의 비밀번호와 사용자가 제출한 비밀번호를 비교합니다.
    이 과정에서 비밀번호는 PasswordEncoder(예: BCryptPasswordEncoder)를 통해 암호화된 상태로 비교됩니다.
  5. 인증이 성공하면 SecurityContextHolder에 인증된 사용자의 Authentication 객체가 저장되고, 이후 애플리케이션에서 해당 사용자 정보를 참조하여 작업을 진행할 수 있습니다.

요약하면, 아이디와 패스워드로 토큰 생성 > 토큰의 정보로 DB에서 유저 정보 조회 > DB정보와 입력한 정보를 비교해 검증

이러한 순서로 검증이 진행됩니다.

(SecurityContextHolder에 저장하는 부분이 있는데,

SecurityContextHolder는 Spring Security에서 현재 애플리케이션 내의 보안 관련 정보를 저장하고 관리하는 곳입니다.)

 

기본적으로, Spring Security에서는 이러한 formlogin이 활성화되어 있습니다. 

 

 

2. 아이디/비밀번호 검증을 위한 커스텀 필터 생성

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) 
            .formLogin(form -> disable()) // disable 처리
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .requestMatchers(HttpMethod.POST, "/users").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

 

우선, 세션 방식을 사용하는 formlogin과 세션을 사용하지 않는 JWT 방식이 충돌하지 않게 하기 위해서 formlogin을 disable 처리해줍니다.


formlogin의 세션방식과 JWT방식의 더 자세한 내용은 추후에 여기에 정리하겠습니다.

 

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    public LoginFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 사용자의 요청에서 아이디와 비밀번호 추출
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        // username과 password로 토큰을 만듬
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password, null);

        // AuthenticationManager에게 토큰을 넘겨주어 검증을 진행함.
        return authenticationManager.authenticate(authenticationToken);
    }

    // 인증이 성공했을 때
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        System.out.println("successful authentication");
    }

    // 인증이 실패했을 때
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        System.out.println("unsuccessful authentication");
    }
}

 

그리고 전체 검증 과정의 초반 단계인 username과 password로 토큰을 만들어주고 AuthenticationManager에게 토큰을 넘겨주는 역할을 하는 커스텀 필터를 만들어줍시다.

 

UsernamePasswordAuthenticationFilter를 상속한 커스텀 필터인 LoginFilter를 생성합니다.

 

 

3. 커스텀 필터로 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // authenticationManager의 매개변수로 들어갈 AuthenticationConfiguration
    private final AuthenticationConfiguration authenticationConfiguration;

	// 생성자 주입
    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration) {
        this.authenticationConfiguration = authenticationConfiguration;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // LoginFilter의 매개변수로 넣어줄 AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) 
            .formLogin(form -> disable()) // disable 처리
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .requestMatchers(HttpMethod.POST, "/users").permitAll()
                .anyRequest().authenticated()
            )
			// UsernamePasswordAuthenticationFilter 자리에 커스텀 클래스 등록
			.addFilterAt(new LoginFilter(authenticationManager(this.authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
            
        return http.build();
    }
}

 

SecurityConfig에 만들어준 LoginFilter클래스의 생성자 매개변수로 넣어줄 AuthenticationManger Bean을 등록하고

addFilterAt()로 UsernamePasswordAuthenticationFilter 자리에 LoginFilter를 등록합니다.

 

4. CustomUserDetailsService, CustomUserDetails 작성

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity userEntity = userRepository.findByEmail(username);

        if (userEntity != null) {
            return new CustomUserDetails(userEntity);
        }
        return null;
    }
}

 

이제 검증의 중반 단계를 구현하기 위해 DB에서 사용자 정보를 가져오는 UserDetailsService를 구현하는 CustomUserDetailServices 클래스를 만들어 DB에서 유저 정보를 가져옵니다.

public class CustomUserDetails implements UserDetails {

    private final UserEntity userEntity;

    public CustomUserDetails(UserEntity userEntity) {
        this.userEntity = userEntity;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();

        authorities.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return userEntity.getRole().name();
            }
        });

        return authorities;
    }

    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    @Override
    public String getUsername() {
        return userEntity.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return UserDetails.super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return UserDetails.super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return UserDetails.super.isEnabled();
    }
}

 

그리고 DB에서 가져온 유저정보를 저장해 놓기 위한 클래스인 UserDetails를 구현한 CustomUserDetails를 만들어줍니다.

 

이렇게 만들면 AuthenticationManager에서 알아서 DB에서 가져온 값과 입력한 값을 비교해서 성공 또는 실패 메서드를 호출합니다.

 

 

5. JWT Secret key 생성

검증이 성공해서 JWT를 발급할 때 사용할 JWT Secret key를 생성해 줍니다.

 

key는 임의의 문자열이며, 여러 방식으로 저장할 수 있습니다.

저는 스프링의 실행환경의 환경변수에 적용하였습니다.

 

환경 변수 적용 후 application.properties 파일에 적용해 줍니다.

jwt.secret=${JWT_SECRET}

 

 

6. JwtTokenProvider 클래스 생성

JWT를 발급하고, 서버에 요청이 왔을 때 JWT가 유효한지 검증하는 클래스인 JwtTokenProvider를 만들어줍시다.

 

우선 클래스를 만들기 전에 JWT를 다루는 라이브러리에 대한 의존성을 추가해 줍니다.

(JWT를 다루는 대표적인 라이브러리 종류는 jwt.io에서 확인할 수 있습니다. 저는 jjwt 0.12.3 버전을 사용했습니다.)

// application.properties
dependencies {
	...
	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.3'
}

 

이제 jjwt 라이브러리를 이용해 JwtTokenProvider 클래스를 생성해 줍니다.

@Component
public class JwtTokenProvider {

    private SecretKey secretKey;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
        // HS256 알고리즘을 사용하여 SecretKey를 초기화
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }
    
    public String getUsername(String token) {
        // JWT에서 사용자 이름을 추출
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
    }

    public String getRole(String token) {
        // JWT에서 사용자 역할(role)을 추출
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public Boolean isExpired(String token) {
        // JWT의 만료 여부를 확인
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    public String createJwt(String username, String role, Long expiredMs) {
        // 사용자 이름, 역할 및 만료 시간(ms 단위)으로 JWT 생성
        return Jwts.builder()
                .claim("username", username) // claim으로 payload에 데이터를 넣어줄 수 있음.
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }
}

 

위처럼 JWT를 생성해서 발행하고 검증하는 클래스를 만들어줍니다.

 

 

6. LoginFilter에 JwtTokenProvider 주입

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    public LoginFilter(AuthenticationManager authenticationManager, JwtTokenProvider jwtTokenProvider) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenProvider = jwtTokenProvider;
    }

	...

    // 인증이 성공했을 때
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 인증된 사용자 정보를 호출
        CustomUserDetails userDetails = (CustomUserDetails) authResult.getPrincipal();

        // 사용자의 권한 정보 추출
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        
        String role = auth.getAuthority();
        String username = userDetails.getUsername();
        // username, role로 10시간(60*60*10L) 동안 유효한 JWT 생성
        String token = jwtTokenProvider.createJwt(username, role, 60*60*10L);
        // 응답 헤더에 Authorization으로 토큰 설정
        response.addHeader("Authorization", "Bearer " + token);
    }

    // 인증이 실패했을 때
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.setStatus(401);
    }
}

 

생성자 방식으로 JwtTokenProvider를 주입해 주고,

성공했을 때 동작하는 successfulAuthentication()과 실패했을 때 동작하는 unsuccessfulAuthentication()을 정의해 줍니다.

 

검증이 성공했을 때는 username, role을 이용해서 토큰을 생성한 후 응답 헤더의 Authorization에 토큰 값을 넣어줍니다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtTokenProvider jwtTokenProvider;

    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JwtTokenProvider jwtTokenProvider) {
        this.authenticationConfiguration = authenticationConfiguration;
        this.jwtTokenProvider = jwtTokenProvider;
    }
	
    ...

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            ...설정들
            
            .addFilterAt(new LoginFilter(authenticationManager(this.authenticationConfiguration), this.jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

SecurityConfig에서 LoginFilter를 추가해주고 있으므로 여기에도 마찬가지로 생성자 주입 방식으로 의존성을 주입받고,

LoginFilter 생성자에 인자로 넣어줍니다.

 

 

 

 

로그인 요청의 응답 헤더에 생성한 JWT를 확인할 수 있습니다.

이제 사용자는 다른 경로에 요청을 보낼 때 응답받은 JWT를 요청헤더에 넣고 요청을 보내야 합니다.


JWT 검증 필터 생성

이제 요청에 담긴 JWT를 검증하기 위한 커스텀 필터를 등록해야 합니다.

해당 필터를 통해 요청 헤더의 Authorization가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성합니다. (이 세션은 stateless 상태로 관리하기 때문에 해당요청이 끝나면 삭제됩니다.)

 

1. 역할 분리

우선, 이전에 만들었던 JwtTokenProvider의 getUsername(), getRole(), isExpired()는 검증을 위한 메서드입니다.

이렇게 되면 JwtTokenProvider가 가지는 책임이 2개(토큰 발급, 유효성 검증을 위한 값 조회)가 된다고 생각해 분리를 해줄 생각입니다.

 

@Component
public class JwtValidator {
    private SecretKey secretKey;

    public JwtValidator(@Value("${jwt.secret}") String secret) {
        // HS256 알고리즘을 사용하여 SecretKey를 초기화
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getUsername(String token) {
        // JWT에서 사용자 이름을 추출
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
    }

    public String getRole(String token) {
        // JWT에서 사용자 역할(role)을 추출
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public Boolean isExpired(String token) {
        // JWT의 만료 여부를 확인
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }
}

 

그래서 이러한 검증을 위한 클래스를 만들어주고 JwtTokenProvider에서는 해당하는 메서드를 지워줍니다.

 

 

2. 검증 필터 생성

이제 JWT의 검증을 위한 커스텀 필터를 만들어줍니다.

public class JwtFilter extends OncePerRequestFilter {
    private final JwtValidator jwtValidator;

    public JwtFilter(JwtValidator jwtValidator) {
        this.jwtValidator = jwtValidator;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 요청에서 token값을 찾음
        String token = getJwtFromRequest(request);

        if(token != null && !jwtValidator.isExpired(token)) {
            String username = jwtValidator.getUsername(token);
            String role = jwtValidator.getRole(token);
            Role roleEnum = Role.valueOf(role);

            UserEntity userEntity = new UserEntity();
            userEntity.setEmail(username);
            userEntity.setPassword("tempPassword");
            userEntity.setRole(roleEnum);

            CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());

            // SecurityContextHolder에 인증정보 저장
            SecurityContextHolder.getContext().setAuthentication(authToken);

            // 토큰이 유효하면 요청과 응답을 다음 필터로 넘겨줌
            filterChain.doFilter(request, response);
        } else {
            // 토큰이 유효하지 않다면 필터 체인을 진행하지 않고 401 Unauthorized 응답
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Access token expired or invalid.");
        }
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

위 코드처럼 필터를 만들어줍니다.

여기서 OncePerRequestFilter를 상속받았는데, OncePerRequestFilter는 Spring Framework에서 제공하는 필터 중 하나로,

하나의 요청에 대해서 단 한 번만 실행되도록 보장하는 필터입니다.

OncePerRequestFilter의 doFilterInternal을 오버라이딩해서 필터 로직을 작성합니다.

 

전체적인 순서는

  1. 요청 헤더의 Authorization 검사
  2. JWT 검증 및 사용자 정보 추출
  3. SecurityContextHolder에 인증 정보 저장
  4. Stateless 방식으로 관리

Stateless 방식으로 인증 정보를 관리하기 때문에 요청이 끝날 때 SecurityContextHolder에 저장된 인증정보는 삭제됩니다.

 

 

3. 필터 등록

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtProvider jwtProvider;
    private final JwtValidator jwtValidator;

    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JwtProvider jwtProvider, JwtValidator jwtValidator) {
        this.authenticationConfiguration = authenticationConfiguration;
        this.jwtProvider = jwtProvider;
        this.jwtValidator = jwtValidator;
    }

	...

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            ...
            
            .addFilterAt(new LoginFilter(authenticationManager(this.authenticationConfiguration), this.jwtProvider), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(new JwtFilter(jwtValidator), LoginFilter.class);

        return http.build();
    }
}

 

위처럼 addFilterBefore() 메서드를 이용해서 방금 만든 필터를 LoginFilter 앞에 설정해 줍니다.

이렇게 설정해 주면 사용자의 요청이 왔을 때 JWT의 유효성을 검증해 줍니다.