spring/게시판 api

Spring boot 게시판 API 서버 제작 (11) - 로그인 - Token 코드 리팩토링

얼킴 2023. 2. 23. 11:06

이번에는 Token 관련 코드에 대해서 리팩토링을 진행해 보겠습니다.

문제

현재 프로젝트에서는 JwtAuthenticationFilter 클래스와 SignService 클래스에서 Token Service를 이용하여 token 생성, 추출, 검증 기능을 사용하고 있습니다. 그런데 TokenService 클래스를 살펴보면

/* access */
public String createAccessToken(String subject) {
    return jwtHandler.generateJwtToken(accessKey, subject, accessTokenMaxAgeSeconds);
}
public boolean validateAccessToken(String token) {
    return jwtHandler.validate(accessKey, token);
}

public String extractAccessTokenSubject(String token) {
    return jwtHandler.extractSubject(accessKey, token);
}

/* refresh */
public String createRefreshToken(String subject) {
    return jwtHandler.generateJwtToken(refreshKey, subject, refreshTokenMaxAgeSeconds);
}
public boolean validateRefreshToken(String token) {
    return jwtHandler.validate(refreshKey, token);
}

public String extractRefreshTokenSubject(String token) {
    return jwtHandler.extractSubject(refreshKey, token);
}

생성, 검증, 추츨 메서드가 동일한 기능을 하지만 access와 refresh별로 따로 만들어져 있습니다. 그래서 3개의 메서드로 해결 할 수 있는 기능들을 6개의 메서드를 생성한 셈입니다.

 

Token 처리 리팩토링

TokenService는 SignService와 JwtHandler간에 중간 다리 역할을 했습니다. SignService를 대신하여 토큰에 대한 정보를 주입 받고, JwtHandler를 통해서 access, refresh 토큰을 제어 하였습니다.

SignService는 동일한 타입이지만 서로 다른 설정 정보를 가지고 있는 필드를 주입 받습니다. (access, refresh) 

그렇게 해서 각각의 필드는 동일한 타입이기 때문에 설정 정보가 달라도 코드를 추가 할 필요가 없습니다. 

JwtAuthenticationFilter에서도 SignService 에서 주입받은 필드와 동일한 타입의 필드를 주입받습니다.

SignService

public class SignService {
    private final MemberRepository memberRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;
    //private final TokenService tokenService;
    private final TokenHelper accessTokenHelper;
    private final TokenHelper refreshTokenHelper;

    @Transactional
    public void signUp(SignUpRequest req){
        validateSignUpInfo(req);
        memberRepository.save(SignUpRequest.toEntity(
                req,
                roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
                passwordEncoder
        ));
    }
    private void validateSignUpInfo(SignUpRequest req) {
        if(memberRepository.existsByEmail(req.getEmail()))
            throw new MemberEmailAlreadyExistsException(req.getEmail());
        if(memberRepository.existsByNickname(req.getNickname()))
            throw new MemberNicknameAlreadyExistsException(req.getNickname());
    }

    @Transactional(readOnly = true)
    public SignInResponse signIn(SignInRequest req){
        Member member = memberRepository.findByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
        validatePassword(req, member);
        String subject = createSubject(member);
        //String accessToken = tokenService.createAccessToken(subject);
        //String refreshToken = tokenService.createRefreshToken(subject);
        String accessToken = accessTokenHelper.createToken(subject);
        String refreshToken = refreshTokenHelper.createToken(subject);
        return new SignInResponse(accessToken, refreshToken);
    }

    private void validatePassword(SignInRequest req, Member member) {
        if(!passwordEncoder.matches(req.getPassword(), member.getPassword())) {
            throw new LoginFailureException();
        }
    }

    public RefreshTokenResponse refreshToken(String rToken) {
        validateRefreshToken(rToken);
        //String subject = tokenService.extractRefreshTokenSubject(rToken);
        //String accessToken = tokenService.createAccessToken(subject);
        String subject = refreshTokenHelper.extractSubject(rToken);
        String accessToken = accessTokenHelper.createToken(subject);
        return new RefreshTokenResponse(accessToken);
    }

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

    private String createSubject(Member member) {
        return String.valueOf(member.getId());
    }

}

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    //private final TokenService tokenService;
    private final TokenHelper accessTokenHelper;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = extractToken(request);
        if (validateToken(token)) {
            setAuthentication(token);
        }
        chain.doFilter(request, response);
    }

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

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

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

TokenConfig

이제 TokenService대신 TokenConfig 클래스를 생성하여 같은 역할을 수행하도록 합니다. 달라진 것은 중복되는 코드를 추가하지 않도록 하는 것입니다.

@Configuration
@RequiredArgsConstructor
public class TokenConfig {
    private final JwtHandler jwtHandler;

    @Bean
    public TokenHelper accessTokenHelper(
            @Value("${jwt.key.access}") String key,
            @Value("${jwt.max-age.access}") long maxAgeSeconds){
        return new TokenHelper(jwtHandler, key, maxAgeSeconds);
    };

    @Bean
    public TokenHelper refreshTokenHelper(
            @Value("${jwt.key.refresh}") String key,
            @Value("${jwt.max-age.refresh}") long maxAgeSeconds) {
        return new TokenHelper(jwtHandler, key, maxAgeSeconds);
    }
}

TokenHelper

@RequiredArgsConstructor
public class TokenHelper {
    private final JwtHandler jwtHandler;
    private final String key;
    private final long maxAgeSeconds;

    public String createToken(String subject) {
        return jwtHandler.generateJwtToken(key, subject, maxAgeSeconds);
    }

    public boolean validate(String token) {
        return jwtHandler.validate(key, token);
    }

    public String extractSubject(String token) {
        return jwtHandler.extractSubject(key, token);
    }
}

SecurityConfig

@Override
    protected void configure(HttpSecurity http) throws Exception {
            http
                .httpBasic().disable() // HTTP Basic 인증을 사용하지
                .formLogin().disable() // Form 로그인을 사용하지 않음
                .csrf().disable() // CSRF 공격으로부터 보호하지 않음
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않음
                .and()
                    .authorizeRequests() // 요청에 대한 인가를 구성
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()// 회원가입과 로그인 POST 요청은 인증 없이 허용
                        .antMatchers(HttpMethod.GET, "/api/**").permitAll() // /api/** Get 요청은 인증 없이 허용
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)") //회원 삭제 요청은 MemberGuard.check 검사를 통해 권한이 있는 사용자만 허용
                        .anyRequest().hasAnyRole("ADMIN")
                .and()
                    .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler()) // 엑세스 거부 예외
                .and()
                    .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                    .addFilterBefore( // JwtAuthenticationFilter 를 UsernamePasswordAuthenticationFilter 앞에 등록
                            //new JwtAuthenticationFilter(tokenService, userDetailsService) //JWT 토큰 기반 인증
                            new JwtAuthenticationFilter(accessTokenHelper, userDetailsService) //JWT 토큰 기반 인증
                            , UsernamePasswordAuthenticationFilter.class
                    );
    }

SignService 테스트 수정

TokenService 대신 TokenConfig를 사용하도록 SignService 테스트 수정을 하겠습니다.

@ExtendWith(MockitoExtension.class)
class SignServiceTest {

    //@InjectMocks SignService signService;
    SignService signService;
    @Mock MemberRepository memberRepository;
    @Mock RoleRepository roleRepository;
    @Mock PasswordEncoder passwordEncoder;
    @Mock TokenHelper accessTokenHelper;
    @Mock TokenHelper refreshTokenHelper;

    @BeforeEach 
    void beforeEach() {
        signService = new SignService(memberRepository, roleRepository, passwordEncoder, accessTokenHelper, refreshTokenHelper);
    }

 

TokenHelper 테스트 

더보기
@ExtendWith(MockitoExtension.class)
class TokenHelperTest {
    TokenHelper tokenHelper;
    @Mock
    JwtHandler jwtHandler;

    @BeforeEach
    void beforeEach() {
        tokenHelper = new TokenHelper(jwtHandler, "key", 1000L);
    }

    @Test
    void createTokenTest() {
        // given
        given(jwtHandler.generateJwtToken(anyString(), anyString(), anyLong())).willReturn("token");

        // when
        String createdToken = tokenHelper.createToken("subject");

        // then
        assertThat(createdToken).isEqualTo("token");
        verify(jwtHandler).generateJwtToken(anyString(), anyString(), anyLong());
    }

    @Test
    void validateTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(true);

        // when
        boolean result = tokenHelper.validate("token");

        // then
        assertThat(result).isTrue();
    }

    @Test
    void invalidateTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(false);

        // when
        boolean result = tokenHelper.validate("token");

        // then
        assertThat(result).isFalse();
    }

    @Test
    void extractSubjectTest() {
        // given
        given(jwtHandler.extractSubject(anyString(), anyString())).willReturn("subject");

        // when
        String subject = tokenHelper.extractSubject("token");

        // then
        assertThat(subject).isEqualTo(subject);
    }


}

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

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