처음에 Bearer Token jwt로 구현하였고, accessToken을 json으로 주고 받으면서 회원기능을 구현하고 있었습니다.
그러나, accessToken의 만료시간이 지나면 로그인이 자동으로 풀린다는 사용자 편의를 감소시키는 부분이 있었고
이를 개선하고자 회원 기능을 AccessToken, RefreshToken을 이용하여 구현하기로 마음을 먹었습니다.
요구사항은 다음과 같습니다.
- 회원이 로그인을 했을 시에 accessToken과 refreshToken을 발급합니다.
- 만료일은 accessToken은 1시간, refreshToken은 2주로 만료기한을 잡아놓습니다.
- accessToken이 만료되지 않았다면, 회원은 인증/인가 기능을 가질 수 있습니다.
- accessToken이 만료되었다면, refreshToken의 유효성과 만료일을 확인 후 accessToken을 재발급 해줍니다.
- accessToken이 만료되었고, refreshToken이 만료되었다면 클라이언트의 token을 회수합니다.(cookie로 구현할 시 만료시간을 0으로 해서 뿌려주면 됩니다.)
- 여기서 보안을 한번 더 생각해 redis에 refreshToken을 저장하여, refreshToken이 혹여 변조되었는지 확인하는 작업을 할 수 있게 됩니다.
- 당연한 이야기겠지만, accessToken과 refreshToken이 변조되어서 유효하지 않은 경우에는 강제로 cookie를 초기화해줘야 합니다.
acessToken, refreshToken
JwtType
@Getter
public enum JwtType {
ACCESS_TOKEN("accessToken", 60 * 60 * 1000L), // 1시간
REFRESH_TOKEN("refreshToken", 14 * 24 * 60 * 60 * 1000L), // 2주
;
private final String tokenName;
private final long expiredMillis;
JwtType(String tokenName, long expiredMillis) {
this.tokenName = tokenName;
this.expiredMillis = expiredMillis;
}
}
- 통상적으로 accessToken은 짧게 잡고, refreshToken은 길게 잡아 놓는다고 알려져 있고
- 이런 부분은 실무에서 협의를 통해 설정하게 됩니다.
- Token의 타입을 지정해 두었고, 이를 통해 만료일자를 선언하게 됩니다.(활용 가능)
RedisUtil
- Redis의 데이터를 GET, SET할 수 있는 부분을 도와주는 Util class입니다. 이를 일부분 수정하였습니다.
public <T> Optional<T> getData(String key, Class<T> classType) {
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
String value = valueOperations.get(key);
if (value == null) {
return Optional.empty();
}
try {
return Optional.ofNullable(objectMapper.readValue(value, classType));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
- 제네릭을 활용하여, 타입까지 지정할 수 있게 구성하였고
- Optional을 써서 null을 제어할 수 있게 되었습니다.
public <T> void setDataExpire(String key, T value, long durationMillis) {
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
Duration expireDuration = Duration.ofMillis(durationMillis);
try {
valueOperations.set(key, objectMapper.writeValueAsString(value), expireDuration);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
- refreshToken을 redis에 저장할 때 쓰는 메서드 입니다.
SecurityContext
- 갑자기 이게 왜 나왔느냐. 라고 묻는다면, 시큐리티 설정을 사용하고 있습니다. (지금)
- SecurityContext라는 영역에 인증/인가 정보를 채워주기 위해 기존에는 filter에서 유효성을 검증하고, userDetailsService를 상속받은 클래스에서 loadByUsername을 통해 SecurityContext에 인증/인가 정보를 채워주었습니다.
- 그런다음 Controller에서 @AuthenticationPrincipal 이걸 통해 인증/인가된 유저의 정보를 받아올 수 있었는데,
- 이 부분을 커스텀 어노테이션을 활용해 구현하는 방식으로 변경하였습니다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
boolean required() default true;
}
@Component
@RequiredArgsConstructor
@Slf4j
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final UserRepository userRepository;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginUser.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
long loginUserId = Long.parseLong(authentication.getName());
return userRepository.findById(loginUserId)
.orElseThrow(() -> new BusinessException(loginUserId, "JWT", ErrorCode.USER_NOT_FOUND));
}
}
- 당연하게 Bean으로 argumentResolver를 등록해주어야 하고. (생략)
JwtTokenCondition
구현체 : AccessTokenReissueCondition
구현체 : JwtTokenValidCondition
팩토리 메소드 패턴 : JwtTokenConditionFactory
이부분은 보면 객체지향을 많이 사용한 코드인데, 설명드리기 어렵긴 하네요.
- AccessToken이 유효하면 JwtTokenValidCondition에서 accessToken을 그대로 사용자에게 반환해주고.
- AccessToken이 만료되었고, RefreshToken이 유효한 경우 AccessTokenReissueCondition에서 AccessToken을 재발급해줍니다.
- JwtTokenConditionFactory를 통해서
List<JwtTokenCondition> jwtTokenConditions = jwtTokenConditionFactory.createJwtTokenConditions();
jwtTokenConditions.stream()
.filter(jwtTokenCondition -> jwtTokenCondition.isSatisfiedBy(accessTokenDto, refreshTokenDto, httpRequest))
.findFirst()
.ifPresentOrElse(jwtTokenCondition -> jwtTokenCondition.setJwtToken(accessTokenDto, refreshTokenDto, httpRequest, httpResponse),
() -> authCookieService.setCookieExpired(httpResponse));
- 잘 살펴보시면 어떻게 작동하는지 알 수 있고.
- 제 github 코드가서 로직을 살펴보면 구현하실 수 있을 겁니다.
- 저도 다른 분의 코드를 살펴보고 분석을 좀 많이 하고 따라서 구현해보았는데, 자바의 장점을 이렇게 살릴 수 있나? 놀랐습니다.
PR
느낀점
- 처음에는 1부터 10까지 구현하는 법을 작성하려고 했는데, 생각보다 양이 많고 다들 각자 상황이 다를 수 있어서
- 참고하고 구현하면 될 것 같습니다.
- 제꺼에는 로그아웃은 구현이 아직 안 되어 있습니다.
- 이전에는 그냥 프론트에서 쿠키를 삭제하는 방식으로 구현이 되어 있었기에. 그렇게 구현하였는데, 이 부분도 상황에 따라 달라질 것이라고 생각합니다.
- 전체 코드
- https://github.com/beginner0107/spring-react-blog
참고한 Github
https://github.com/KEEPER31337/Homepage-Back-R2
'DB관련 > REDIS' 카테고리의 다른 글
Spring Boot + Redis 조회수, 회원 기능 구현한 후기 - (1) (0) | 2024.02.15 |
---|---|
REDIS - 숫자 다루기 (DECR, DECRBY, INCRBY, INCR) (0) | 2024.01.27 |
REDIS - DEL, GETRANGE, SETRANGE (1) | 2024.01.27 |
REDIS - MGET (1) | 2024.01.27 |
REDIS - SETNX / SETEX / MEST / MSETNX (0) | 2024.01.26 |