프로젝트
Spring Security 활용 JWT 구현하기
진우식
2023. 10. 11. 14:39
반응형
Spring Security 활용 JWT 구현하기
왜 JWT를 선택 하였는가?
쿠키
- 클라이언트에 300개 까지 저장 가능, 도메인당 20개의 값 가질 수 있다.
- 쿠키는 사용자가 요청하지 않아도, 브라우저가 Request 시에 Request Header를 넣어 자동으로 서버에 전송한다.
- 기존에는 쿠키에 사용자의 인증 정보를 담아서 보내는 방식 사용
- 문제점 : 사용자의 인증 정보가 탈취될 가능성이 있다.
세션
- 서버에서 클라이언트를 구분하기 위해 세션 ID를 부여한다.
- 해당 세션을 서버측에서 관리하며, 사용자는 쿠키에 세션정보를 담아 보내면 서버에서 유효한 사용자인지 판단한다.
- 장점
- 데이터가 서버에 있기 때문에, 세션이 탈취당해도 사용자의 인증정보는 안전할 수 있다.
- 단점
- 사용자가 많아질수록 서버 메모리를 많이 차지하게 된다.
- 서버가 여러개인 환경에서 세션을 동기화 시킬 때, 추가적인 시간이 소요된다.
- 해결방안
- 레디스를 활용하여 하나의 서버에서 세션을 관리한다.
- 로드밸런싱을 통해 특정 IP에서 들어오는 요청은, 특정 서버로만 갈 수 있도록 한다.
JWT
- 구성
- Header, Payload, Signature
- 생성 과정
- Header에 Signature 해싱에 사용할 알고리즘 방식을 지정하여 토큰 검증에 사용
- Payload에 사용자의 정보 및 토큰 만료 기한 적음
- Header와 Payload를 각각 BASE64로 인코딩
- Secret Key로 헤더에서 정의한 알고리즘을 통해 해싱 한다
- 위 값을 다시 BASE64로 인코딩 하여 생성한다
- 문제점
- 토큰이 탈취 당하면, 만료기한 전까지 탈취당한 토큰이 악용당할 수 있다.
- 토큰 탈취 시나리오를 생각하면, 토큰의 유효시간을 짧게 설정 해야 한다.
- 토큰의 유효시간이 짧을 경우 사용자는 계속 재로그인을 해야하는 불편함이 생긴다.
- 해결방안
- Access Token + Refresh Token 같이 사용
- 인가를 담당하는 Access Token은 기간을 짧게 설정하고
- 재발급을 담당하는 Refresh Token은 기간을 길게 설정할 수 있다.
- 토큰 보관 방법
- Access Token
- 단독 사용 : 브라우저의 세션 스토리지에 저장
- 세션 스토리지의 문제점 - JS로 토큰 값을 꺼내서 보내는 방식이기 때문에, XSS에 취약
- Refresh Token과 함께 사용
- 쿠키에 담을 때 : Refresh Token도 쿠키에 담기 때문에, Refresh Token과 함께 같이 탈취당할 수 있다.
- 대안 : 자바 스크립트의 private 변수로 저장해 둔다.
- 예상 문제점 - 페이지 이동시 마다 재발급 필요하지 않는가?
- 해결방안 1. Redis를 활용하여 토큰의 발급 속도를 향상 시킨다.
- 해결방안 2. React, Vue 처럼 SPA를 활용하여 페이지 이동 없이 토큰 만료시에만 재발급 할 수 있다.
- Refresh Token
- 쿠키에 보관 : 쿠키도 JS로 접근 가능하기 때문에, HTTP Only 옵션과 HTTPS가 적용되지 않은 이미지 등으로 탈취 되는것을 방지하기 위해 Secure 옵션을 설정해준다.
💡Refresh Token을 발급만 해주면 보안상으로 문제가 없는 것인가?- 그렇지 않다!!!!
- 토큰 탈취 시나리오
- 쿠키에 담긴 Refresh Token 탈취
- 기존 사용자는 Access Token 활용 및 추후 만료시 Refresh Token으로 재발급 요청
- 해커는 탈취한 Refresh Token으로 Access Token 재발급
- Refresh Token은 만료 기한이 길기 때문에, 만료기간 도달까지 해커는 Refresh Token으로 Access Token 재발급 받아 사용할 수 있음.
- 하나의 Refresh Token으로 두명이 사용하는 문제가 생김
- 대안
- Refresh Token Rotation 도입
- 새로운 Access token 발급 요청이 들어올 때 마다, 리프레시 토큰을 교체하는 방식
- 이 경우 Refresh Token은 하나에 한개의 토큰만 발급할 수 있게 된다.
- 토큰 탈취 시나리오
- 쿠키에 담긴 Refresh Token 탈취
- Refresh Token 사용해서 재발급 요청 - 사용된 Refresh Token을 DB에 저장
- 사용자가 요청할 경우 : Access + Refresh Token을 사용자에게 재발급
- 해커가 요청할 경우 : Access + Refresh Token을 해커에게 재발급
- 추후 사용자 또는, 해커가 이미 사용된 Refresh Token으로 재발급 요청
- 재발급 요청이 들어올 경우 해당 사용자와 관련된 모든 Refresh Token을 Invalid 상태로 만듬.
- 이후 해커의 Refresh Token은 사용 불가능 상태가 되고, 사용자는 재로그인을 통해 Refresh Token과 Access Token을 다시 발급 받을 수 있음.
- Refresh Token Rotation 도입
- Access Token
Spring Security를 활용한 JWT 구현
TokenInfo
@Builder
@Data
@AllArgsConstructor
public class TokenInfo {
private String grantType;
private String accessToken;
private String refreshToken;
}
- Builder 패턴을 적용하였다.
- @Data는 지양하는 것이 좋지만, 개발 편의성을 위해 우선 사용하였다.
JwtTokenProvider
@Slf4j
@Component
public class JwtTokenProvider {
private Key secret;
@Value("${jwt.access-expired}")
private Long accessTokenExpired;
@Value("${jwt.refresh-expired}")
private Long refreshTokenExpired;
public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
byte[] keyBytes = secret.getBytes();
this.secret = Keys.hmacShaKeyFor(keyBytes);
}
/**
* 유저 정보를 통해 토큰 생성
*/
public TokenInfo generateToken(Authentication authentication) {
log.info("generateToken start");
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + (1000 * accessTokenExpired)); // 30분
Date refreshTokenExpiresIn = new Date(now + (1000 * refreshTokenExpired)); // 14일
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", "USER")
.setExpiration(accessTokenExpiresIn)
.signWith(secret, SignatureAlgorithm.HS256)
.compact();
log.info(parseClaims(accessToken).toString());
String refreshToken = Jwts.builder()
.setExpiration(refreshTokenExpiresIn)
.signWith(secret, SignatureAlgorithm.HS256)
.compact();
log.info("accessToken: {}", accessToken);
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
/**
* 토큰에서 유저 정보 추출
*/
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
log.info("claims: {}", claims);
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
log.info("authorities: {}", authorities);
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
/**
* 토큰 정보 검증
*/
public boolean validateToken(String token) {
log.info("validateToken start");
log.info("token: {}", token);
try {
Jwts.parserBuilder().setSigningKey(secret).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
// refresh token 활용해서 재발급
log.info("Expired JWT Token", e);
throw e;
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(secret)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
public String getMemberEmail(String refreshToken) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secret)
.build()
.parseClaimsJws(refreshToken)
.getBody();
return claims.getSubject();
}
}
- generateToken : 만들어진 authentication을 활용해서 TokenInfo을 만든다
- getAuthentication : accessToken을 확인하고 인증 정보를 반환해준다.
- validateToken : 토큰의 valid를 검증한다.
- 각 Exception들은 RuntimeError를 상속하고 있는데, ExpiredJwtException은 추후 accessToken 만료 시나리오에서 쓰이기 때문에 catch 하지 않고 throw 해준다.
- getClaims : 클레임을 반환한다.
JwtAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret}")
private String secret;
private final JwtTokenProvider jwtTokenProvider;
private final ObjectMapper objectMapper;
private static final String BEARER = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
log.info("doFilterInternal start");
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
// access token이 있고, BEARER로 시작한다면
if (authorization != null && authorization.startsWith(BEARER)) {
String token = authorization.substring(BEARER.length());
log.info("token: {}", token);
// token을 검증한다.
try {
if (jwtTokenProvider.validateToken(token)) {
// token이 유효하다면
// token으로부터 유저 정보를 받아온다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (ExpiredJwtException e) {
log.info("ExpiredJwtException");
// access token이 만료되었다면
// 에러를 보낸다
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Map<String, String> error = new HashMap<>();
error.put("message", "access token expired");
error.put("code", "401");
error.put("status", "UNAUTHORIZED");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(error));
return;
}
}
filterChain.doFilter(request, response);
}
}
- OncePerRequestFilter 🆚 GenericFilterBean
- GenericFilterBean : 매 서블릿 마다 호출된다
- OncePerRequestFilter : RequestDispatcher 클래스에 의해 다른 서블릿으로 dispatch 되더라도, Filter가 실행되지 않는다.
- 추후 Oauth 구현을 위해 OncePerRequestFilter로 설정하였다.
- doFilterInternal
- Header에서 Authorization을 활용해 accessToken을 검증한다
- 엑세스 토큰이 만료되었을 경우 Exception을 캐치하여 response에 에러 메세지를 반환해준다
- 엑세스 토큰이 정상적일 경우, SecurityContextHolder에 Authenticaton을 가져와 설정해준다.
- 엑세스 토큰이 없을 경우 설정 없이 필터 체인을 거치게 된다.
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final ObjectMapper objectMapper;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.antMatchers("/login/member").permitAll() // 해당 API 모든 요청 허가
.antMatchers("/login/token/reissue").permitAll() // 해당 API 모든 요청 허가
.antMatchers("/login/test").authenticated()
// .anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class);
return http.build();
// 그 외 나머지 요청은 인증 필요
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
- passwordEncoder : 단방향 암호화 문제를 해결하기 위한 팩토리패턴을 활용해 빈으로 등록하였다. 추후 암호화 알고리즘 변경시에도 적용하기 좋다
- Config를 통해 필터체인을 등록한다. 이때 내가 만든
JwtAuthenticationFilter가 시큐리티보다 먼저 적용될 수 있도록 해야한다.
- Cors 정책으로 인해 HTTP OPTIONS는 모두 허용 해주었다.
- /login/member, /login/token/reissue는 권한이 없는 사용자의 요청이므로, 해당 요청에 대해서는 모든 요청을 허락 해주어야 한다.
- /login/test 요청은 authenticated를 통해 인가 확인하고 실행하도록 한다
Spring Controller, Service, Repository 구현
LoginMemberController
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/login")
public class LoginMemberController {
@Value("${cors.frontend}")
private String referer;
private Long refreshTokenExpired;
private final LoginMemberService loginMemberService;
@PostMapping("/member")
public TokenInfo loginTest(@RequestBody LoginMemberRequestDto loginMemberRequestDto
, HttpServletResponse response) throws IOException {
log.info("memberLoginRequestDto: {}", loginMemberRequestDto);
TokenInfo token = loginMemberService.login(loginMemberRequestDto);
log.info("token: {}", token);
Cookie refreshToken = new Cookie("refreshToken", token.getRefreshToken());
response.setHeader("Set-Cookie",
"refreshToken=" + token.getRefreshToken() + "; Path=/; HttpOnly; Secure; Max-Age=" + refreshTokenExpired);
return token;
}
@PostMapping("/test")
public String test() {
return "test";
}
@GetMapping("/token/reissue")
public Object reissueAccessToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
log.info("reissueAccessToken start");
Cookie refreshToken = WebUtils.getCookie(request, "refreshToken");
log.info("refreshToken: {}", refreshToken);
Object responseJson = loginMemberService.reissue(refreshToken.getValue());
log.info("tokenInfo: {}", responseJson);
if (responseJson instanceof TokenInfo) {
TokenInfo tokenInfo = (TokenInfo) responseJson;
response.setHeader("Set-Cookie",
"refreshToken=" + tokenInfo.getRefreshToken() + "; Path=/; HttpOnly; Secure; Max-Age=" + refreshTokenExpired);
}
return responseJson;
}
}



- loginTest : “/login/member” - 로그인 이메일, 비밀번호를 활용해서 로그인을 시도하고, 로그인이 성공할 경우 response에 Set-Cookie 헤더를 설정하여 refreshToken을 반환해준다.
- test : 엑세스 토큰의 만료 여부를 체크하여, 실제 토큰이 잘 동작하는지 체크한다.
- reissueAccessToken : “/login/token/reissue” - 쿠키에 담긴 refreshToken을 활용해서 토큰을 재발급 한다.
LoginMemberService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class LoginMemberService {
private final LoginMemberRepository loginMemberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public TokenInfo login(LoginMemberRequestDto loginMemberRequestDto) {
log.info("login start");
String memberEmail = loginMemberRequestDto.getMemberEmail();
String password = loginMemberRequestDto.getPassword();
// 1. MemberEmail, Password로 Member 조회
Member member = loginMemberRepository.findByEmail(memberEmail)
.orElseThrow(() -> new RuntimeException("존재하지 않는 회원입니다."));
if (!member.getPassword().equals(password)) {
throw new RuntimeException("비밀번호가 일치하지 않습니다.");
}
// 2. 토큰 생성
TokenInfo tokenInfo = createNewToken(memberEmail, password);
// 3. 저장소에 Refresh Token 저장
saveRefreshToken(tokenInfo, member);
log.info("end generateToken");
return tokenInfo;
}
@Transactional
public Object reissue(String refreshToken) {
log.info("reissue start");
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// Refresh Token Rotation 기법 사용, 유효한 Refresh Token인지 확인
// 유효하지 않을 경우 Exception 발생
// 유효할 경우 Refresh Token 가져와서 사용
RefreshToken refreshTokenEntity = refreshTokenRotation(refreshToken);
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
return new UsedTokenError("토큰 탈취 에러", HttpStatus.BAD_REQUEST.toString(),"Refresh Token이 이미 사용되었습니다.");
}
refreshTokenEntity.updateStatus(RefreshTokenStatus.INVALID);
// 3. 토큰 생성
TokenInfo tokenInfo = createNewToken(refreshTokenEntity.getMember().getEmail(), refreshTokenEntity.getMember().getPassword());
// 4. 저장소에 Refresh Token 저장
saveRefreshToken(tokenInfo, refreshTokenEntity.getMember());
log.info("tokenInfo: {}", tokenInfo);
return tokenInfo;
}
/**
* Refresh Token Rotation 기법 적용
*/
private RefreshToken refreshTokenRotation(String refreshToken) {
Optional<RefreshToken> token = refreshTokenRepository.findById(refreshToken);
if (token.isEmpty()) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
RefreshToken refreshTokenEntity = token.get();
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
// 탈취된 토큰이 활용된 것이므로, 관련 멤버의 모든 Refresh Token을 무효화한다.
// refresh 토큰은 한번만 사용할 수 있도록 한다.
refreshTokenRepository.updateAllByMember(refreshTokenEntity.getMember(), RefreshTokenStatus.INVALID);
}
return refreshTokenEntity;
}
/**
* 새로운 토큰 생성
*/
private TokenInfo createNewToken(String memberEmail, String password) {
log.info("createNewToken start");
// 1. Refresh Token으로 MemberEmail 조회
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberEmail, password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
log.info("authentication: {}", authentication);
// 2. 새로운 토큰 생성 - refresh token도 새로 생성해서 나가게 된다.
return jwtTokenProvider.generateToken(authentication);
}
/**
* Refresh Token 저장
*/
private void saveRefreshToken(TokenInfo tokenInfo, Member refreshTokenEntity) {
log.info("saveRefreshToken start");
RefreshToken newRefreshToken = new RefreshToken(tokenInfo.getRefreshToken(), RefreshTokenStatus.VALID,
refreshTokenEntity);
refreshTokenRepository.save(newRefreshToken);
}
}
- login
- 아이디 비밀번호를 활용해 토큰을 발급한다.
- 발급된 토큰의 Refresh Token을 DB에 저장한다.
- reissue
- 유효한 RefreshToken일 경우 재발급 해주는 로직이다.
- 사용된 RefreshToken은 INVALID로 변경시켜 주며, 새로운 토큰을 Refresh와 Access를 함께 보내준다. (RTR 적용을 위해)
- 저장소에 새로 발급된 RefreshToken을 저장한다
- refreshTokenRoation
- DB에서 refreshToken이 존재하는지 확인한다.
- 존재한다면 refreshToken이 유효한지 status를 확인한다.
- 유효하지 않은 refreshToken이 요청올 경우 관련 멤버의 모든 RefreshToken을 INVALID로 변경한다.
- 트랜잭션 안에서 작동하기 때문에, 에러를 반환하지 않고 정상로직으로 작동하며 refreshTokenEntity 값이 INVALID 이기 때문에, 해당 값을 활용하여 Error객체를 반환하도록 설정한다.
- createNewToken
- 이메일과 패스워드를 활용해 새로운 토큰을 발급해준다.
- saveRefreshToken
- Repository에 토큰을 저장한다.
💡
추후 RTR 방식을 변경할 것이다. 현재는 refreshToken이 유효한 경우와 유효하지 않은 경우가 모두 DB에 저장되어 있다. 하지만 이 중에서 유효한 refreshToken을 Redis에 저장시키면 유효한 사용자의 재발급 요청은 더 빠르게 처리될 수 있을 것이다. 사용된 토큰은 redis에서 삭제하면 된다. 유효하지 않은 refreshToken으로 요청이 온다면 RDBMS에 저장된 데이터베이스에서 해당 토큰을 찾고, 만약에 해당 토큰이 저장되어 있다면 이미 사용된 토큰이므로 레디스에서 해당 유저와 관련된 모든 토큰을 만료시킬 수 있을 것이다. 이때 레디스에 저장하는 key는 token이 아니라 userEmail을 기반으로 해야할 것이다. 토큰 만료 시나리오에서 userEmail을 사용해 찾아야하기 때문이다.
LoginMemberRepository
@Repository
public interface LoginMemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
- findByEmail : 이메일을 통해 멤버를 찾아온다
RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
@Modifying(clearAutomatically = true)
@Query("update RefreshToken r set r.status = :refreshTokenStatus where r.member = :member")
void updateAllByMember(@Param("member") Member member, @Param("refreshTokenStatus") RefreshTokenStatus refreshTokenStatus);
}
- @Modifying : 쿼리 수행 후 엔티티매니저의 1차캐시를 삭제해준다.
- member에 해당하는 모든 RefreshToken을 Invalid로 처리한다.
Refresh Token Rotation 시나리오
1. 두개의 계정으로 발급한 리프레시 토큰을 만들어 둔다.

2. 하나의 토큰을 사용해 만료 시키고 새로운 토큰을 발급한다.


3. INVALUD된 토큰으로 재요청을 보내면 update query가 나간다

4. DB를 확인하면 모든 토큰이 INVALID가 되어있는 것을 알 수 있

❗같은 member에 대해서 reissue 요청시 jpa쿼리 2번 나가는 상황 발생






- 같은 멤버이지만, id로 한번 email로 한번 총 2번 조회되는 현상 발견
TokenInfo tokenInfo = createNewToken(refreshTokenEntity.getMember().getEmail(), refreshTokenEntity.getMember().getPassword());
- 영속성 객체는 ID로 관리되는데, email을 통해 불러오려고 하니, 영속성 객체가 아닌 DB를 참고하여 가지고 왔다
- 이후 CustomUserDetailsService 를 수정하여 쿼리를 한번으로 줄일 수 있었다
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final LoginMemberRepository loginMemberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
return loginMemberRepository.findById(Long.valueOf(userId)) // 해당 부분이 findByEmail로 되어 있었음
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(userId + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
return User.builder()
.username(member.getId().toString()) // username에 email을 넣어주던 것을 Id로 수정하였다.
.password(passwordEncoder.encode(member.getPassword()))
.roles(member.getRoles().toArray(new String[0]))
.build();
}
}
- Spring Data JPA 에서도 join fetch를 사용하여 member를 한번에 불러오도록 하였다.
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
@Query("select r from RefreshToken r join fetch r.member where r.refreshToken = :refreshToken")
Optional<RefreshToken> findFetchByRefreshToken(@Param("refreshToken") String refreshToken);
}
- 이후 재발급 서비스에서도 Email이 아닌 Id를 사용하도록 하였으며, JwtProvider에서 sub에도 email이 아니라 id를 넣도록 하였다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class LoginMemberService {
private final LoginMemberRepository loginMemberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public Object reissue(String refreshToken) {
log.info("reissue start");
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// Refresh Token Rotation 기법 사용, 유효한 Refresh Token인지 확인
// 유효하지 않을 경우 Exception 발생
// 유효할 경우 Refresh Token 가져와서 사용
RefreshToken refreshTokenEntity = refreshTokenRotation(refreshToken);
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
return new UsedTokenError("토큰 탈취 에러", HttpStatus.BAD_REQUEST.toString(), "Refresh Token이 이미 사용되었습니다.");
}
refreshTokenEntity.updateStatus(RefreshTokenStatus.INVALID);
log.info(jwtTokenProvider.getMemberId(refreshToken));
// 3. 토큰 생성
TokenInfo tokenInfo = createNewToken(jwtTokenProvider.getMemberId(refreshToken),
refreshTokenEntity.getMember().getPassword()); // createNewToken 부분 ID로 수정
// 4. 저장소에 Refresh Token 저장
saveRefreshToken(tokenInfo, refreshTokenEntity.getMember());
log.info("tokenInfo: {}", tokenInfo);
return tokenInfo;
}
/**
* Refresh Token Rotation 기법 적용
*/
private RefreshToken refreshTokenRotation(String refreshToken) {
log.info("refreshTokenRotation start");
Optional<RefreshToken> token = refreshTokenRepository.findFetchByRefreshToken(refreshToken);
if (token.isEmpty()) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
log.info("token: {}", token);
RefreshToken refreshTokenEntity = token.get();
log.info("refreshTokenEntity: {}", refreshTokenEntity);
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
// 탈취된 토큰이 활용된 것이므로, 관련 멤버의 모든 Refresh Token을 무효화한다.
// refresh 토큰은 한번만 사용할 수 있도록 한다.
refreshTokenRepository.updateAllByMember(refreshTokenEntity.getMember(), RefreshTokenStatus.INVALID);
}
log.info("end refreshTokenRotation");
return refreshTokenEntity;
}
/**
* 새로운 토큰 생성
*/
private TokenInfo createNewToken(String id, String password) {
log.info("createNewToken start");
// 1. Refresh Token으로 MemberEmail 조회
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(id,
password);
log.info("authenticationToken: {}", authenticationToken);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
log.info("authentication: {}", authentication);
// 2. 새로운 토큰 생성 - refresh token도 새로 생성해서 나가게 된다.
return jwtTokenProvider.generateToken(authentication);
}
/**
* createNewToken 오버로딩
*/
private TokenInfo createNewToken(Long id, String password) {
return createNewToken(id.toString(), password); // id를 string으로 바꿔서 넣도록 함
}
/**
* Refresh Token 저장
*/
private void saveRefreshToken(TokenInfo tokenInfo, Member member) {
log.info("saveRefreshToken start");
RefreshToken newRefreshToken = new RefreshToken(tokenInfo.getRefreshToken(), RefreshTokenStatus.VALID, member);
refreshTokenRepository.save(newRefreshToken);
}
}




반응형