(인프런 Spring Boot JWT Tutorial 강의Amigoscode 유튜브 강의를 참고하여 진행했습니다.)

 

인프런 강의 2강에서 SecurityConfig 클래스를 등록할 때 WebSecurityConfigurerAdapter 클래스를 상속받아 진행하는 부분이 있었는데, 현재는 WebSecurityConfigurerAdapter 클래스는 deprecated 된 상태라 사용할 수 없었다. 따라서 다른 유튜브 강의를 참고해서 진행하였다. 

 

Basic Authentication

프로젝트를 시작할 때 spring security를 dependency로 더하지 않았었다. MavenRepository에서 코드를 찾아 build.gradle 파일의 dependencies 변수에 추가해 준다. 

implementation("org.springframework.boot:spring-boot-starter-security")

 

그리고 기존에 작성한 /ok API를 실행하면 바뀌는 점이 있는데, 기존에는 바로 200 OK와 함께 문자열을 리턴하였던 API가 로그인 폼을 리턴한다. 

 

로그인 폼의 Username은 기본 상태에서는 "user"이고, 비밀번호는 서버를 실행시킬 때 일시적인 비밀번호가 부여된다. 

로그인한 이후에는 기존 /ok API와 같은 Response를 반환한다. 

똑같은 요청을 postman으로 보내보았다. 결과는 401 Unauthorized였다. 즉 /ok API로 보내진 요청이 unauthorized response를 받고 로그인 폼으로 리다이렉트(redirect) 되었던 것이다. 

 

왜 그랬는지는 Spring Security의 구조를 보면 알 수 있다.

Spring Security는 앱이 처음 시작되면 SecurityFilterChain 타입의 Bean을 찾는다고 한다. 이때 SecurityFilterChain은 인터페이스 타입이다. 

@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
    http.formLogin(withDefaults());
    http.httpBasic(withDefaults());
    return http.build();
}

SecurityFilterChain은 다음과 같은 구조를 가진다. 

어떤 http 요청이 들어오건 모든 요청에 대해서는 해당 요청이 인가되었는지를 확인한다. 

만약 그렇지 않다면 로그인 폼을 렌더링하고, 여기서도 실패할 경우는 basic 폼을 렌더링한다. 

 

이제 이 코드를 그대로 가져와서 SecurityConfig 클래스를 정의해 보자. 

@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
        http.formLogin(withDefaults());
        http.httpBasic(withDefaults());
        return http.build();
    }

}

 

@EnableWebSecurity 어노테이션은 보안 관련 클래스와 Bean들을 포함하는 클래스에 명시한다. 그러면 스프링 컨테이너에서는 해당 클래스를 자동으로 Security Configuration 클래스로 인식한다. 

 

JWT Authentication

방금 전까지는 가장 기본적으로 Spring Security를 사용하는 경우를 살펴보았는데, 이번에 구현할 JWT 인증은 조금 더 복잡하다. 

우선 @Controller 클래스가 호출되기 전까지 요청에 관여하는 클래스들이 많다. 

 

Client -> JWTAuthFilter -> UserDetailsService -> (InMemory)DB -> SecurityContextHolderr -> Controller

 

해당 순서를 거쳐서 controller 클래스가 호출되고, 중간에 인증이 실패하면 아까처럼 401 Unauthorized 응답을 리턴하게 된다. 

 

(1) JWTAuthFilter

가장 먼저 요청을 받는 클래스로, 요청의 헤더에 'JWT'가 포함되어있는지를 확인한다. 만약 JWT가 없다면 JWT 토큰 관련 정보가 없는 것이므로 401 응답을 리턴한다. 

만약 JWT 헤더가 있다면 그 다음 단계인 UserDetailsService에다가 JWT 헤더의 토큰에서 추출한 유저 관련 정보를 가지고 해당 정보를 요청한다. (ex. id=1인 유저의 정보 요청)

UserDetailsService로부터 정보를 받으면 받은 유저 정보와 JWT 토큰을 디코딩해서 얻은 정보를 비교한다. 둘이 다르면 401 에러를 리턴하고, 같으면 SecurityContextHolder를 호출한다. 

 

(2) UserDetailsService

JWTAuthFilter의 요청을 받아서 그 다음 클래스인 DB(InMemoryDB일 수도, 유저 정보가 저장된 일반 DB일 수도 있고 구현은 다양하다)에다가 유저 정보를 요청하고, DB가 유저 정보를 리턴하면 그걸 JWTAuthFilter에게 전달한다. 

 

(3) DB

UserDetailsService의 요청을 받아서 DB에서 유저 정보를 조회한 뒤 UserDetailsService에게 리턴한다. 

 

(4) SecurityContextHolder

이 안에 SecurityFilterChain 인터페이스도 포함되어 있다. JWTAuthFilter에 의해 호출되면 이 클래스에서는 유저의 상태를 authenticated로 바꾸고 요청을 DispatcherServlet에게 넘긴다.

그러면 DispatcherServlet에서 유저가 원래 요청했던 URL에 해당하는 컨트롤러의 메소드를 호출하고 해당 컨트롤러에서 응답을 리턴하게 된다. 

 

JWT 인증 구현 - JwtAuthFilter

본격적인 JWT 인증을 구현하기 전, 하나의 dependency가 더 필요하다.

implementation("io.jsonwebtoken:jjwt-api:0.11.5")

 

이후 config 디렉토리에 JwtAuthFilter 클래스를 추가하고, 해당 클래스에 @Component 어노테이션을 붙인다. 

또한 해당 클래스가 OncePerRequestFilter 클래스를 상속하도록 한다. 

 

OncePerRequestFilter는 Filter의 일종으로, 한 번의 요청당 한 번만 호출되는 filter 클래스라고 보면 된다. 그리고 해당 클래스를 상속하려면 반드시 doFilterInternal() 메소드를 구현해야 한다. 한 번의 요청이 호출될 때 해당 filter 클래스가 어떤 작업을 할지를 이 메소드에서 정의한다고 보면 된다. 

 

구현하면 이런 모양이 되겠다. (메소드의 구현은 유튜브 영상 45:34에 나와 있다.)

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        // implementation
    }
}

 

JwtAuthFilter 메소드를 보면 Username을 얻는 부분과 토큰 유효성 검증 부분은 구현되어 있지 않다. 

이 부분은 jwtutils 파일을 검색하면 나오는 여러 깃헙 리포에서 가져올 수 있는데, 이들 중 하나에서 코드를 가져와서 사용했다. 

 

JwtAuthFilter.java에서 아직 구현되지 않은 부분을 메소드를 호출하여 완성하자. 

@Component
@RequiredAllArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;
    private final JwtUtils jwtUtils;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        // code
        userEmail = jwtUtils.extractUsername(jwtToken);
        final boolean isTokenValid = jwtUtils.validateToken(jwtToken, userDetails);
        // code
    }
}

 

이제 스프링 컨테이너에게 이 필터를 사용하겠다고 선언해야 한다. 

앞서 맨 먼저 만들었던 SpringConfig.java에는 @EnableWebSecurity 어노테이션이 붙어 있어서 스프링 컨테이너는 이 클래스를 security configuration 클래스로 인식한다. 따라서 이 클래스에 선언하면 된다. 

(코드의 구현은 유튜브 영상의 53:42에서 볼 수 있다.)

 

참고로 해당 영상에서는 Lombok를 dependency에 추가해서 모든 생성자 관련 코드를 @RequiredArgsConstructor으로 대체하는데, Lombok을 추가하지 않았다면 다음 작업으로 대체하면 된다. 

 

(예시) Lombok을 dependencies에 추가했을 경우

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

}

 

(예시) Lombok을 dependencies에 추가하지 않았을 경우

@Configuration
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

    @Autowired
    public SecurityConfig(JwtAuthFilter jwtAuthFilter){
        this.jwtAuthFilter = jwtAuthFilter;
    }
}

 

JWT 인증 구현 - UserDetailsService

UserDetailsService를 구현하는 방법은 두 가지가 있다. 

UserDetailsService 인터페이스를 구현한 개별 클래스를 만들 수도 있고, SecurityConfig 클래스에 @Bean을 붙여 메소드로 구현할 수도 있다. 

강의에서는 비교적 간단한 메소드 구현 방식을 사용하였으나, 여기서는 circular import 에러 문제로 UserDetailsService 인터페이스를 클래스로 구현하여 사용하였다.

 

UserDetailsServiceImpl.java

@Component
public class UserDetailsServiceImpl implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Autowired
    public UserDetailsServiceImpl(MemberRepository memberRepository){
        this.memberRepository = memberRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository
                .findByName(username)
                .stream()
                .findAny()
                .orElseThrow(() -> new UsernameNotFoundException("해당 유저가 없습니다."));
    }

}

 

이후의 과정에 대해서는 다음 포스팅에서 이어가 보겠다. 

 

 

참고한 포스트

https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html

https://www.youtube.com/watch?v=6n4k8Van_HI&list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&index=3 

https://fusiondeveloper.tistory.com/69

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt#curriculum

https://velog.io/@suhongkim98/Spring-Security-JWT%EB%A1%9C-%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

https://velog.io/@pjh612/Deprecated%EB%90%9C-WebSecurityConfigurerAdapter-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8C%80%EC%B2%98%ED%95%98%EC%A7%80

https://www.youtube.com/watch?v=b9O9NI-RJ3o 

https://github.com/MajidLamghari/Spring-boot-security-jwt/blob/master/src/main/java/io/javabrains/springsecurityjwt/util/jwtUtil.java 

 

+ Recent posts