본문 바로가기

스프링

[Security] Spring JWT 인증 With REST API (2)

이전에 이어

이전에는 Spring Security를 이용해 JWT로 인증을 진행하는 방식을 구현해보았다.

이 방식은 다음과 같이 진행된다.

그런데 만약 토큰이 지정한 시간이 되어 만료된다면 어떻게 될까?

이전에 구현한 방식은 위와 같이 진행될 것이다.

기간이 만료된 토큰과 함께 /hello에 요청을 하지만 토큰 기간이 만료되어 요청이 반려될 것이다.

그에 따라, 사용자는 재로그인을 진행한다.

로그인이 성공했다면, 새로운 토큰을 발행하여 반환해주고 해당 토큰을 다시 포함시켜 서비스를 요청할 것이다.

로그인을 계속한다면 사용자에게 너무 불편한 느낌을 줄 수 있다.

이런 부분을 해결하기 위해 우리는 이렇게 생각해볼 수 있을 것이다.

"Access Token의 만료 시간 자체를 늘리면 되지 않을까?"

어느 정도는 맞는 말이라고 볼 수 있다. 하지만, 제 3자에게 토큰을 탈취당하게 된다면 해당 토큰의 유효기간이 만료되기 전까지는 막을 수 있는 방법이 존재하지 않는다.

그렇다면, 토큰의 기간을 짧게 설정해야한다. 하지만, 짧게 설정한다면 만료 기간이 끝날 때마다 계속적으로 로그인을 해줘야 하기에 사용자가 상당한 불편함을 겪게 되는 딜레마에 빠지게 된다.

이러한 부분 때문에 등장한 것이 바로 Refresh Token이다.

Refresh Token은 Access Token이 만료되었을 시에 Refresh Token의 만료 시간이 끝나지 않았다면 Access Token을 재발행해주는 역할을 행하는 Token이다.

Refresh Token은 보통 Redis에 저장하여 유효한 Refresh Token인지 검증한 후에 Access Token을 재발행해준다.

Access Token을 짧은 시간으로 두고 만료되어도 Refresh Token을 확인하여 재발행만 해준다면 사용자는 더이상 로그인을 하지 않아도 될 것이다. (Refresh Token 자체는 길게 잡아둔다.)

이를 진행하는 순서는 다음과 같다.

먼저, Access Token가 만료되어 반려당한다면 Refresh Token을 함께 담아 재발행을 요청한다.

요청하게 되면 Refresh Token을 검증하여 Access Token과 Refresh Token을 새로 발행하여 주고 Acess Token을 포함하여 /hello 요청하면 서비스를 제공받는다.

이러한 부분을 저번에 이어 구현해보려고 한다.

JwtTokenProvider

만료 기간은 넉넉잡아 7일으로 설정해두었다.

private long refreshTokenValidTime = 1000L * 60 * 60 * 24 * 7; // 7일

Access Token을 발행한 것과 더불어 Refresh Token을 발행하는 것을 구현하였다.

public String createRefreshToken() {
    Date now = new Date();

    return Jwts.builder()
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + refreshTokenValidTime))
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
}
  • 오직 재발행의 목적으로만 사용하기에 sub 불필요

전체 코드

@Setter
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    @Value("${spring.jwt.secretKey}")
    private String secretKey;

    private long tokenValidTime = 1000L * 60 * 30; // 30분
    private long refreshTokenValidTime = 1000L * 60 * 60 * 24 * 7; // 7일

    private final UserDetailsService userDetailsService;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String createToken(String email) {
        Claims claims = Jwts.claims().setSubject(email);
        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public String createRefreshToken() {
        Date now = new Date();

        return Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshTokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(getMemberEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getMemberEmail(String token) {
        try {
            return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
        } catch(ExpiredJwtException e) {
            return e.getClaims().getSubject();
        }
    }

    public String resolveToken(HttpServletRequest req) {
        return req.getHeader("X-AUTH-TOKEN");
    }

    public boolean validateTokenExpiration(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

Service

실제로 재발행하는 로직을 살펴보자.

@Transactional
public TokenResponseDto reIssue(TokenRequestDto requestDto) {
    if (!jwtTokenProvider.validateTokenExpiration(requestDto.getRefreshToken()))
        throw new InvalidRefreshTokenException();

    Member member = findMemberByToken(requestDto);

    if (!member.getRefreshToken().equals(requestDto.getRefreshToken()))
        throw new InvalidRefreshTokenException();

    String accessToken = jwtTokenProvider.createToken(member.getEmail());
    String refreshToken = jwtTokenProvider.createRefreshToken();
    member.updateRefreshToken(refreshToken);
    return new TokenResponseDto(accessToken, refreshToken);
}

public Member findMemberByToken(TokenRequestDto requestDto) {
    Authentication auth = jwtTokenProvider.getAuthentication(requestDto.getAccessToken());
    UserDetails userDetails = (UserDetails) auth.getPrincipal();
    String username = userDetails.getUsername();
    return memberRepository.findByEmail(username).orElseThrow(MemberNotFoundException::new);
}

Dto로부터 Refresh Token을 꺼내 만료 기간이 지났는지 확인한다.

지났다면, Exception을 발생시켜 다시 로그인을 진행할 수 있도록 만들어주면 될 것이다.

그 이후 findMemberByToken메소드를 활용해 파라미터로 입력받은 Access Token에 대한 회원 정보를 찾아온다.

이 메소드는 유효한 토큰이라면 AccessToken으로부터 Email 정보를 받아와 DB에 저장된 회원을 찾고 해당 회원의 실제 Refresh Token을 받아온다.

그리고 파라미터로 입력받은 Refresh Token과 실제 DB에 저장된 Refresh Token을 비교하여 검증한다.

일치한다면, 올바른 Refresh Token으로 새로 Access Token과 Refresh Token을 발급해 이를 반환한다.

전체 코드

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class SignService {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final ProviderService providerService;

    /**
     * Dto로 들어온 값을 통해 회원가입을 진행
     * @param requestDto
     * @return
     */
    @Transactional
    public MemberRegisterResponseDto registerMember(MemberRegisterRequestDto requestDto) {
        validateDuplicated(requestDto.getEmail());
        Member member = memberRepository.save(
                Member.builder()
                        .email(requestDto.getEmail())
                        .password(passwordEncoder.encode(requestDto.getPassword()))
                        .provider(null)
                        .build());

        return MemberRegisterResponseDto.builder()
                .id(member.getId())
                .email(member.getEmail())
                .build();
    }

    /**
     * Unique한 값을 가져야하나, 중복된 값을 가질 경우를 검증
     * @param email
     */
    public void validateDuplicated(String email) {
        if (memberRepository.findByEmail(email).isPresent())
            throw new MemberEmailAlreadyExistsException();
    }

    @Transactional
    public MemberLoginResponseDto loginMember(MemberLoginRequestDto requestDto) {
        Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(LoginFailureException::new);
        if (!passwordEncoder.matches(requestDto.getPassword(), member.getPassword()))
            throw new LoginFailureException();
        member.updateRefreshToken(jwtTokenProvider.createRefreshToken());
        return new MemberLoginResponseDto(member.getId(), jwtTokenProvider.createToken(requestDto.getEmail()), member.getRefreshToken());
    }

    /**
     * 토큰 재발행
     * @param requestDto
     * @return
     */
    @Transactional
    public TokenResponseDto reIssue(TokenRequestDto requestDto) {
        if (!jwtTokenProvider.validateTokenExpiration(requestDto.getRefreshToken()))
            throw new InvalidRefreshTokenException();

        Member member = findMemberByToken(requestDto);

        if (!member.getRefreshToken().equals(requestDto.getRefreshToken()))
            throw new InvalidRefreshTokenException();

        String accessToken = jwtTokenProvider.createToken(member.getEmail());
        String refreshToken = jwtTokenProvider.createRefreshToken();
        member.updateRefreshToken(refreshToken);
        return new TokenResponseDto(accessToken, refreshToken);
    }

    public Member findMemberByToken(TokenRequestDto requestDto) {
        Authentication auth = jwtTokenProvider.getAuthentication(requestDto.getAccessToken());
        UserDetails userDetails = (UserDetails) auth.getPrincipal();
        String username = userDetails.getUsername();
        return memberRepository.findByEmail(username).orElseThrow(MemberNotFoundException::new);
    }
}

Controller

토큰을 재발행하는 컨트롤러이다.

AccessToken과 Refresh Token을 입력받아 이를 Service에 넘겨 처리한다.

@PostMapping("/reissue")
public SingleResult<TokenResponseDto> reIssue(@RequestBody TokenRequestDto tokenRequestDto) {
    TokenResponseDto responseDto = signService.reIssue(tokenRequestDto);
    return responseService.getSingleResult(responseDto);
}

테스트

먼저, 회원가입을 진행

그 후 로그인을 진행

그 후 인증이 필요한 사이트 접속

추후 토큰 재발행이 필요할 경우 재발행

마무리

지금까지 JWT의 Refresh Token까지 활용하는 것을 진행해보았다.

우리는 이제 구글, 페이스북, 네이버, 카카오, 깃허브 등 외부 플랫폼에서의 정보를 토대로 로그인을 진행할 수 있는 OAuth 2.0을 JWT 토큰을 활용해 로그인하는 부분을 살펴보아야겠다고 생각했다.

다음 포스트에서는 아마 이 부분에 대해서 다뤄보지 않을까 싶다.