본문 바로가기
개발/Spring

발급받은 JWT로 요청하기

by solchan98 2022. 2. 11.

깃허브 저장소

 

GitHub - solchan98/Playground: 🛝 개발 공부 놀이터 🛝

🛝 개발 공부 놀이터 🛝. Contribute to solchan98/Playground development by creating an account on GitHub.

github.com

+2022-09-28 새로 작성되었습니다.

로그인과 JWT토큰 발급
이전 포스트에서 Spring Security를 통해 로그인을 진행하고, 토큰을 발급하는 과정을 진행하였다.
이번에는 발급받은 JWT를 통해 권한이 필요한 URI에 요청을 해보자.

Spring Security는 여러가지의 필터를 순차적으로 돌며 해당되는 필터를 실행한다.
그리고 인증에 관련된 책임은 AuthenticationManager에 의해 수행된다.

 

기본적으로 Filter로 수행되는 것은 Form기반의 아이디와 비밀번호로 진행되는 UsernamePasswordAuthenticationFilter가 수행된다.

하지만 JWT 인증을 위해서는 새로운 필터를 만들어 UsernamePasswordAuthenticationFilter보다 먼저 수행되게 설정해야 한다.

 

TODO

  • JwtProvider 추가 작성 (getSubject 메서드)
  • AccountDetailsService(+ AccountDetails) 작성
  • JwtAuthenticationFilter 작성
  • CustomAuthenticationEntryPoint(JWT Exception Handler) 작성
  • AppConfig(filterChain) 추가 작성

JwtProvider - getSubject() 구현

jwt의 payload에 있는 유저 정보를 Subject로 꺼낸다.

@Component
@RequiredArgsConstructor
public class JwtProvider {

    ...

    public Subject getSubject(String atk) throws JsonProcessingException {
        String subjectStr = Jwts.parser().setSigningKey(key).parseClaimsJws(atk).getBody().getSubject();
        return objectMapper.readValue(subjectStr, Subject.class);
    }
}

AccountDetailsService 작성

Spring Security에 유저 정보를 관리하기 위해 AccountDetailsService 및 AccountDetails를 작성한다.

/springbootjwt/jwt/*

AccountDetails

@Getter
public class AccountDetails extends User {

    private final Account account;

    public AccountDetails(Account account) {
        super(account.getEmail(), account.getPassword(), List.of(new SimpleGrantedAuthority("USER")));
        this.account = account;
    }
}

AccountDetailsService

@Service
@RequiredArgsConstructor
public class AccountDetailsService implements UserDetailsService {

    private final AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository
                .findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
        return new AccountDetails(account);
    }
}

JwtAuthenticationFilter 작성

위에서 언급했듯이 CustomFilter를 만들어 UsernamePasswordAuthenticationFilter보다 먼저 걸리도록 설정해야한다.

아래의 위치에 작성한다.
/springbootjwt/jwt/JwtAuthenticationFilter.java

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final AccountDetailsService accountDetailsService;

    public JwtAuthenticationFilter(JwtProvider jwtProvider, AccountDetailsService accountDetailsService) {
        this.jwtProvider = jwtProvider;
        this.accountDetailsService = accountDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader("Authorization");
        if (!Objects.isNull(authorization)) {
            String atk = authorization.substring(7);
            try {
                Subject subject = jwtProvider.getSubject(atk);
                UserDetails userDetails = accountDetailsService.loadUserByUsername(subject.getEmail());
                Authentication token = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(token);
            } catch (JwtException e) {
                request.setAttribute("exception", e.getMessage());
            }
        }
        filterChain.doFilter(request, response);
    }
}

JWT 예외 처리를 위한 CustomAuthenticationEntryPoint 작성

토큰 관련 예외에 대한 처리를 위해 CustomAuthenticationEntryPoint를 작성한다.

필터 단계에서 발생한 예외를 처리한다.

/springbootjwt/config/CustomAuthenticationEntryPoint.java

@Getter
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public CustomAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        String exceptionMessage = (String) request.getAttribute("exception");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        Message message = new Message(exceptionMessage, HttpStatus.UNAUTHORIZED);
        String res = this.convertObjectToJson(message);
        response.getWriter().print(res);
    }

    private String convertObjectToJson(Object object) throws JsonProcessingException {
        return object == null ? null : objectMapper.writeValueAsString(object);
    }
}

AppConfig 추가 작성

현재 AppConfig에서 SpringSecurity의 FilterChain에 대한 설정도 같이 하고있다.

다음과 같이 수정한다.

filterChain에서 다음의 부분들이 추가되었다.

  • exceptionHandling(), authenticationEntryPoint(customAuthenticationEntryPoint)
    • invalid한 token에 대한 예외 처리가 필요하다.
  • addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    • 위에서 작성한 customFilter를 UsernamePasswordAuthenticationFilter보다 앞에 설정하는 부분이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AppConfig {

    private final JwtProvider jwtProvider;
    private final AccountDetailsService accountDetailsService;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/account/sign-up", "/account/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, accountDetailsService),
                        UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

최종 테스트

최종 테스트를 위해 AccountController에 다음과 같은 임시 API를 작성하여 테스트 해본다.

@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {

    ...

    @GetMapping("/test")
    public String test() {
        return "good!";
    }
}
  1. 이전 포스팅에서 진행했던 로그인을 통해 AccessToken을 발급받는다.
  2. 발급받은 AccessToken을 Request Header에 Authorization속성으로 Bearer accessToken 형식으로 설정한다.
  3. GET http://localhost:8080/account/test 요청을 보내어 응답을 확인한다.

발급받은 AccessToken으로 요청

정상적으로 "good!"이라는 스트링 응답을 확인할 수 있다.

만료된 토큰 요청 테스트

우리는 토큰에 대한 예외 처리 또한 작성했다.
포스팅을 진행하면서 작성했던 토큰의 유효시간은 5분이었다.
따라서 5분 뒤에 똑같은 토큰으로 요청을 보내면 다음과 같이 예외 응답이 와야한다.


JWT를 통한 로그인 포스팅
1. 로그인과 JWT 발급
2. 발급받은 JWT로 요청하기
3. Redis를 통한 JWT Refresh Token 관리
4. Redis를 통한 로그아웃 이후, AccessToken 관리 (BlackList)