[SpringBoot] 스프링부트 환경에서 JWT 연동

애이용·2021년 6월 17일
1

springboot

목록 보기
14/20
post-thumbnail

동아리에서 프로젝트를 진행하면서 처음 Refresh Token까지 구현해보았다.
간단한 프로젝트를 하나 생성해서 과정을 써보도록 하겠다.
(스프링 시큐리티는 사용하지 않았다.)

회원 도메인과 레포지토리 만들기

User 클래스

간단하게 id, email, password 멤버 변수로만 이루어진 회원 도메인이다.
(따로 password를 이용하진 않는다.)

@Getter @Setter
@Builder
public class User {
    private Long id;
    private String email;
    private String password;
}

@Setter 어노테이션은 데이터베이스 연동없이 구현체로 가벼운 메모리 기반 데이터 저장소를 사용했기 때문에 추가했다.


User 레포지토리

UserRepository 인터페이스를 구현한 UserRepositoryImpl 클래스이다.
여기서는 회원을 저장하는 메서드, id로 회원을 찾는 메서드를 구현했다.

// 회원 레포지토리 메모리 구현체
@Repository
public class UserRepositoryImpl implements UserRepository {

    private static final Map<Long, User> store = new HashMap<>();
    private static Long sequence = 0L;

    @Override
    public void save(User user) {
        user.setId(++sequence); // @Setter 이유
        store.put(user.getId(), user);
        return user;
    }

    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public void clearStore() {
        store.clear();
    }
    
}

회원 서비스 개발

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;

    private final JwtService jwtService;

    // 회원가입
    public TokenDto signUp(User user) throws JsonProcessingException {
        userRepository.save(user);
        return jwtService.createTokenResponse(user.getId());
    }

    // id로 회원 조회
    public Optional<User> findById(Long id) {
        return userRepository.findById(id);
    }

    // 액세스 토큰으로 회원 인증
    public Long authAccessToken(String accessToken) throws JsonProcessingException {
        return jwtService.getPayload(accessToken);
    }
    
}

따로 로그인 기능은 구현하지 않고, 회원가입을 할 때 액세스, 리프레시 토큰을 모두 발급하도록 하였다.

JWT 서비스 개발

본격적으로 JWT 서비스 클래스를 만들자
실제 토큰을 생성하는 서비스(JwtIssueService)와 전체적으로 담당하는 서비스(JwtService)를 생성해서
JwtService에 JwtIssueService를 주입하였다.

✔️ 액세스 토큰 구현

JwtIssueService

@RequiredArgsConstructore
@Service
public class JwtIssueService {

    private final ObjectMapper objectMapper;

    @Value("${test.key}")
    private String SECRET_KEY;

    public String issueToken(Long userId, Long validTime) throws JsonProcessingException {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        Date expireTime = new Date();
        expireTime.setTime(expireTime.getTime() + validTime);
        byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);

        Map<String, Object> headerMap = new HashMap<>();

        headerMap.put("typ", "JWT");
        headerMap.put("alg", "HS256");

        Key signingKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());

        return Jwts.builder()
                .setHeader(headerMap)
                .setSubject(objectMapper.writeValueAsString(userId))
                .setExpiration(expireTime)
                .signWith(signingKey, signatureAlgorithm)
                .compact();
    }

    public String issueAccessToken(Long userId) throws JsonProcessingException {
        Long accessTokenValidTime = 30 * 60 * 1000L; // 30분
        return issueToken(userId, accessTokenValidTime);
    }

    public String issueRefreshToken(Long userId) throws JsonProcessingException { 
        Long refreshTokenValidTime = 60 * 60 * 24 * 30 * 1000L; // 30일
        return issueToken(userId, refreshTokenValidTime);
    }

}

RefreshToken은 밑에서 추가로 구현할 것이다.

JwtService

@RequiredArgsConstructor
@Service
public class JwtService {

    private final ObjectMapper objectMapper;
    private final JwtIssueService jwtIssueService;

    @Value("${test.key}")
    private String SECRET_KEY;

    public Long getPayload(String token) throws JsonProcessingException {
        Claims claims = getAllClaimsFromToken(token);
        return objectMapper.readValue(claims.getSubject(), Long.class);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public TokenDto createTokenResponse(Long userId) throws JsonProcessingException {
        String accessToken = jwtIssueService.issueAccessToken(userId);
        String refreshToken = jwtIssueService.issueRefreshToken(userId);

        return TokenDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }
    
}

테스트 코드 작성

@SpringBootTest
class UserServiceTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserMemoryRepository userRepository;

    @AfterEach
    public void afterEach() { // 테스트간 종속 제거
        userRepository.clearStore();
    }

    @Test
    public void 회원가입_테스트() throws JsonProcessingException {
        // given
        User user = User.builder()
                .email("test@test.com")
                .password("test")
                .build();
        // when
        userService.signUp(user);
        // then
        assertThat(user.getId(), is(1L));
        assertThat(userService.findById(1L).get(), is(user));
    }

    @Test
    public void 액세스토큰_인증_테스트() throws JsonProcessingException {
        // given
        User user = User.builder()
                .email("test@test.com")
                .password("test")
                .build();
        TokenDto tokenDto = userService.signUp(user);
       	// when
        assertThat(userService.authAccessToken(tokenDto.getAccessToken()), is(user.getId()));
    }
}

✔️ 리프레시 토큰 구현

리프레시 토큰은 액세스 토큰을 재발급하기 위해 발급한다.

JWT 발급 과정 참고

액세스 토큰은 만료 기간을 짧게 두어 탈취되더라도 빠르게 만료되어 보안을 좀 더 강화한다.
만료 기간동안 유효한 액세스 토큰은 여러 개일 수 있다.
반면, 리프레시 토큰은 만료 기간이 길게 두어야 하기 때문에 보안을 강화하기 위한 다른 방법이 필요하다.

나는 만료 기간동안 유효한 토큰을 한 개로 두도록 하여 보안을 좀 더 강화하였다.
이를 위해 최근 발급한 리프레시 토큰을 저장하는 데이터베이스가 필요하여 TokenRepository를 만들었다.

TokenRepositoryImpl 클래스

회원 레포지토리처럼 TokenRepository 인터페이스를 구현한 클래스이다.

// 리프레시 토큰 레포지토리 구현체
@Repository
public class TokenRepositoryImpl implements TokenRepository {

    private static final Map<Long, String> store = new HashMap<>();

    @Override
    public void updateRefreshToken(Long userId, String refreshToken) {
        store.put(userId, refreshToken);
    }

    @Override
    public Optional<String> findRefreshTokenByUserId(Long userId) {
        return Optional.ofNullable(store.get(userId));
    }

    public void clearStore() { // 테스트를 위한 메서드(@AfterEach)
        store.clear();
    }

}

Map 객체와 updateRefreshToken 메서드를 통해 회원 한 명당 하나의 리프레시 토큰을 갖도록 하였다.
(데이터베이스를 연동하지 않고 작성한 코드임)

JwtService 추가

    private final TokenRepository tokenRepository;
	
    // ...
    
    public TokenDto createTokenResponse(Long userId) throws JsonProcessingException {
        String accessToken = jwtIssueService.issueAccessToken(userId);
        String refreshToken = jwtIssueService.issueRefreshToken(userId);

        tokenRepository.updateRefreshToken(userId, refreshToken);

        return TokenDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

createTokenResponse 메서드에

tokenRepository.updateRefreshToken(userId, refreshToken);

이 코드를 추가하자! (데이터를 저장하는 코드)

UserService

    private final TokenRepository tokenRepository;
    
    // ...
    
    // 리프레시 토큰으로 토큰 재발급
    public TokenDto reIssueToken(String refreshToken) throws JsonProcessingException {
        Long userId = jwtService.getPayload(refreshToken);
        // 저장된 리프레시 토큰을 가져온다.
        String savedRefreshToken = tokenRepository.findRefreshTokenByUserId(userId).orElseThrow( 
                () -> {throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");}
        );
        if (!refreshToken.equals(savedRefreshToken)) {
            throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");
        }
        return jwtService.createTokenResponse(userId);
    }

코드 설명

1. refreshToken의 payload에서 userId를 꺼낸다
2. 키 userId로 저장된 refreshToken을 찾는다.
   2-1. 존재하지 않으면 "유효하지 않은 리프레시 토큰입니다" 예외 던진다.
   2-2. 존재하면 3번
3. 입력받은 refreshToken과 데이터베이스에서 꺼낸 refreshToken을 비교한다.
   3-1. 틀리면 "유효하지 않은 리프레시 토큰입니다" 예외 던진다.
   3-2. 같으면 토큰 재발급

1번 payload를 가져오는 과정에서 token이 만료된 경우는 예외가 던져지니 해당 예외를 처리하면 된다.

키를 refreshToken, 값을 userId로 설정하고 1, 3번의 과정을 생략하는 방법도 있겠다.
두 개의 방법 중 뭐가 더 나은지는 모르겠다. 한번 알아봐야겠다 (아시는 분 조언 부탁..)

테스트 코드 작성

    @Test
    public void 리프레시토큰_재발급_성공_테스트() throws JsonProcessingException {
        // given
        User user = User.builder()
                .email("test@test.com")
                .password("test")
                .build();
        TokenDto tokenDto = userService.signUp(user);
        userService.reIssueToken(tokenDto.getRefreshToken());
    }

    @Test
    public void 리프레시토큰_재발급_실패_테스트() throws JsonProcessingException, InterruptedException {
        // given
        User user = User.builder()
                .email("test@test.com")
                .password("test")
                .build();
                
        TokenDto tokenDto = userService.signUp(user);
        String beforeRefreshToken = tokenDto.getRefreshToken();

        Thread.sleep(1000); // 다른 리프레시 토큰을 재발급 받기 위해 1초 sleep
        userService.reIssueToken(beforeRefreshToken);

        // when
        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                () -> userService.reIssueToken(beforeRefreshToken));

        assertThat(e.getMessage(), is("유효하지 않은 리프레시 토큰입니다."));
    }
profile
로그를 남기자 〰️

1개의 댓글