spring/게시판 api

Spring boot 게시판 API 서버 제작 (11) - 로그인 - 인증 refresh token으로 access token발급

얼킴 2023. 2. 22. 21:32

이번에는 refresh token을 가지고 access token을 발급해 보겠습니다. 

 

SignService 추가 

SignService.java

먼저 SignService 클래스에 refresh token 값을 인자로 받았을 때 access token을 반환해주는 로직을 추가하겠습니다.

    public RefreshTokenResponse refreshToken(String rToken) {
        validateRefreshToken(rToken); // refresh token 검증
        String subject = tokenService.extractRefreshTokenSubject(rToken); // subject 추출
        String accessToken = tokenService.createAccessToken(subject); //subject를 가지고 access token 생성
        return new RefreshTokenResponse(accessToken);
    }

    private void validateRefreshToken(String rToken) {
        if(!tokenService.validateRefreshToken(rToken)) {
            throw new AuthenticationEntryPointException();
        }
    }

순서는 다음과 같습니다.

  1. refresh token 검증
  2. refresh token으로부터 subject 추출
  3. subject를 가지고 access token 생성

RefreshTokenResponse.java

반환을 위해 dto 페키지에 RefreshTokenResponse.java를 생성하겠습니다.

@Data
@AllArgsConstructor
public class RefreshTokenResponse {
    private String accessToken;
}

 

SignService 테스트

정상적인 케이스 & 비정상적인 케이스

더보기
@Test
    void refreshTokenTest() {
        // given
        String refreshToken = "refreshToken";
        String subject = "subject";
        String accessToken = "accessToken";
        given(tokenService.validateRefreshToken(refreshToken)).willReturn(true);
        given(tokenService.extractRefreshTokenSubject(refreshToken)).willReturn(subject);
        given(tokenService.createAccessToken(subject)).willReturn(accessToken);

        // when
        RefreshTokenResponse res = signService.refreshToken(refreshToken);

        // then
        assertThat(res.getAccessToken()).isEqualTo(accessToken);
    }

    @Test
    void refreshTokenExceptionByInvalidTokenTest() {
        // given
        String refreshToken = "refreshToken";
        given(tokenService.validateRefreshToken(refreshToken)).willReturn(false);

        // when, then
        assertThatThrownBy(() -> signService.refreshToken(refreshToken))
                .isInstanceOf(AuthenticationEntryPointException.class);
    }

SignController 추가

이제 API요청을 처리할 수 있는 메서드를 SignController에 추가하겠습니다.

@PostMapping("/api/refresh-token")
    @ResponseStatus(HttpStatus.OK)
    //@RequestHeader : 헤더에 포함된 Authorization 토큰 값 추출
    public Response refreshToken(@RequestHeader(value = "Authorization") String refreshToken) {
        return success(signService.refreshToken(refreshToken));
    }

Securityconfig에 /api/refresh-token 요청 인가 추가

@Override
    protected void configure(HttpSecurity http) throws Exception {
            http
                .httpBasic().disable() 
                .formLogin().disable()
                .csrf().disable() 
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 
                .and()
                    .authorizeRequests() 
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up").permitAll()
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll() // /api/refresh-token 추가
                        .antMatchers(HttpMethod.GET, "/api/**").permitAll() 
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)") 
                        .anyRequest().hasAnyRole("ADMIN")
                .and()
                    .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler()) 
                .and()
                    .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                    .addFilterBefore( 
                            new JwtAuthenticationFilter(tokenService, userDetailsService) 
                            , UsernamePasswordAuthenticationFilter.class
                    );
    }

/api/refresh-token 테스트

더보기
@Test
    void refreshTokenTest() throws Exception {
        // given
        given(signService.refreshToken("refreshToken")).willReturn(createRefreshTokenResponse("accessToken"));

        // when, then
        mockMvc.perform(
                        post("/api/refresh-token")
                                .header("Authorization", "refreshToken"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.data.accessToken").value("accessToken"));
    }

SignControllerAdvice 테스트

더보기
@Test
    void refreshTokenAuthenticationEntryPointException() throws Exception { // 1
        // given
        given(signService.refreshToken(anyString())).willThrow(AuthenticationEntryPointException.class);

        // when, then
        mockMvc.perform(
                        post("/api/refresh-token")
                                .header("Authorization", "refreshToken"))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.code").value(-1001));
    }

    @Test
    void refreshTokenMissingRequestHeaderException() throws Exception { // 2
        // given, when, then
        mockMvc.perform(
                        post("/api/refresh-token"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(-1009));
    }

Spring Security 

이제 POST /api/refresth-token 요청에 access token을 생성했습니다. 이전과 달리 이제는 access token과 refresh token 모두 접근할 수 있습니다. 이에 따른 Spring Security 검증 로직을 수정하겠습니다.

JwtAuthenticationFilter.java

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final TokenService tokenService;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = extractToken(request); //JWT 토큰 값 추출
        if (validateToken(token)) { // 유효성 검사
            setAuthentication(token); // 사용자 정보 저장
        }
        chain.doFilter(request, response); // 요청을 다음 filter로 전달
    }

    private String extractToken(ServletRequest request) {
        return ((HttpServletRequest) request).getHeader("Authorization");
    }

    private boolean validateToken(String token) {
        return token != null && tokenService.validateAccessToken(token);
    }

    private void setAuthentication(String token) {
        String userId = tokenService.extractAccessTokenSubject(token);
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()));
    }
}

CustomAuthenticationToken.java

JwtAuthenticationFilter 클래스의 setAuthentication 메서드에서 인증 정보를 저장하는 객체인 CustomAuthenticationToken을 생성해보겠습니다.

public class CustomAuthenticationToken extends AbstractAuthenticationToken {
    private CustomUserDetails principal;

    public CustomAuthenticationToken(CustomUserDetails principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal; //사용자 정보
        setAuthenticated(true);
    }

    @Override
    public CustomUserDetails getPrincipal() {
        return principal;
    }

    @Override
    public Object getCredentials() {
        throw new UnsupportedOperationException();
    }
}

AuthHelper.java

토큰에 따른 사용자 인증이 불필요하여 삭제합니다.

@Component
@Slf4j
public class AuthHelper {

    public boolean isAuthenticated() {
        return getAuthentication() instanceof CustomAuthenticationToken &&
                getAuthentication().isAuthenticated();
    }

    public Long extractMemberId() {
        return Long.valueOf(getUserDetails().getUserId());
    }

    public Set<RoleType> extractMemberRoles() {
        return getUserDetails().getAuthorities()
                .stream()
                .map(authority -> authority.getAuthority())
                .map(strAuth -> RoleType.valueOf(strAuth))
                .collect(Collectors.toSet());
    }

    //public boolean isAccessTokenType() {
    //    log.info("isAccessTokenType");
    //    return "access".equals(((CustomAuthenticationToken) getAuthentication()).getType());
    //}

    //public boolean isRefreshTokenType() {
    //    return "refresh".equals(((CustomAuthenticationToken) getAuthentication()).getType());
    //}

    private CustomUserDetails getUserDetails() {
        return (CustomUserDetails) getAuthentication().getPrincipal();
    }

    private Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

MemberGuard.java

MemberGuard에서도 토큰 타입에 따른 검증을 삭제하겠습니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class MemberGuard {

    private final AuthHelper authHelper;

    public boolean check(Long id) {
        //return authHelper.isAuthenticated()
        //        && authHelper.isAccessTokenType()
        //        && hasAuthority(id);
        return authHelper.isAuthenticated() && hasAuthority(id);
    }

    private boolean hasAuthority(Long id) {
        Long memberId = authHelper.extractMemberId();
        Set<RoleType> memberRoles = authHelper.extractMemberRoles();
        return id.equals(memberId) || memberRoles.contains(RoleType.ROLE_ADMIN);
    }
}

테스트 수정

Spring Security 로직 수정을 하고나면 테스트 하나가 실패합니다. 

@Test
void deleteAccessDeniedByRefreshTokenTest() throws Exception {
    // given
    Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
    SignInResponse signInRes = signService.signIn(createSignInRequest(initDB.getMember1Email(), initDB.getPassword()));

    // when, then
    mockMvc.perform(
                    delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getRefreshToken()))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/exception/entry-point"));
}

기존에는 accesstoken으로만 요청을 처리할 수 있다고 테스트를 작성하였기에 refresh token 으로 delete 처리를 할 때 기대값을 CustomAccessDeniedHandler를 설정했습니다. 하지만 수정된 로직으로 인해 더이상 인증되지 않기 때문에 기대 값을 CustomAuthenticationEntryPointHandler로 수정 하였습니다.

 

API 확인

이번에는 Postman에서 API 요청을 해서 로직이 정상적으로 작동하는지 확인 해 보겠습니다.

POST /api/sign-in

POST /api/refresh-token 토큰 값 없을 때

POST /api/refresh-token 토큰 값 있을 때

 


궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.

github : https://github.com/jaeyeon423/spring_board_api