현대 웹 서비스에서는 토큰을 사용하여 사용자들의 인증 작업을 처리하는 것이 가장 좋은 방법이다.
본 프로젝트에서 요구하는 것 또한 JWT + Oauth2 구현이며 이번 글은 토큰 기반의 인증 시스템에서 주로 사용하는 JWT(Json Web Token)에 대해 SpringBoot와 Spring Security 기반으로 제작한다.
1. Spring Security 처리 과정
기본적으로 Spring Security는 Session 기반으로 동작하고 그에 대한 글은 전에 게시한 적이 있다. 자세한 처리과정과 구현은 아래 게시글에서 확인할 수 있다.
https://wonsjung.tistory.com/443
[ 0. 사전 세팅 ]
먼저 프로젝트에서 사용할 Dependency들을 build.gradle에 추가해준다.
plugins {
id 'org.springframework.boot' version '2.7.1'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation group: 'org.springframework.ldap', name: 'spring-ldap-core', version: '2.4.1'
implementation 'javax.xml.bind:jaxb-api:2.3.0'
runtimeOnly 'com.h2database:h2'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
그리고 정적 자원을 제공하는 클래스를 생성하여 아래와 같이 설정한다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/static/",
"classpath:/public/", "classpath:/",
"classpath:/resources/",
"classpath:/META-INF/resources/",
"classpath:/META-INF/resources/webjars/"
};
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// /에 해당하는 url mapping을 /common/test로 forward한다.
registry.addViewController( "/" ).setViewName( "forward:/index" );
// 우선순위를 가장 높게 잡는다.
registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
}
}
그리고 SpringSecurity에 대한 기본적인 설정 등을 추가한다. SpringSecurity에 대한 설정 클래스에서는
- configure 메소드를 통해 정적 자원들에 대해서는 Security를 적용하지 않음을 추가한다.
- configure 메소드를 통해 어떤 요청에 대해서는 로그인을 요구하고, 어떤 요청에 대해서는 로그인을 요구하지 않을지 설정한다.
- form 기반의 로그인을 비활성화 한다.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 정적 자원에 대해서는 Security 설정을 적용하지 않음.
@Override
public void configure(WebSecurity web) {
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests()
// 토큰을 활용하는 경우 모든 요청에 대해 접근이 가능하도록 함
.anyRequest().permitAll()
.and()
// 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// form 기반의 로그인에 대해 비활성화 한다.
.formLogin()
.disable();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
이번 예제에서는 토큰을 활용하고, 세션을 활용하지 않도록 설정해준다.
전 게시글에서도 언급을 했듯이 Spring은 현재 WebSecurityConfigurerAdapter를 Deprecated하여 지원하지 않는다.
필요한 config들은 모두 Bean으로 등록하여 사용하여야한다. 자세한 Document는 아래 링크를 참조하면 된다.
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
따라서, 위 코드가 아닌 Bean으로 등록하는 코드로 수정해주어야한다.
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.httpBasic().disable();
http
.authorizeRequests()
// 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// form 기반의 로그인에 대해 비활성화 한다.
.formLogin()
.disable();
return http.build();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
[ 1. 로그인 요청 ]
사용자는 로그인 하기 위해 아이디와 비밀번호를 입력해서 로그인 요청을 하게 된다. 이번에 작성하는 예제에서는 로그인 API를 호출하고, Json으로 사용자의 아이디와 비밀번호를 보내는 상황이다.
[ 2. UserPasswordAuthenticationToken 발급 ]
전송이 오면 AuthenticationFilter로 요청이 먼저 오게 되고, 아이디와 비밀번호를 기반으로 UserPasswordAuthenticationToken을 발급해주어야 한다. 프론트 단에서 유효성 검사를 하겠지만, 안전을 위해서 다시 한 번 아이디와 패스워드의 유효성 검사를 해주는 것이 좋지만 아래의 예제에서는 생략한다. 해당 Filter를 직접 구현하면 아래와 같다.
@Log4j2
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(final AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(final HttpServletRequest request,
final HttpServletResponse response) throws AuthenticationException{
final UsernamePasswordAuthenticationToken authRequest;
try{
final User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
authRequest = new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPw());
} catch (IOException exception){
throw new InputNotFoundException();
}
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
위에서 사용되는 사용자 정보를 담는 User 객체는 아래와 같다.
@Entity
@Table(name = "member")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends Common implements Serializable {
@Column(nullable = false, unique = true, length = 50)
private String email;
@Setter
@Column(nullable = false, length = 50)
private String pw;
@Setter
@Column(nullable = false, length = 50)
@Enumerated(EnumType.STRING)
private UserRole role;
}
만약 아이디와 비밀번호가 제대로 전달되지 않아을 경우에는 예외 처리를 해주어야 하므로 InputNotFoundException 클래스를 생성하여 처리한다.
public class InputNotFoundException extends RuntimeException {
public InputNotFoundException(){
super();
}
}
이렇게 직접 제작한 Filter를 이제 적용시켜야 하므로 UsernamePasswordAuthenticationFilter 필터 이전에 적용시켜야 한다.
그리고 해당 CustomAuthenticationFilter가 수행된 후에 처리될 Handler 역시 Bean으로 등록하고 CustomAuthenticationFilter의 핸들러로 추가해주어야 하는데, 해당 코드들은 WebSecurityConfig에 추가해줄 수 있다. 똑같이 빈으로 등록하는 방식을 적용한다.
@Log4j2
public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response,
final Authentication authentication) {
final User user = ((MyUserDetails) authentication.getPrincipal()).getUser();
final String token = TokenUtils.generateJwtToken(user);
response.addHeader(AuthConstants.AUTH_HEADER, AuthConstants.TOKEN_TYPE + " " + token);
}
}
CustomLoginSuccessHandler는 AuthenticationProvider를 통해 인증이 성골될 경우 처리되는데, TokenUtils에 대해서는 아래에서 작성한다. 또한, 인증과 관련해 자주 사용되는 상수는 아래의 AuthConstants 클래스에 정의해두었다.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class AuthConstants {
public static final String AUTH_HEADER = "Authorization";
public static final String TOKEN_TYPE = "BEARER";
}
로그인이 성공하면 TokenUtils를 통해 토큰을 생성하고, response에 이를 추가하여 반환한다.
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AbstractAuthenticationProcessingFilter filter = new CustomAuthenticationFilter(authenticationManager());
filter.setFilterProcessesUrl("/user/login");
filter.setAuthenticationSuccessHandler(customLoginSuccessHandler());
filter.afterPropertiesSet();
http
.csrf().disable()
.httpBasic().disable();
http
.authorizeRequests()
// 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// form 기반의 로그인에 대해 비활성화 한다.
.formLogin()
.disable()
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public CustomLoginSuccessHandler customLoginSuccessHandler(){
return new CustomLoginSuccessHandler();
}
}
CustomAuthenticationFilter를 빈으로 등록하는 과정에서 UserName 파라미터와 UserPassword 파라미터를 설정할 수 있다. 이러한 과정을 거치면 UsernamePasswordToken이 발급되게 된다.
[ 3. UsernamePasswordToken을 AuthenticationManager에게 전달 ]
AuthenticationFilter는 생성한 UsernamePasswordToken을 AuthenticationManager에게 전달한다. AuthenticationManager은 실제로 인증을 처리할 여러 개의 AuthenticationProvider를 가지고 있다.
여기서 하나 주의할 점이 존재한다. 지금까지 많은 Spring Security + JWT 게시글은 대부분 WebSecurityConfigurerAdapter를 사용한다. 그리고 WebSecurityConfigurerAdapter 안에 이미 존재하고 있는 AuthenticationManager를 그대로 사용한다. 하지만 현재는 WebSecurityConfigurerAdapter를 사용할 수 없기 때문에 이 부분을 따로 받아와주어야한다. 그리고 똑같이 Config에 Bean으로 등록해주어야 사용이 가능하다. 아래에서 이 부분 관련 코드를 소개할 예정이다.
[ 4. UsernamePasswordToken을 AuthenticationProvider에게 전달 ]
AuthenticationManager는 전달 받은 UsernamePasswordToken을 순차적으로 AuthenticationProvider들에게 전달하여 실제 인증의 과정을 수행해야 하며, 실제 인증에 대한 부분은 authenticate 함수에 작성을 해주어야 한다. SpringSecurity에서는 Username으로 DB에서 데이터를 조회한 다음에, 비밀번호의 일치여부를 검사하는 방식으로 작동을 한다. 그렇기 때문에 먼저 UsernamePasswordToken 토큰으로부터 아이디를 조회해야 하고 그 코드는 아래와 같다.
@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
final String email = token.getName();
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
[ 5. UserDetailsService로 조회할 아이디를 전달 ]
AuthenticationProvider에서 아이디를 조회하였으면, UserDetailsService로부터 아이디를 기반으로 데이터를 조회해야한다. UserDetailsService는 인터페이스이기 때문에 이를 implements한 클래스를 작성해주어야 한다. 실제 반환값을 작성하는 부분은 7번에서 다룬다.
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public MyUserDetails loadUserByUsername(String email) {
}
}
또한, User와 관련된 SQL을 처리하는 JpaRepository를 구현한 UserRepository는 아래와 같다.
@Repository
public interface UserRepository extends JpaRepository <User, Long> {
User findByEmailAndPw(String email, String pw);
Optional<User> findByEmail(String email);
}
[ 6. 아이디를 기반으로 DB에서 데이터 조회 ]
전달받은 아이디를 기반으로 DB에서 조회하는 구현체는 직접 개발한 User일 것이고, UserDetailsService의 반환값은 UserDetails 인터페이스이기 때문에 이를 implements하여 구현한 MyUserDetails를 아래와 같이 작성할 수 있다.
@RequiredArgsConstructor
@Getter
public class MyUserDetails implements UserDetails {
@Delegate
private final User user;
private final Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return user.getPw();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return user.getIsEnable();
}
@Override
public boolean isAccountNonLocked() {
return user.getIsEnable();
}
@Override
public boolean isCredentialsNonExpired() {
return user.getIsEnable();
}
@Override
public boolean isEnabled() {
return user.getIsEnable();
}
}
[ 7. 아이디를 기반으로 조회한 결과를 반환 ]
아이디를 기반으로 조회한 결과를 반환하기 위해서는 위에서 작성하던 UserDetailsServiceImpl을 마무리해주어야 하는데, 그 전에 사용자의 아이디를 기반으로 데이터가 조회하지 않아을 경우 처리해주기 위한 Exception 클래스를 추가해주어야 한다.
public class UserNotFoundException extends RuntimeException{
public UserNotFoundException(String email){
super(email + " NotFoundException");
}
}
그리고 조회한 결과를 CustomAuthenticationProvider로 반환하는 UserDetailsServiceImpl을 마무리해주면 아래와 같다.
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public MyUserDetails loadUserByUsername(String email) {
return userRepository.findByEmail(email)
.map(u -> new MyUserDetails(u, Collections.singleton(new SimpleGrantedAuthority(u.getRole().getValue()))))
.orElseThrow(() -> new UserNotFoundException(email));
}
}
위의 예제에서는 UserRepository로부터 조회한 결과를 Optional로 반환하고 있기 때문에 map 함수를 이용해서 새로운 UserDetails 객체로 생성하여 반환하고 있다.
[ 8. 인증 처리 후 인증된 토큰을 AuthenticationManager에게 반환 ]
이제 CustomAuthenticationProvider에서 UserDetailsService를 통해 조회한 정보와 입력받은 비밀번호가 일치하는지 확인하여, 일치한다면 인증된 토큰을 생성하여 반환해주어야 한다. DB에 저장된 사용자 비밀번호는 암호화가 되어있기 때문에, 입력으로부터 들어온 비밀번호를 PasswordEncoder를 통해 암호화하여 DB에서 조회한 사용자의 비밀번호와 매칭되는지 확인해주어야 한다. 만약 비밀번호가 매칭되지 않는 경우에는 BadCredentialsException을 발생시켜 처리해준다.
@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// AuthenticationFilter에서 생성된 토큰으로부터 아이디와 비밀번호 조회
final String userEmail = token.getName();
final String userPw = (String) token.getCredentials();
// UserDetailsService를 통해 DB에서 아이디로 사용자 조회
final MyUserDetails userDetails = (MyUserDetails) userDetailsService.loadUserByUsername(userEmail);
if(!passwordEncoder.matches(userPw, userDetails.getPassword())) {
throw new BadCredentialsException(userDetails.getUsername() + "Invalid password");
}
return new UsernamePasswordAuthenticationToken(userDetails, userPw, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
위와 같이 생성된 CustomAuthenticationProvider를 이제 Bean으로 등록해주어야 하는데, 이것을 WebSecurityConfig에 작성하면 된다. 여기서 위에서 언급했던 AuthenticationManager를 처리하는 방법이 나온다.
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
private final AuthenticationConfiguration configuration;
private final UserDetailsService userDetailsService;
// AuthenticationManager를 Bean으로 등록
@Bean
public AuthenticationManager authenticationManager() throws Exception{
return configuration.getAuthenticationManager();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AbstractAuthenticationProcessingFilter filter = new CustomAuthenticationFilter(authenticationManager());
filter.setFilterProcessesUrl("/user/login");
filter.setAuthenticationSuccessHandler(customLoginSuccessHandler());
filter.afterPropertiesSet();
http
.csrf().disable()
.httpBasic().disable();
http
.headers()
.frameOptions()
.sameOrigin();
http
.authorizeRequests()
.antMatchers("/h2-console/*","favicon.ico").permitAll()
.and()
// 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// form 기반의 로그인에 대해 비활성화 한다.
.formLogin()
.disable()
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public CustomLoginSuccessHandler customLoginSuccessHandler(){
return new CustomLoginSuccessHandler();
}
@Bean
public CustomAuthenticationProvider customAuthenticationProvider(){
return new CustomAuthenticationProvider(userDetailsService, bCryptPasswordEncoder());
}
@Autowired
void configure(AuthenticationManagerBuilder builder,
@Lazy BCryptPasswordEncoder encoder) throws Exception {
builder.userDetailsService(userDetailsService).passwordEncoder(encoder);
}
}
마지막 configure 메소드 파라미터 중 BCryptPasswordEncoder 앞에 붙어 있는 @Lazy는 빈 순환 참조 해결을 위해 조치하였다. 하지만 이 방법은 스프링에서 권장하는 방식이 아니기에 후에 보완할 예정이다.
[ 9. 인증된 토큰을 AuthenticationFilter에게 전달 ]
AuthenticationProvider에서 인증이 완료된 UsernamePasswordAuthenticationToken을 AuthenticationFilter로 반환하고, AuthenticationFilter에서는 LoginSuccessHandler로 전달한다.
[ 10. 인증된 토큰을 기반으로 JWT 발급 ]
LoginSuccessHandler로 넘어온 요청은 /user/loginSuccess로 redirect된다. 전달받은 Authentication 정보를 활용해 Json Web Token을 생성해주어야 하는데, 토큰과 관련된 요청을 처리하는 TokenUtils를 아래와 같이 만들어 줄 수 있다.
@Log4j2
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class TokenUtils {
private static final String secretKey = "ThisIsA_SecretKeyForJwtExample";
public static String generateJwtToken(User user){
JwtBuilder builder = Jwts.builder()
.setSubject(user.getEmail())
.setHeader(createHeader())
.setClaims(createClaims(user))
.setExpiration(createExpireDateForOneYear())
.signWith(SignatureAlgorithm.HS256, createSigningKey());
return builder.compact();
}
public static boolean isValidToken(String token){
try{
Claims claims = getClaimsFormToken(token);
log.info("expireTime : {}", claims.getExpiration());
log.info("email : {}", claims.get("email"));
log.info("role : {}", claims.get("role"));
return true;
} catch (ExpiredJwtException exception){
log.error("Token Expired");
return false;
} catch (JwtException exception){
log.error("Token Tampered");
return false;
} catch (NullPointerException exception){
log.error("Token is null");
return false;
}
}
private static Map<String, Object> createHeader(){
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
header.put("regDate", System.currentTimeMillis());
return header;
}
public static String getTokenFromHeader(String header){
return header.split("")[1];
}
private static Map<String, Object> createClaims(User user){
// 공개 클레임에 사용자의 이름과 이메일을 설정하여 정보를 조회할 수 있다.
Map<String, Object> claims = new HashMap<>();
claims.put("email", user.getEmail());
claims.put("role", user.getRole());
return claims;
}
private static Date createExpireDateForOneYear(){
// 토큰 만료 시간은 30일으로 설정
Calendar c = Calendar.getInstance();
c.add(Calendar.DATE, 30);
return c.getTime();
}
private static Key createSigningKey(){
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secretKey);
return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
}
private static Claims getClaimsFormToken(String token){
return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey))
.parseClaimsJws(token).getBody();
}
private static String getUserEmailFromToken(String token){
Claims claims = getClaimsFormToken(token);
return (String) claims.get("email");
}
private static UserRole getRoleFromToken(String token){
Claims claims = getClaimsFormToken(token);
return (UserRole) claims.get("role");
}
}
인증이 성공되고 나면 CustomLoginSuccessHandler에서 Token이 생성되고 되고, 생성된 토큰을 반환하게 된다.
2. Spring Security 처리 과정
이제 토큰을 생성해주는 부분까지는 마무리를 하였고, 토큰을 발급받은 사용자만 원하는 로직을 처리할 수 있도록 해주어야 한다. 아래의 내용에서는 Interceptor를 활용해 유효한 토큰을 가진 사용자만 접근할 수 있도록 접근 제어를 해주고 있다.
[ 1. 유효한 토큰 검증을 위한 인터셉터 추가 ]
이 클래스는 토큰을 검증하도록 설정한 API에 대해 요청을 intercept하여 토큰의 유효성 검사를 진행한다. 유효성 검사에 실패하면 예외 API로 redirect를 시키고 있다.
@Log4j2
public class JwtTokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(final HttpServletRequest request,
final HttpServletResponse response,
final Object handler) throws Exception {
final String header = request.getHeader(AuthConstants.AUTH_HEADER);
if(header != null){
final String token = TokenUtils.getTokenFromHeader(header);
if(TokenUtils.isValidToken(token)){
return true;
}
}
response.sendRedirect("/error/unauthorized");
return false;
}
}
[ 2. 예외 처리 컨트롤러 추가 ]
토큰의 유효성 검증에 실패한 경우 아래의 /error/unauthorized API로 redirect된다.
@RestController
@RequestMapping(value = "/error")
public class ErrorController {
@GetMapping(value = "/unauthorized")
public ResponseEntity<Void> unauthorized() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
[ 3. 인터셉터 추가 및 패턴 적용 ]
작성한 인터셉터 클래스를 설정에 추가하고, 토큰의 유혀성을 검증할 API의 Path 패턴을 적용한다.
이번 예제에서는 전체 사용자를 조회하는 /user/findAll 에 대해 유효한 토큰을 헤더에 포함시켜 요청한 경우만 API를 호출 가능하도록 설정하였다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 작성한 인터셉터를 추가한다.
registry.addInterceptor(jwtTokenInterceptor())
.addPathPatterns("/user/findAll");
}
@Bean
public FilterRegistrationBean<HeaderFilter> getFilterRegistrationBean(){
FilterRegistrationBean<HeaderFilter> registrationBean = new FilterRegistrationBean<>(createHeaderFilter());
registrationBean.setOrder(Integer.MIN_VALUE);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
@Bean
public HeaderFilter createHeaderFilter(){
return new HeaderFilter();
}
@Bean
public JwtTokenInterceptor jwtTokenInterceptor(){
return new JwtTokenInterceptor();
}
}
[ 4. 사용자 API 추가 ]
모든 사용자가 호출 가능한 회원가입 API와 위에서 적용한 PathPattern으로 유효한 토큰을 전송한 사용자만 호출가능한 전체 사용자 목록 조회 API를 추가하자.
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
private final UserService userService;
private final BCryptPasswordEncoder passwordEncoder;
@PostMapping("/signUp")
public ResponseEntity<String> signUp(@RequestBody final SignUpDto signUpDto){
return userService.findByEmail(signUpDto.getEmail()).isPresent()
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(signUpDto)));
}
@GetMapping("/list")
public ResponseEntity<UserListResponseDTO> findAll(){
final UserListResponseDTO userListResponseDTO
= UserListResponseDTO.builder()
.userList(userService.findAll()).build();
return ResponseEntity.ok(userListResponseDTO);
}
}
위 API 스펙에 맞는 DTO 클래스를 추가해준다.
@Getter
public class SignUpDTO {
private String email;
private String pw;
}
@Getter
@Builder
public class UserListResponseDTO {
private final List<User> userList;
}
그리고 UserService에서는 다음과 같은 로직들로 위의 API를 처리하고 있다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
@Transactional
public User signUp(final SignUpDto signUpDto){
final User user = User.builder()
.email(signUpDto.getEmail())
.pw(passwordEncoder.encode(signUpDto.getPw()))
.role(UserRole.ROLE_USER)
.build();
return userRepository.save(user);
}
public Optional<User> findByEmail(final String email){
return userRepository.findByEmail(email);
}
public boolean isEmailDuplicated(final String email){
return userRepository.existsByEmail(email);
}
public List<User> findAll(){
return userRepository.findAll();
}
}
3. Spring Security 처리 과정 요약
1. 사용자가 아이디 비밀번호로 로그인 요청 (Request)
2. AuthenticationFilter에서 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 전달
3. AuthenticationManager는 등록된 AuthenticationProvider(s)을 조회하여 인증 요구
4. AuthenticationProvider는 UserDetailsService를 통해 입력받은 아이디에 대한 사용자 정보를 DB에서 조회함
5. 입력받은 비밀번호를 암호화하여 DB의 비밀번호와 매칭되는 경우 인증이 성공된 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager로 반환
6. AuthenticationManager는 UsernamePasswordAuthenticationToken을 AuthenticationFilter로 전달
7. AuthenticationFilter는 전달 받은 UsernamePasswordAuthenticationToken을 LoginSuccessHandler로 전송하고, 토큰을 response의 헤더에 추가하여 반환
※ 참고 ※
https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/index.html
https://shinsunyoung.tistory.com/78
https://godekdls.github.io/Spring%20Security/authentication/
https://way-be-developer.tistory.com/221
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
'2022 > 2022-1' 카테고리의 다른 글
[SpringBoot] SpringBoot + SpringSecurity + JWT (회원가입 / 로그인 구현) (0) | 2022.08.10 |
---|---|
로그인 처리 - 필터 / 인터셉터 (1) (필터 개념 및 예제 코드) (0) | 2022.08.07 |
JWT(Json Web Token)란? (0) | 2022.07.19 |
[인증/인가] Token(토큰) 기반 인증과 Session(세션) 기반 인증 (0) | 2022.07.14 |
[SpringBoot] Spring Security 처리 과정 (0) | 2022.07.13 |