반응형

이번 시간에는 토큰 인증 방식을 수정해보겠습니다.

 

원래 로그인 기능은 12편에서 마무리 지었지만, 몇 가지 문제점이 있다는 사실은 인지하고 있었습니다.

대표적인 문제점을 살펴보고, 개선하는 작업을 거쳐보도록 하겠습니다.

 

2021.12.05 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (12) - 로그인 - 8 - 인증 및 인가 - 4(마무리)

위 과정까지 마무리했다면, 지금의 과정을 무리없이 소화할 수 있을 것입니다.

 

* 앞으로 프로젝트를 진행함에 있어서 이번 수정 사항이 필수는 아니며, 선택적으로 적용하시면 됩니다.

 

 

config.security.CustomUserDetailsService 입니다.

package kukekyakya.kukemarket.config.security;

import ...

@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    ...
    @Override
    public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        Member member = memberRepository.findWithRolesById(Long.valueOf(userId))...
        return new CustomUserDetails(
                ...,
                member.getRoles().stream().map(memberRole -> memberRole.getRole())...
        );
    }
}

기존에 작성된 CustomUserDetailsService.loadUserByUsername의 코드입니다.

파라미터로 전달 받은 사용자 id 값을 이용해서, 데이터베이스에서 Member를 조회하고 있습니다.

조회된 Member에서는 CustomUserDetails에 주입해주기 위한, 권한 목록들을 꺼내고 있습니다.

이러한 loadUserByUsername을 호출하는, 클라이언트 코드를 살펴보겠습니다.

 

 

config.security.JwtAuthenticationFilter입니다.

package kukekyakya.kukemarket.config.security;

import ...

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {
    ...
    private void setAuthentication(String token) {
        String userId = accessTokenHelper.extractSubject(token); // 1
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId); // 2
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()));
    }

}

1. 토큰에서 사용자의 id를 추출하고,

2. UserDetailsService.loadUserByUsername을 호출하고 있습니다.

 

생각해봅시다.

우리는 모든 요청에서 JwtAuthenticationFilter를 거치고, UserDetailsService.loadUserByUsername을 호출하게 됩니다.

모든 요청에서 데이터베이스에 접근하고 있는 것입니다.

하지만 데이터베이스에 접근하는 비용은 비쌉니다.

 

사실 이에 관한 문제점과 해결 방법은,

2021.12.02 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (9) - 로그인 - 6 - 인증 및 인가 - 2

에서도 언급했었습니다.

해당 내용은 다음과 같습니다.

 

***
- 토큰 인증 방식에서 각 요청마다 데이터베이스에 접근?
요청을 받을 때마다 사용자의 정보를 데이터베이스에서 조회하는 것은 비효율적인 작업입니다.
이를 방지하기 위해 토큰에다가 인증에 필요한 모든 정보를 미리 넣어둘 수도 있겠습니다.
인증에 필요한 정보가 토큰에 담겨있다면, 데이터베이스에 접근할 필요는 없어지게 됩니다.

하지만 사용자의 정보 또는 권한은 언제 변경될지 모릅니다.
액세스 토큰의 만료 기간이 짧다고 하더라도, 변경된 정보가 발급된 토큰으로 인해 사용자에게 즉시 반영되지 않는다면, 문제의 소지가 있습니다.
이를 방지하기 위해 우리의 코드에서는 데이터베이스에서 다시 조회하고 있는 것입니다.
이메일이나 닉네임처럼 unique 제약 조건에 의해 생성된 secondary index를 이용하여 조회하는 것이 아닌, 사용자의 primary key에 의해 생성된 clustering index(primary index)를 이용하여 데이터베이스에 접근하는 것이므로, 비교적 저렴한 비용으로 트랜잭션을 수행할 수 있을 것입니다.

물론, 데이터베이스에 접근하는 것은 여전히 큰 비용이기 때문에, 더 나은 해결책은 분명 필요합니다.
가장 좋은 방법은, 아예 데이터베이스에 접근하지 않는 것입니다.
토큰에 저장된 낡은 정보가 사용자에게 끼치는 영향이 없다면, 데이터베이스에 접근할 필요는 없을 것입니다.
영향이 있더라도 토큰에 담긴 정보가 업데이트되는 상황은 그렇게 많지 않을 것이기에, 차라리 토큰을 새로 발급해주는게 더 나을 것입니다.
혹여나 데이터베이스 접근이 필요하더라도, Redis와 같은 메모리 DB에 사용자 정보를 짧은 시간 캐시하는 등의 방법도 있겠습니다.

다음과 같은 상황도 살펴보겠습니다.
사용자 인증에 많은 정보가 필요하고, 이를 모두 토큰에 담아야한다고 가정해보겠습니다.
HTTP에서는 HTTP 헤더 크기에 제한을 두고 있지 않지만, 특정 웹 서버에서는 HTTP 헤더 크기에 제한을 두고 있습니다.
토큰이 과도하게 커진다면, HTTP 헤더를 이용한 토큰 전송이 아예 불가능할 수도 있는 것입니다.
이 뿐만 아니라, 거대한 토큰은 전송 과정에서 네트워크 대역폭을 낭비하게 됩니다.
이를 해결하기 위해 토큰을 압축하거나, 인증에 필요한 정보를 최소화하거나, HTTP Body로 토큰을 전송할 수도 있겠지만, 가장 손쉬운 해결책으로는 서버가 데이터베이스에서 인증 정보를 직접 꺼내오는 방식도 있을 것입니다.
네트워크는 복잡하고 위험하기 때문에, 오히려 데이터베이스 접근이 더욱 안전하고 효율적일지도 모릅니다.

정리하면, 토큰 인증 방식에서도 데이터베이스 접근이 필요한 상황과 해결책은 다음과 같을 것입니다.
1. 토큰에 저장된 낡은 정보가 사용자에게 즉시 반영되어야 하는 경우 : 앞서 언급했듯이, 토큰을 다시 내려주거나 짧은 캐시 또는 만료 시간을 가짐으로써 해결할 수 있을 것입니다.
2. 토큰에 너무 많은 정보가 담기는 경우 : HTTP에서는 헤더 크기에 대해 제한을 두지 않지만, 특정 웹 서버에서는 이를 제한하고 있고, 너무 커다란 토큰은 네트워크 대역폭을 낭비할 수도 있습니다. 이를 해결하려면 압축을 수행하거나, 인증에 필요한 정보를 최소화하거나, HTTP Body를 이용하거나, 데이터베이스를 이용하는 등의 방법이 있겠습니다.
여기서 언급된 데이터베이스라는 해결책은, 반드시 RDB 등에서의 디스크 접근이 아니라 메모리 DB를 이용할 수도 있을 것입니다.

우리의 코드는 데이터베이스에 접근하고 있습니다.
하지만 지금은 단순히 공부하는 입장이기에, 아이디어만 논의하고 넘어가도록 하겠습니다.
현재 구현된 방식은 반드시 개선되어야한다는 것을 언급하고 싶었습니다.
불필요한 데이터베이스 접근은 토큰 인증 방식의 장점을 깨트리게 될 것입니다.

* 작성된 내용은 개인적인 의견입니다.
***

 

기존의 CustomUserDetailsService.loadUserByUsername을 작성하던 시점에는, 위 내용을 언급하며 해당 코드의 경각심을 인지하고 있었습니다. 

 

우리가 진행하고 있는 프로젝트에서는, 네트워크 대역폭을 낭비할 만큼 커다란 토큰을 생성할 이유는 없습니다.

또한, 토큰에 담긴 정보가 수정되는 API도 없습니다. 토큰에 낡은 정보가 저장되는 상황이 없는 것입니다.

그러한 상황이 생긴다고 하더라도, 단순히 토큰을 다시 생성해주는 것이 오히려 이득일 것입니다.

 

이러한 이유로, 뒤늦게라도 토큰 인증 방식을 개선하고자 마음 먹게 되었습니다.

 

 

handler.JwtHandler를 다음과 같이 수정해주겠습니다.

package kukekyakya.kukemarket.handler;

import ...

@Component
public class JwtHandler {
    private String type = "Bearer ";

    // 1
    public String createToken(String key, Map<String, Object> privateClaims, long maxAgeSeconds) {
        Date now = new Date();
        return type + Jwts.builder()
                .addClaims(privateClaims)
                .addClaims(Map.of(Claims.ISSUED_AT, now, Claims.EXPIRATION, new Date(now.getTime() + maxAgeSeconds * 1000L)))
                .signWith(SignatureAlgorithm.HS256, key.getBytes())
                .compact();
    }

    public Optional<Claims> parse(String key, String token) { // 2
        try {
            return Optional.of(Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(untype(token)).getBody());
        } catch (JwtException e) {
            return Optional.empty();
        }
    }

    private String untype(String token) {
        return token.substring(type.length());
    }
}

1. 이제 subject대신, 비공개 클레임을 전달받아서 토큰을 생성해주도록 하겠습니다.

2. validate 메소드로 하던 검증 작업은, parse 메소드에 통합하였습니다. 유효하지않은 토큰이라면, 비어있는 Optional을 반환해주도록 하겠습니다.

 

* 비공개 클레임 : 직접 정의하여 사용하는 클레임

 

 

***

위 JwtHandler 코드에 문제가 있는 점을 뒤늦게 발견하여 기록만 남겨둡니다.

public String create(String key, Map<String, Object> privateClaims, long maxAgeSeconds) {
    Date now = new Date();
    return TYPE + Jwts.builder()
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + maxAgeSeconds * 1000L))
            .addClaims(privateClaims)
            .signWith(SignatureAlgorithm.HS256, key.getBytes())
            .compact();
}

위와 같은 식으로 작성해야 만료 시간이 동작하게 됩니다.

해당 메소드에 대한 만료 테스트도 잘못 작성되어 있으니, 확인하시고 알맞게 수정하시면 됩니다.

***

 

또한, 인코딩된 key를 입력받던 방식에서, 바이트 배열을 SigningKey로 사용하도록 수정해주었습니다.

# application-secret.yml
jwt:
  key:
    access: accesskukemarket
    refresh: refreshkukemarket
  max-age:
    access: 1800 # 60 * 30
    refresh: 604800 # 60 * 60 * 24 * 7

이제 굳이 key를 인코딩하여 등록해줄 필요는 없습니다.

 

 

JwtHandler를 의존하는 config.token.TokenHelper를 살펴보겠습니다.

package kukekyakya.kukemarket.config.token;

import ...

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

    private static final String SEP = ",";
    private static final String ROLE_TYPES = "ROLE_TYPES";
    private static final String MEMBER_ID = "MEMBER_ID";

    public String createToken(PrivateClaims privateClaims) { // 1
        return jwtHandler.createToken(
                key,
                Map.of(MEMBER_ID, privateClaims.getMemberId(), ROLE_TYPES, privateClaims.getRoleTypes().stream().collect(joining(SEP))),
                maxAgeSeconds
        );
    }

    public Optional<PrivateClaims> parse(String token) { // 2
        return jwtHandler.parse(key, token).map(this::convert);
    }

    private PrivateClaims convert(Claims claims) {
        return new PrivateClaims(
                claims.get(MEMBER_ID, String.class),
                Arrays.asList(claims.get(ROLE_TYPES, String.class).split(SEP))
        );
    }

    @Getter
    @AllArgsConstructor
    public static class PrivateClaims { // 3
        private String memberId;
        private List<String> roleTypes;
    }
}

1. 토큰을 생성할 때, PrivateClaims를 인자로 전달받겠습니다. 범용적으로 사용될 수 있던 JwtHandler와는 달리, 우리의 인증 방식에서 필요한 정보만 PrivateClaims으로 전달받을 수 있도록 하겠습니다. 여러 개의 권한은 하나의 스트링으로 저장됩니다.

2. JwtHandler와 마찬가지로, 유효하지 않은 토큰이라면 비어있는 Optional을 반환할 것입니다.

3. 토큰에 저장될 비공개 클레임입니다. 사용자 id와 권한 정보만 입력해주도록 하겠습니다.

 

 

TokenHelper를 의존하는 CustomUserDetailsService를 수정해주겠습니다.

package kukekyakya.kukemarket.config.security;

import ...

@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final TokenHelper accessTokenHelper;

    @Override
    public CustomUserDetails loadUserByUsername(String token) throws UsernameNotFoundException {
        return accessTokenHelper.parse(token)
                .map(this::convert)
                .orElse(null);
    }

    private CustomUserDetails convert(TokenHelper.PrivateClaims privateClaims) {
        return new CustomUserDetails(
                privateClaims.getMemberId(),
                privateClaims.getRoleTypes().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet())
        );
    }
}

 

더 이상 데이터베이스에 접근할 필요는 없습니다.

단순히 전달받은 토큰에서 인증에 필요한 정보를 추출하고, CustomUserDetails를 만들어주면 됩니다.

유효하지않은 토큰이라면 null을 반환해주겠습니다.

 

 

TokenHelper를 의존하는 JwtAuthenticationFilter를 수정해주겠습니다.

package kukekyakya.kukemarket.config.security;

import ...

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        extractToken(request).map(userDetailsService::loadUserByUsername).ifPresent(this::setAuthentication);
        chain.doFilter(request, response);
    }

    private void setAuthentication(CustomUserDetails userDetails) {
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()));
    }

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

코드가 한결 간결해졌습니다. 

요청에서 토큰을 추출하고, 토큰이 있다면 CustomUserDetails를 반환해주고, 이를 SecurityContext에 등록해줍니다.

Optional을 이용한 메소드 체인으로 작성해주었습니다.

 

 

JwtAuthenticationFilter를 필터로 등록해주던 SecurityConfig 코드도 알맞게 수정해주겠습니다. 

package kukekyakya.kukemarket.config.security;

import ...

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomUserDetailsService userDetailsService;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                ...
                .and()
                    .addFilterBefore(new JwtAuthenticationFilter(userDetailsService), UsernamePasswordAuthenticationFilter.class);
    }

    ...
}

JwtAuthenticationFilter는 CustomUserDetailsService만 주입받으면 됩니다.

 

 

토큰 인증 방식에 관한 모든 수정이 끝났습니다.

서비스 코드와 테스트 코드도 알맞게 다시 작성해주겠습니다.

 

 

기존에 TokenHelper를 의존하던 SignService입니다.

package kukekyakya.kukemarket.service.sign;

import ...

@Service
@RequiredArgsConstructor
public class SignService {
    ...

    @Transactional(readOnly = true)
    public SignInResponse signIn(SignInRequest req) {
        Member member = memberRepository.findWithRolesByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
        validatePassword(req, member);
        TokenHelper.PrivateClaims privateClaims = createPrivateClaims(member);
        String accessToken = accessTokenHelper.createToken(privateClaims);
        String refreshToken = refreshTokenHelper.createToken(privateClaims);
        return new SignInResponse(accessToken, refreshToken);
    }

    public RefreshTokenResponse refreshToken(String rToken) {
        TokenHelper.PrivateClaims privateClaims = refreshTokenHelper.parse(rToken).orElseThrow(RefreshTokenFailureException::new);
        String accessToken = accessTokenHelper.createToken(privateClaims);
        return new RefreshTokenResponse(accessToken);
    }

    ...

    private TokenHelper.PrivateClaims createPrivateClaims(Member member) {
        return new TokenHelper.PrivateClaims(
                String.valueOf(member.getId()),
                member.getRoles().stream()
                        .map(memberRole -> memberRole.getRole())
                        .map(role -> role.getRoleType())
                        .map(roleType -> roleType.toString())
                        .collect(Collectors.toList()));
    }
}

자세한 설명은 생략하겠습니다.

 

새롭게 작성된 RefreshTokenFailureException을 ExceptionAdvice에 등록해주고, exception.properties에 예외 코드와 메시지를 지정하는 작업도 생략하겠습니다.

 

 

SignService.signIn에서 새롭게 작성된 MemberRepository.findWithRolesByEmail은 다음과 같습니다.

package kukekyakya.kukemarket.repository.member;

import ...

public interface MemberRepository extends JpaRepository<Member, Long> {
    ...
    @EntityGraph("Member.roles")
    Optional<Member> findWithRolesByEmail(String email);
}

2021.12.08 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (19) - Entity Graph로 LAZY 전략에서 N + 1 문제 해결

위 글에서 작성했던 엔티티 그래프를 이용하여, Role도 함께 조회해주도록 합시다.

 

 

간단한 테스트도 바로 작성해주겠습니다.

// MemberRepositoryTest.java
@Test
void findWithRolesByEmailTest() {
    // given
    List<RoleType> roleTypes = List.of(RoleType.ROLE_NORMAL, RoleType.ROLE_SPECIAL_BUYER, RoleType.ROLE_ADMIN);
    List<Role> roles = roleTypes.stream().map(roleType -> new Role(roleType)).collect(toList());
    roleRepository.saveAll(roles);
    Member member = memberRepository.save(createMemberWithRoles(roleRepository.findAll()));
    clear();

    // when
    Member foundMember = memberRepository.findWithRolesByEmail(member.getEmail()).orElseThrow(MemberNotFoundException::new);

    // then
    List<RoleType> result = foundMember.getRoles().stream().map(memberRole -> memberRole.getRole().getRoleType()).collect(toList());
    assertThat(result.size()).isEqualTo(roleTypes.size());
    assertThat(result).contains(roleTypes.get(0), roleTypes.get(1), roleTypes.get(2));
}

하나의 select 쿼리가 생성되는지 로그를 확인해봅시다.

 

 

JwtHandlerTest, TokenHelperTest, SignServiceTest에 작성된 기존의 테스트들도 알맞게 수정해주도록 하겠습니다.

package kukekyakya.kukemarket.handler;

import ...

class JwtHandlerTest {
    JwtHandler jwtHandler = new JwtHandler();

    @Test
    void createTokenTest() {
        // given, when
        String key = "myKey";
        String token = createToken(key, Map.of(), 60L);

        // then
        assertThat(token).contains("Bearer ");
    }

    @Test
    void parseTest() {
        // given
        String key = "key";
        String value = "value";
        String token = createToken(key, Map.of(key, value), 60L);

        // when
        Claims claims = jwtHandler.parse(key, token).orElseThrow(RuntimeException::new);

        // then
        assertThat(claims.get(key)).isEqualTo(value);
    }

    @Test
    void parseByInvalidKeyTest() {
        // given
        String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
        String token = createToken(encodedKey, Map.of(), 60L);

        // when
        Optional<Claims> claims = jwtHandler.parse("invalidKey", token);

        // then
        assertThat(claims).isEmpty();
    }

    @Test
    void parseByExpiredTokenTest() {
        // given
        String encodedKey = Base64.getEncoder().encodeToString("myKey".getBytes());
        String token = createToken(encodedKey, Map.of(),0L);

        // when
        Optional<Claims> claims = jwtHandler.parse("invalidKey", token);

        // then
        assertThat(claims).isEmpty();
    }

    private String createToken(String encodedKey, Map<String, Object> claims, long maxAgeSeconds) {
        return jwtHandler.createToken(
                encodedKey,
                claims,
                maxAgeSeconds);
    }
}
package kukekyakya.kukemarket.config.token;

import ...

class TokenHelperTest {
    TokenHelper tokenHelper;

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

    @Test
    void createTokenAndParseTest() {
        // given
        String memberId = "1";
        List<String> roleTypes = List.of("NORMAL", "ADMIN");
        TokenHelper.PrivateClaims privateClaims = new TokenHelper.PrivateClaims(memberId, roleTypes);

        // when
        String token = tokenHelper.createToken(privateClaims);

        // then
        TokenHelper.PrivateClaims parsedPrivateClaims = tokenHelper.parse(token).orElseThrow(RuntimeException::new);
        assertThat(parsedPrivateClaims.getMemberId()).isEqualTo(memberId);
        assertThat(parsedPrivateClaims.getRoleTypes()).contains(roleTypes.get(0), roleTypes.get(1));
    }
}
package kukekyakya.kukemarket.service.sign;

import ...

@ExtendWith(MockitoExtension.class)
public class SignServiceTest {

    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);
    }

    @Test
    void signUpTest() {
        // given
        SignUpRequest req = createSignUpRequest();
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.of(new Role(RoleType.ROLE_NORMAL)));

        // when
        signService.signUp(req);

        // then
        verify(passwordEncoder).encode(req.getPassword());
        verify(memberRepository).save(any());
    }

    @Test
    void validateSignUpByDuplicateEmailTest() {
        // given
        given(memberRepository.existsByEmail(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(MemberEmailAlreadyExistsException.class);
    }

    @Test
    void validateSignUpByDuplicateNicknameTest() {
        // given
        given(memberRepository.existsByNickname(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(MemberNicknameAlreadyExistsException.class);
    }

    @Test
    void signUpRoleNotFoundTest() {
        // given
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(RoleNotFoundException.class);
    }

    @Test
    void signInTest() {
        // given
        given(memberRepository.findWithRolesByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(true);
        given(accessTokenHelper.createToken(any())).willReturn("access");
        given(refreshTokenHelper.createToken(any())).willReturn("refresh");

        // when
        SignInResponse res = signService.signIn(createSignInRequest("email", "password"));

        // then
        assertThat(res.getAccessToken()).isEqualTo("access");
        assertThat(res.getRefreshToken()).isEqualTo("refresh");
    }

    @Test
    void signInExceptionByNoneMemberTest() {
        // given
        given(memberRepository.findWithRolesByEmail(any())).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signIn(createSignInRequest("email", "password")))
                .isInstanceOf(LoginFailureException.class);
    }

    @Test
    void signInExceptionByInvalidPasswordTest() {
        // given
        given(memberRepository.findWithRolesByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(false);

        // when, then
        assertThatThrownBy(() -> signService.signIn(createSignInRequest("email", "password")))
                .isInstanceOf(LoginFailureException.class);
    }

    @Test
    void refreshTokenTest() {
        // given
        String refreshToken = "refreshToken";
        String subject = "subject";
        String accessToken = "accessToken";
        given(refreshTokenHelper.parse(refreshToken)).willReturn(Optional.of(new TokenHelper.PrivateClaims("memberId", List.of("ROLE_NORMAL"))));
        given(accessTokenHelper.createToken(any())).willReturn(accessToken);

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

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

    @Test
    void refreshTokenExceptionByInvalidTokenTest() {
        // given
        String refreshToken = "refreshToken";
        given(refreshTokenHelper.parse(refreshToken)).willReturn(Optional.empty());

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

자세한 설명은 생략하겠습니다.

 

 

모든 테스트 통과

다른 이상은 없는지, 테스트를 모두 수행해봅시다.

 

 

이번 시간에는 토큰 인증 방식을 대폭 수정하였습니다.

이제 토큰에는 권한 정보도 함께 가지고 있고, 각 요청마다 데이터베이스에 접근할 필요는 없어졌습니다.

 

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts