고딩왕 코범석

Spring Security + JWT + Redis로 로그인 구현하기 (2) 로그인 본문

Language & Framework/Spring

Spring Security + JWT + Redis로 로그인 구현하기 (2) 로그인

고딩왕 코범석 2021. 11. 23. 23:15
반응형

안녕하세요! 이번 포스팅에서는 1편에 이어 로그인, 로그아웃, 토큰 재발급, 권한 체크를 위한 간단한 회원 정보 조회를 구현해보겠습니다.


보다 자세한 코드는 제 깃허브 를 참조해주세요!


JWT 기반으로 요청 및 토큰이 만료되었을 경우에 대한 플로우를 그림으로 표현해보았습니다.


image


이 과정에 필요한 기능은 다음과 같습니다.

  • 로그인 (accessToken을 발급 받고, refreshToken을 Redis에 저장하기 위해)
  • 로그아웃 (accessToken의 남은 유효기간 동안 redis에 logoutAccessToken을 저장하여 해당 토큰으로 접근 하는 것을 금지시키기)
  • 토큰 재발급 (토큰은 유효 기간이 있기 때문에, 기간이 지났을 경우를 위해 redis에 저장한 refreshToken의 남은 만료 기간에 따라 accessToken과 refreshToken 두 개 모두 혹은 accessToken만 재발급하기 위해)

앞의 기능들을 적용하기 위해서 Redis 설정 코드를 보겠습니다.

CacheConfig

@Configuration
@RequiredArgsConstructor
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory){
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofSeconds(CacheKey.DEFAULT_EXPIRE_SEC))
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));


        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(configuration)
                .build();

    }
}

캐시를 사용하기 위한 Key와 디폴트 만료 시간을 설정한 클래스입니다.

CacheKey

@Getter
public class CacheKey {

    public static final String USER = "user";
    public static final int DEFAULT_EXPIRE_SEC = 60;
}

그리고 저번 포스팅에 잠깐 보았던 XXXRedisRepository 들을 보셨을 겁니다. Repository와 연관된 클래스들을 작성하겠습니다.

RefreshToken 및 Repository

@Getter
@RedisHash("refreshToken")
@AllArgsConstructor
@Builder
public class RefreshToken {

    @Id
    private String id;

    private String refreshToken;

    @TimeToLive
    private Long expiration;

    public static RefreshToken createRefreshToken(String username, String refreshToken, Long remainingMilliSeconds) {
        return RefreshToken.builder()
                .id(username)
                .refreshToken(refreshToken)
                .expiration(remainingMilliSeconds / 1000)
                .build();
    }
}

public interface RefreshTokenRedisRepository extends CrudRepository<RefreshToken, String> {
}

LogoutAccessToken 및 Repository

@Getter
@RedisHash("logoutAccessToken")
@AllArgsConstructor
@Builder
public class LogoutAccessToken {

    @Id
    private String id;

    private String username;

    @TimeToLive
    private Long expiration;

    public static LogoutAccessToken of(String accessToken, String username, Long remainingMilliSeconds) {
        return LogoutAccessToken.builder()
                .id(accessToken)
                .username(username)
                .expiration(remainingMilliSeconds / 1000)
                .build();
    }
}

public interface LogoutAccessTokenRedisRepository extends CrudRepository<LogoutAccessToken, String> {
}

우선, JPA를 써보신 분들이라면 굉장히 익숙한 구조일 것입니다. POJO 클래스 생성시 사용된 어노테이션들에 대해 알아보자면

@RedisHash(value)

어노테이션의 선언된 value로 Redis의 Set 자료구조를 통해 해당 객체가 저장됩니다.

image


여러 유저가 로그인 할 경우 refreshToken 이라는 키에 여러 username 들이 value로 존재합니다.

image


또한 저장된 객체는 Hash 자료구조로 저장됩니다. refreshToken:username 이라는 키에 id, _class, expiration, refreshToken 이라는 sub Key가 존재하고 각각의 value들이 저장되어집니다.


@TimeToLive

설정한 시간 만큼 데이터를 저장합니다. 설정한 시간이 지나면 자동으로 해당 데이터가 사라지는 휘발 역할을 해줍니다.


이제 추가된 Controller와 Service 코드들을 보겠습니다. 저번 포스팅에서 작성했던 코드들도 포함하겠습니다 :)

Controller

@RestController
@RequiredArgsConstructor
public class Api {

    private final MemberService memberService;
    private final JwtTokenUtil jwtTokenUtil;

    @GetMapping("/health")
    public String health() {
        return "OK";
    }

    @PostMapping("/join")
    public String join(@RequestBody JoinDto joinDto) {
        memberService.join(joinDto);
        return "회원가입 완료";
    }

    @PostMapping("/join/admin")
    public String joinAdmin(@RequestBody JoinDto joinDto) {
        memberService.joinAdmin(joinDto);
        return "어드민 회원 가입 완료";
    }

    @PostMapping("/login")
    public ResponseEntity<TokenDto> login(@RequestBody LoginDto loginDto) {
        return ResponseEntity.ok(memberService.login(loginDto));
    }

    @GetMapping("/members/{email}")
    public MemberInfo getMemberInfo(@PathVariable String email) {
        return memberService.getMemberInfo(email);
    }

    @PostMapping("/reissue")
    public ResponseEntity<TokenDto> reissue(@RequestHeader("RefreshToken") String refreshToken) {
        return ResponseEntity.ok(memberService.reissue(refreshToken));
    }

    @PostMapping("/logout")
    public void logout(@RequestHeader("Authorization") String accessToken,
                                    @RequestHeader("RefreshToken") String refreshToken) {
        String username = jwtTokenUtil.getUsername(resolveToken(accessToken));
        memberService.logout(TokenDto.of(accessToken, refreshToken), username);
    }

    private String resolveToken(String accessToken) {
        return accessToken.substring(7);
    }
}

Service

@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final RefreshTokenRedisRepository refreshTokenRedisRepository;
    private final LogoutAccessTokenRedisRepository logoutAccessTokenRedisRepository;
    private final JwtTokenUtil jwtTokenUtil;

    public void join(JoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        memberRepository.save(Member.ofUser(joinDto));
    }

    public void joinAdmin(JoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        memberRepository.save(Member.ofAdmin(joinDto));
    }

    // 1
    public TokenDto login(LoginDto loginDto) {
        Member member = memberRepository.findByEmail(loginDto.getEmail()).orElseThrow(() -> new NoSuchElementException("회원이 없습니다."));
        checkPassword(loginDto.getPassword(), member.getPassword());

        String username = member.getUsername();
        String accessToken = jwtTokenUtil.generateAccessToken(username);
        RefreshToken refreshToken = saveRefreshToken(username);
        return TokenDto.of(accessToken, refreshToken.getRefreshToken());
    }

    private void checkPassword(String rawPassword, String findMemberPassword) {
        if (!passwordEncoder.matches(rawPassword, findMemberPassword)) {
            throw new IllegalArgumentException("비밀번호가 맞지 않습니다.");
        }
    }

    private RefreshToken saveRefreshToken(String username) {
        return refreshTokenRedisRepository.save(RefreshToken.createRefreshToken(username,
                jwtTokenUtil.generateRefreshToken(username), REFRESH_TOKEN_EXPIRATION_TIME.getValue()));
    }

    // 2
    public MemberInfo getMemberInfo(String email) {
        Member member = memberRepository.findByEmail(email).orElseThrow(() -> new NoSuchElementException("회원이 없습니다."));
        if (!member.getUsername().equals(getCurrentUsername())) {
            throw new IllegalArgumentException("회원 정보가 일치하지 않습니다.");
        }
        return MemberInfo.builder()
                .username(member.getUsername())
                .email(member.getEmail())
                .build();
    }

    // 4
    @CacheEvict(value = CacheKey.USER, key = "#username")
    public void logout(TokenDto tokenDto, String username) {
        String accessToken = resolveToken(tokenDto.getAccessToken());
        long remainMilliSeconds = jwtTokenUtil.getRemainMilliSeconds(accessToken);
        refreshTokenRedisRepository.deleteById(username);
        logoutAccessTokenRedisRepository.save(LogoutAccessToken.of(accessToken, username, remainMilliSeconds));
    }

    private String resolveToken(String token) {
        return token.substring(7);
    }

    // 3
    public TokenDto reissue(String refreshToken) {
        refreshToken = resolveToken(refreshToken);
        String username = getCurrentUsername();
        RefreshToken redisRefreshToken = refreshTokenRedisRepository.findById(username).orElseThrow(NoSuchElementException::new);

        if (refreshToken.equals(redisRefreshToken.getRefreshToken())) {
            return reissueRefreshToken(refreshToken, username);
        }
        throw new IllegalArgumentException("토큰이 일치하지 않습니다.");
    }

    private String getCurrentUsername() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserDetails principal = (UserDetails) authentication.getPrincipal();
        return principal.getUsername();
    }

    private TokenDto reissueRefreshToken(String refreshToken, String username) {
        if (lessThanReissueExpirationTimesLeft(refreshToken)) {
            String accessToken = jwtTokenUtil.generateAccessToken(username);
            return TokenDto.of(accessToken, saveRefreshToken(username).getRefreshToken());
        }
        return TokenDto.of(jwtTokenUtil.generateAccessToken(username), refreshToken);
    }

    private boolean lessThanReissueExpirationTimesLeft(String refreshToken) {
        return jwtTokenUtil.getRemainMilliSeconds(refreshToken) < JwtExpirationEnums.REISSUE_EXPIRATION_TIME.getValue();
    }
}

주석에 기입되어있는 번호 순서대로 기능을 살펴보겠습니다.

1. 로그인


이메일로 회원을 찾은 후, 비밀번호가 일치하는지 PasswordEncoder를 통해 검증합니다. 그 후, accessToken을 만들고 refreshToken을 Redis에 저장합니다.


image


보시는 것 처럼 응답으로 accessToken, refreshToken을 확인할 수 있고,


image

Redis에도 잘 저장이된 것을 확인할 수 있습니다.


2. 간단한 회원 정보 조회


로그인을 했다면 1편 포스팅에서 시큐리티 설정으로 인해 회원 조회는 USER 권한만 동작 가능합니다.


그 전에 CustomUserDetailsService 클래스를 잠깐 짚고 넘어가겠습니다.


@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Cacheable(value = CacheKey.USER, key = "#username", unless = "#result == null")
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsernameWithAuthority(username).orElseThrow(() -> new NoSuchElementException("없는 회원입니다."));
        return CustomUserDetails.of(member);
    }
}

1편에서 저는 토큰을 가지고 요청할 때 마다 DB에서 회원을 조회하는 것을 줄이기 위해 Cacheable 어노테이션을 이용했습니다.


Cacheable의 동작 방식은 캐시에서 메서드의 파라미터로 캐시를 먼저 조회합니다. 제가 사용한 Redis에 데이터가 있을 경우는 Redis에 저장된 데이터를 그대로 반환해주고, 없을 경우는 DB에 직접 조회합니다.


Cacheable을 통해 저장된 데이터는 어노테이션에 설정된 value::key 의 형태로 Redis의 Key로 저장됩니다. 그에 대한 값은 제가 전 포스팅에서 만든 CustomUserDetails의 형태로 저장됩니다.


이제, admin, user 권한으로 각각 회원가입을 한 후, 회원 상세 조회 기능을 실행해보겠습니다. 먼저, 회원가입시 USER 권한은 test@test.com, ADMIN 권한은 admin@admin.com 으로 회원가입을 진행했으며 test@test.com으로 회원정보를 조회해보겠습니다.


image


Redis에도 캐싱 되어있는지 보겠습니다.


image


@Cacheable에서 설정한 형태인 value::key형태로 저장된 Key가 보일겁니다. get 했을 경우, CustomUserDetails 클래스의 형태로 저장되고, 위에서 설정한 CacheKey에서 디폴트 TTL을 60초로 주어


image


이렇게 시간이 지나면 value::key가 휘발된 것을 알 수 있습니다.


또한, 어드민 계정으로 조회할 경우,


image


image


권한 관련 http 상태인 403 에러를 확인할 수 있습니다!


3. 토큰 재발급


토큰이 만료되었을 경우에는 accessToken과 refreshToken의 유효 기간에 따라 refreshToken도 같이 재발급해줘야 합니다. 우선 시큐리티 컨텍스트에서 username을 가져와 이 username으로 Redis에서 refreshToken을 찾습니다.


이후, 클라이언트가 보내준 refreshToken과 Redis에 저장된 refreshToken이 일치하다면, accessToken과 남은 만료 기간에 따라 refreshToken까지 새로 고쳐서 재발급해줍니다.


image


refreshToken은 1편에서 Refresh 토큰에 대한 만료 기간을 7일로 잡았기 때문에 변하지 않았습니다.


4. 로그아웃


로그아웃은 Redis에 저장된 refreshToken을 삭제하고, accessToken을 Key로 하여 남은 기간 만큼 TTL을 설정 후 LogoutAccessToken을 저장합니다.


이 때, Key를 username으로 할 경우 로그아웃 후 바로 로그인을 시도할 경우 JwtAuthenticationFilter에서 로그아웃 상태인지 확인할 때, 예외가 발생하기 때문에 accessToken을 그대로 Key로 넣어 저장합니다.


@CacheEvict@Cacheable과 반대로 남아있는 유저 정보를 삭제하기 위해 어노테이션에 설정된 key로 캐시에 저장된 데이터를 삭제합니다.


image


image


로그아웃 후, refreshToken이 삭제되고 logoutAccessToken이 들어가있음을 알 수 있습니다.


이상으로 Spring Security + JWT + Redis를 이용해 로그인 기능을 전반적으로 구현해보았습니다. 항상 피드백은 환영합니다!

반응형