JWT와 OAuth2 로그인을 도입하며 access token을 처리하는 것에 벅차 refresh token 구현을 미루고 미루다 드디어 구현!
흐름 상으로는 기존 로그인 및 access token 발급 플로우에 refresh token 발급 로직을 추가한 것에 불과하다.
컨트롤러는 단순히 id, pw 데이터를 받아 로그인 요청을 처리하는 코드밖에 없으므로 생략한다.
@Getter
public class RefreshToken {
private String token;
private LocalDateTime expiredAt;
public RefreshToken(){
this.token = UUID.randomUUID().toString();
this.expiredAt = LocalDateTime.now().plusHours(3);
}
}
refresh token의 정보를 담을 클래스다.
UUID로 만든 unique한 문자열(refreshToken)과 만료 시점을 저장한다.
refresh token의 만료 시점은 발급 기준 세 시간 뒤, access token의 만료 시점은 15분 뒤로 정했다.
Refresh token을 UUID로 생성한 이유는, 굳이 비용을 들여 JWT로 생성하거나 Refresh token이 사용자 정보를 포함하고 있을 필요가 없다고 판단했기 때문이다. 이에 발행한 Refresh token과 사용자를 매핑하기 위해 Redis를 도입하기로 결정했다.
내가 생각한 UUID와 JWT Refresh token의 장단점은 다음과 같다.
UUID | JWT |
---|---|
JWT 생성비용 ↓ | 정보 탈취를 덜 당하려고 refresh token을 도입했는데, 또 사용자 정보를 줘야함 |
redis든 DB든 사용자와 매핑할 수 있게 저장해야함 | JWT에 담긴 정보로 (매핑이나 저장소 추가 없이) access token 재발급 가능 |
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-redis'
spring-boot-starter-data-redis를 사용하면 별다른 설정 없이 Lettuce(Redis Client 구현체)를 사용할 수 있다.
spring.redis.host=
spring.redis.password=
spring.redis.port=6379
host와 password를 입력한다. 서버에 redis를 다운받고 비밀번호를 설정하는 법은 추후 포스트할 예정이다.
RedisRepository를 사용하기 위해 자료구조 객체 Class를 작성한다.
@Getter
@RedisHash(value="activeGardeners", timeToLive = 960)
@ToString
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class ActiveGardener {
@Id
private Long gardenerId;
private String name;
private RefreshToken refreshToken;
private LocalDateTime createdAt;
public static ActiveGardener from(Long gardenerId, String name, RefreshToken refreshToken){
return ActiveGardener.builder()
.gardenerId(gardenerId)
.name(name)
.refreshToken(refreshToken)
.createdAt(LocalDateTime.now())
.build();
}
}
간단한 사용자 정보와 refresh token, 생성 시점등을 기록하기로 했다.
데이터의 유효기간을 설정하는 어노테이션.
refresh token은 15분 뒤 만료지만, 뭔가 찝찝해서 16분 뒤 지워지도록 설정해놓았다.
기본 메소드말고는 필요한 게 없어서 CrudRepository만 implements했다.
public interface RedisRepository extends CrudRepository<ActiveGardener, Long> {
}
여기까지 했으면 Redis를 사용할 준비가 끝났다!!!
@Override
public GardenerDto.Info login(GardenerDto.Login login) {
Gardener gardener = gardenerDao.getGardenerForLogin(login.getUsername());
// 비밀번호 일치 여부 검사
if (!encoder.matches(login.getPassword(), gardener.getPassword())) {
throw new BadCredentialsException(ExceptionCode.WRONG_PASSWORD.getCode());
}
return setAuthentication(gardener);
}
// 인증 성공 후 Security Context에 유저 정보를 저장하고 토큰과 기본 정보를 리턴
@Override
public GardenerDto.Info setAuthentication(Gardener gardener) {
// gardener 객체를 포함한 user 생성
UserPrincipal user = UserPrincipal.create(gardener);
// Authentication에 담을 토큰 생성
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user, null, Collections.singleton(new SimpleGrantedAuthority("USER")));
// security context에 저장
SecurityContextHolder.getContext().setAuthentication(token);
// access token, refresh token ⭐️⭐️⭐️
AccessToken accessToken = tokenProvider.createAccessToken(gardener.getGardenerId(), gardener.getName());
RefreshToken refreshToken = tokenProvider.createRefreshToken(gardener.getGardenerId(), gardener.getName());
return GardenerDto.Info.from(accessToken.getToken(), refreshToken.getToken(), gardener);
}
public RefreshToken createRefreshToken(Long gardenerId, String name){
RefreshToken refreshToken = new RefreshToken();
redisRepository.save(ActiveGardener.from(gardenerId, name, refreshToken));
return refreshToken;
}
TokenProvider에서 Redis에 RefreshToken과 사용자 정보를 저장한 뒤, refresh token을 돌려준다.
OAuth2MemberService에서 인증 성공 후 로직을 담당하는 클래스다.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
UserPrincipal user = (UserPrincipal) authentication.getPrincipal();
// refresh 토큰 생성 및 Redis 저장
tokenProvider.createRefreshToken(user.getId(), user.getName());
// access 토큰 생성
AccessToken accessToken = tokenProvider.createAccessToken(user.getId(), user.getName());
getRedirectStrategy().sendRedirect(request, response, TARGET_URL + accessToken.getToken());
}
소셜 로그인의 경우 accessToken만 path variable로 넘겨준다.
소셜 로그인의 플로우는 다음과 같다.
- 소셜 로그인 유저의 로그인 요청
- OAuth2MemberService에서 인증/인가 및 유저 DB save/update 처리
- OAuth2SuccessHandler
- refresh token을 생성한 후 redis에 저장
- access token 발급 및 클라이언트 전달
- 클라이언트는 전달받은 access token을 통해 헤더에 띄울 사용자 기본 정보와 refresh token 요청
- (Custom)UserDetailsService에서 확인한 Authenticaion을 통해 요청받은 정보 전달
PathVariable로 도착한 access token을 저장하고 간단한 유저 정보를 요청하는 페이지다.
import React, {useEffect} from 'react'
import {useNavigate, useParams} from 'react-router-dom';
import getData from "../../api/backend-api/common/getData";
import setGardener from "../../api/service/setGardener";
const GetToken = () => {
// path variable 추출
let {accessToken} = useParams();
const navigate = useNavigate();
const setUser = async () => {
// access token 저장
await localStorage.setItem("accessToken", accessToken);
// 위에서 받은 access token을 포함하는 Axios 객체로 사용자 정보 및 refresh token 요청
// Authentication을 기반으로 기본 정보를 리턴해준다
const res = await getData(`${process.env.REACT_APP_API_URL}/info`);
res.token.accessToken = accessToken;
// response로 받은 data 저장
await setGardener(res);
// 페이지 이동
navigate("/", {replace: true});
}
useEffect(() => {
setUser();
}, [])
return (
<></>
)
}
export default GetToken;
액세스 토큰 재발급 로직은 다음 포스팅에서..!