Spring Security 프로젝트 설정 7 - JWT Refresh Token 재발급
✒️ 2025-05-28 14:24 내용 수정
- code : https://github.com/ase10git/SpringSecurityTest
- SpringSecurity 프로젝트 설정 목록
- Spring Security 기본 사용자 추가 및 테스트
- Spring Security 프로젝트 설정 1 - DB연결과 JPA 설정
- Spring Security 프로젝트 설정 2 - JwtService와 Filter 설정
- Spring Security 프로젝트 설정 3 - Security Config
- Spring Security 프로젝트 설정 4 - Authentication Service와 Controller
- Spring Security 프로젝트 설정 5 - Security CORS 설정
- Spring Security 프로젝트 설정 6 - JWT Refresh Token 생성 및 저장
- Spring Security 프로젝트 설정 7 - JWT Refresh Token 재발급
- Spring Security 프로젝트 설정 8 - JWT 클라이언트 저장
- Spring Security 프로젝트 설정 9 - JWT 로그아웃
- Spring Security 프로젝트 설정 10 - 권한 설정
재발급을 어디서 진행할까
참고 자료 : Learn With Ifte's Implementing Secure Refresh Tokens in Spring Boot part 2, Access Token의 한계와 Refresh Token의 필요성, Inflearn refresh token filter, Spring Security를 활용한 JWT 로그인 구현(Access Token, Refresh Token)
- 이전 Spring Security 프로젝트 설정 6 - JWT Refresh Token 생성 및 저장에선 Refresh Token을 생성하고 저장하는 방법을 진행했다.
- 재발급 과정을 어디서 어떻게 구현할지 찾아봤는데, 처음에는
Filter에서 수행하는 것처럼 보이는 방법과 Controller로 들어온 요청을 Service에서 처리하는 것처럼 보이는 방법이 있었다. - 그러나
Filter에서 처리하는 것처럼 보인 방법도 Controller에서/refresh-token형태의 GET이나 POST 요청을 받아 Service에서 처리하는 방법이었다. - 다른 방법이라고 생각했던 이유가
Filter에서 Access Token을 검증하는 것처럼 Refresh Token을 검증하는 로직이 필요하지 않을까 하는 생각이 들었기 때문이다.- 하지만 처음부터 사용자가 요청을 보낼 때 Access Token과 Refresh Token을 따로 구별해서 동시에 보내는 것이 아니고, 두 Token이 다른 형태(JWT와 String, UUID 차이)가 아니라면 Authorization Header의 Bearer Schema로 들어온 Token이 Access Token인지 Refresh Token인지 구별할 방법이 없다.
- 또 생각해보니
Filter에서 자동으로 재발급 처리를 못하는 것은 아니지만 이 요청이 재발급을 위한 요청인지 리소스를 요청하는 건지 구분하지 않는 이상 의도치 않게 Token을 멋대로 재발급 해버릴 수 있어 Access Token이 만료되면 클라이언트에서 Refresh Token으로 재발급 요청을 보내는 것이 맞는 흐름처럼 보였다. - 결국 Controller로부터 재발급 요청이 들어오면 Service에서 이를 처리하는 동작이 필요하다.
Access Token 재발급 과정
- 아래는 Filter에서 요청에 담긴 Token의 유효성에 따른 절차를 정리한 그림이다.
- 원본 이미지는 Access Token의 한계와 Refresh Token의 필요성이며, 이미지를 참고하여 위의 로그인 차트의 과정과 연결되도록 수정했다.

- 클라이언트에서 로그인을 진행하며 서버에 email과 password를 전송한다.
- 서버에선 이 정보를 DB의 사용자 정보와 비교한다.
- 로그인이 완료되면 서버는 Refresh Token을 DB에 저장하고, Access Token과 Refresh Token을 클라이언트에 전달한다.
- Token 저장은 어느 DB에 할지 고민했는데, 보통은 Redis(Remote Dictionary Server)에 저장한다고 한다. 다만 현재 프로젝트는 Redis로 Token을 관리하는 기능보단 프로젝트에서 제공하는 서비스들을 구현하는데 더 시간을 쓰기 위해서 Token 저장은 데이터를 저장하고 있는 DB에 같이 저장하기로 했다.
- 클라이언트는 응답으로 받은 Access Token은 로컬 변수에, Refresh Token은 보안 설정을 적용한 cookie에 저장한다.
- 클라이언트는 이후 새 요청을 보낼 때 Access Token을 Authorization Header에 담아 서버로 API 요청을 보낸다.
- 만약 Access Token이 만료되었다면 서버에선 Access Token이 만료되었다는 응답을 전송한다.
- 클라이언트에서 응답을 받은 후 Access Token 재발급을 위해 Refresh Token을 서버에 전달한다.
- 서버에서 Refresh Token을 검증하고, Refresh Token이 유효하다면 Access Token 재발급을 진행하고, 만료되었다면 bad_request 응답을 전송한다.
같은 Refresh Token으로 계속해서 재발급 요청을 받지 못하게 하기
- Refresh Token을 재발급 할 때 RTR(Refresh Token Rotation) 방법을 사용하여 Refresh Token을 한 번만 사용하도록 하는 방법이 있다.
- 이 방법을 사용하면 하나의 Refresh Token으로 Access Token을 계속해서 발급 받는 공격을 피할 수 있다.
- 하지만 RTR도 Access Token이나 최초 생성된 Refresh Token이 처음부터 탈취 되었을 때의 문제를 완벽하게 피하긴 어렵다.
- 다만 Refresh Token이 중간에 탈취 당하여 새 Access Token을 발급 받으려 하는 동작을 방지할 수 있을 것이라 생각하여 이를 프로젝트에 적용해보기로 했다.
Access Token 재발급 설정
- 테스트를 진행하면서 Access Token과 Refresh Token의 만료 상태도 확인해야 하기에
application.yml또는application.properties파일에서 Access Token과 Refresh Token의 만료일을 짧게 수정하고 진행했다.
Repository 수정
- 처음엔
TokenEntity에revokedfield나isLoggedOutfield를 추가하려 했으나, RTR 방법에 맞춰 기존 Refresh Token을 삭제하면 두 field가 필요 없을 것으로 생각하여 수정하지 않았다. - Repository에 특정 사용자의 모든 Refresh Token을 조회하는 동작을 추가하여 이후 특정 사용자의 모든 Token 삭제 동작에서 호출할 예정이다.
- 현재
TokenEntity에선 사용자의 email을 저장하기에 email로 검색한다.
- 현재
package com.example.security.token;
import com.example.security.user.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface TokenRepository extends JpaRepository<Token, String> {
// Refresh Token으로 검색
Optional<Token> findByRefreshToken(String token);
// 사용자로 Refresh Token 검색
List<Token> findAllByEmail(String email);
}
Controller 수정
- Controller에서 재발급 요청을 받기 위한 POST
/refresh-tokenendpoint를 추가한다. - 여기서 커스텀 Request 객체를
@RequestBody로 받는 것이 아닌HttpServletRequest를 받아 Request의 모든 영역(Header를 가져오기 위해)에 접근할 수 있도록 한다.
package com.example.security.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService service;
// 회원가입
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register (
@RequestBody RegisterRequest request
) {
return ResponseEntity.ok(service.register(request));
}
// 인증
@PostMapping("/authenticate")
public ResponseEntity<AuthenticationResponse> authenticate (
@RequestBody AuthenticationRequest request
) {
return ResponseEntity.ok(service.authenticate(request));
}
// 재발급
@PostMapping("/refresh-token")
public ResponseEntity<AuthenticationResponse> authenticate (
HttpServletRequest request,
HttpServletResponse response
) {
return service.refreshToken(request, response);
}
}
AuthenticationService에 재발급 메소드 추가
@Transactional추가- 몇몇 참고 자료에서 Service 동작에
@TransactionalAnnotation을 추가하였는데, 사용자 인증과 Token 발급 절차가 함께 묶여 있는 경우 특정 동작을 수행하다가 에러가 발생하여 사용자는 인증이 되었는데 Token이 저장되지 못했거나 혹은 Token 처리에 문제가 생겨 DB에 이상한 데이터가 저장되는 등의 동작을 막기 위함으로 보였다. - 따라서 안전한 인증 절차를 위해
@Transactional을 추가하였다.
- 몇몇 참고 자료에서 Service 동작에
- 재발급 메소드 추가
- Access Token을 재발급 하기 위해 먼저 요청에 들어온 Authorization Header에서 Refresh Token을 추출한다.
- Token이 존재하지 않거나 Header가 일치하지 않으면 재발급을 진행하지 않는다.
- 추출한 Token에서 사용자 이메일 정보를 가져오고, DB에서 해당 사용자를 조회하여 사용자 일치 여부를 확인한다.
- Spring Security 프로젝트 설정 7 - JWT Refresh Token 재발급#JwtService 수정에서 추가할
isRefreshTokenValid()메소드로 요청으로 온 Refresh Token의 유효성 검사를 진행하고, 유효하다면 Access Token과 Refresh Token을 생성한다.- 이 때 기존 DB에 저장된 Refresh Token을 제거하고, 새로 만든 Refresh Token을 저장한다.
- 응답용 클래스
AuthenticationResponse에 두 Token을 담아ResponseEntity의 Body에 담고, 상태 코드를OK로 설정하여 반환한다. - 위에서
isRefreshTokenValid()메소드의 유효성 검사를 통과하지 못했다면 Access Token 재발급 절차 없이UNAUTHORIAZED상태를 담은ResponseEntity를 반환한다.
package com.example.security.auth;
import com.example.security.config.JwtService;
import com.example.security.user.Role;
import com.example.security.user.User;
import com.example.security.user.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthenticationService {
// DB와 상호작용하는 사용자 repo
private final UserRepository repository;
// 비밀번호 인코더
private final PasswordEncoder passwordEncoder;
// jwt 서비스
private final JwtService jwtService;
// 사용자 신원 확인
private final AuthenticationManager authenticationManager;
// ...
// Access Token 재발급
@Transactional
public ResponseEntity<AuthenticationResponse> refreshToken(
HttpServletRequest request,
HttpServletResponse response
) {
// request의 authorization header에서 token 추출
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return new ResponseEntity<>(null, HttpStatus.UNAUTHORIZED);
}
// Refresh Token 추출
String token = authHeader.substring(7);
// jwt로부터 사용자 이메일을 추출
String userEmail = jwtService.extractUsername(token);
// 검증 절차
// 사용자 존재 여부
User user = repository.findByEmail(userEmail)
.orElseThrow(()->new UsernameNotFoundException("No user found"));
// Refresh Token 유효성 검사
if (jwtService.isRefreshTokenValid(token, user)) {
// 유효할 경우 재발급 진행
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
// 기존에 db에 저장된 Refresh Token 제거
jwtService.removeUserToken(token, user);
// 토큰을 db에 저장
jwtService.saveUserToken(refreshToken, user);
return new ResponseEntity<>(
new AuthenticationResponse(
accessToken,
refreshToken
),
HttpStatus.OK
);
}
return new ResponseEntity<>(null, HttpStatus.UNAUTHORIZED);
}
}
- 로그인을 진행하는
authenticate()메소드에서 로그인을 진행하면 DB에 저장된 해당 사용자의 모든 Refresh Token을 제거하는removeAllUserToken()와 생성한 Refresh Token을 DB에 저장하는saveUserToken()를 추가한다.- 해당 메소드는 아래
JwtService에서 구현한다. - 로그인을 진행했을 때는 새로운 Access Token과 Refresh Token을 발급 받기 때문에 기존에 저장된 Refresh Token을 유지할 필요가 없어 DB에서 제거하도록 구현했다.
- 해당 메소드는 아래
package com.example.security.auth;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthenticationService {
// DB와 상호작용하는 사용자 repo
private final UserRepository repository;
// 비밀번호 인코더
private final PasswordEncoder passwordEncoder;
// jwt 서비스
private final JwtService jwtService;
// 사용자 신원 확인
private final AuthenticationManager authenticationManager;
// ...
// 인증 확인 - 로그인
@Transactional
public AuthenticationResponse authenticate(AuthenticationRequest request) {
// 요청으로 들어온 사용자의 신원 확인
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
// 위의 인증을 거친 사용자를 DB에 검색
var user = repository.findByEmail(request.getEmail())
.orElseThrow();
// 토큰 생성 - 사용자 정보로 생성
var accessToken = jwtService.generateAccessToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
// 기존에 db에 저장된 사용자의 모든 Refresh Token 제거
jwtService.removeAllUserToken(user);
// 토큰을 db에 저장
jwtService.saveUserToken(refreshToken, user);
// 인증 응답 객체 생성
return AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// ...
}
JwtService 수정
- Access Token과 Refresh Token의 유효성 검사를 분리
- 현재 거의 사용자 여부와 만료 기한 비교를 위주로 검사를 진행했는데, 사용자 비교 부분에서 이후 중복된다고 판단되면 과정을 줄일 것 같다.
| Token | 검사항목 |
|---|---|
| Access Token | Token 내의 사용자 정보와 DB에 있는 사용자 정보 비교 |
| Token의 만료 여부 | |
| Refresh Token | Token 내의 사용자 정보와 DB에 있는 사용자 정보 비교 |
| Token의 만료 여부 | |
| DB에 저장된 해당 Refresh Token 존재 여부 | |
| Token의 사용자 정보와 DB Token의 사용자 정보 비교 | |
| DB의 사용자 정보와 DB Token의 사용자 정보 비교 |
// Access Token 유효성 검사
public boolean isAccessTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
// 토큰의 사용자 정보가 DB의 정보와 일치 여부 + 만료 기한 확인
// DB에 사용자 정보가 없다면 여기서 false를 반환하여 유효하지 않음을 확인
return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
}
// Refresh Token 유효성 검사
public boolean isRefreshTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
// DB에 저장된 토큰 정보 가져오기
Token dbToken = tokenRepository.findByRefreshToken(token).orElse(null);
// 요청에 들어온 토큰 정보 유효성
// DB 사용자와 토큰의 사용자 정보 일치 여부, 토큰 만료 여부
boolean isValidRequestToken =
(username.equals(userDetails.getUsername())) && !isTokenExpired(token);
// DB에 저장된 토큰 정보 유효성
// DB에 토큰 존재 여부
// 요청 사용자와 DB에 저장된 토큰의 사용자 정보 일치 여부
boolean isValidDbToken = dbToken != null
&& username.equals(dbToken.getEmail())
&& userDetails.getUsername().equals(dbToken.getEmail());
return isValidRequestToken && isValidDbToken;
}
- DB에 저장된 Refresh Token 제거
- RTR 방법을 적용하기 위해 Refresh Token으로 Access Token을 재발급 받거나 로그인 및 로그아웃을 수행했을 때 DB에 저장된 Token을 제거한다.
tokenRepository의delete()메소드를 사용하여 요청으로부터 온 Token 정보로TokenEntity 인스턴스를 생성하여 메소드 매개변수로 넘겨주면 해당 인스턴스와 일치하는 데이터를 DB에서 제거한다.- 특정 사용자의 모든 Refresh Token을 제거하기 위해 Spring Security 프로젝트 설정 7 - JWT Refresh Token 재발급#Repository 수정에서 생성한
findAllByEmail()메소드로 Token을 조회한 후,List.forEach()메소드를 사용하여 리스트 내의 모든 요소에 대해 삭제 동작tokenRepository::delete을 수행한다.
// DB와 상호작용하는 token repo
private final TokenRepository tokenRepository;
// DB에서 토큰 제거
public void removeUserToken(String refreshToken, User user) {
Token token = new Token(refreshToken, user.getEmail());
tokenRepository.delete(token);
}
// DB에 저장된 사용자의 모든 토큰 제거
public void removeAllUserToken(User user) {
List<Token> list = tokenRepository.findAllByEmail(user.getEmail());
list.forEachdelete;
}
- 변경 사항을 모두 종합하여
JwtService를 아래와 같이 수정했다.
package com.example.security.config;
import com.example.security.token.Token;
import com.example.security.token.TokenRepository;
import com.example.security.user.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@Service
@RequiredArgsConstructor
public class JwtService {
// DB와 상호작용하는 token repo
private final TokenRepository tokenRepository;
@Value("${app.security.jwt.secret-key}")
private String secretKey;
// Access Token 만료기한
@Value("${app.security.jwt.access-token-expiration}")
private long accessTokenExpiration;
// Refresh Token 만료기한
@Value("${app.security.jwt.refresh-token-expiration}")
private long refreshTokenExpiration;
// DB에 토큰 저장
public void saveUserToken(String refreshToken, User user) {
Token token = new Token(refreshToken, user.getEmail());
tokenRepository.save(token);
}
// DB에서 토큰 제거
public void removeUserToken(String refreshToken, User user) {
Token token = new Token(refreshToken, user.getEmail());
tokenRepository.delete(token);
}
// DB에 저장된 사용자의 모든 토큰 제거
public void removeAllUserToken(User user) {
List<Token> list = tokenRepository.findAllByEmail(user.getEmail());
list.forEachdelete;
}
// 토큰에서 사용자 이름 추출
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// 클레임 추출
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
// Access 토큰 생성 - UserDetail로만 생성
public String generateAccessToken(UserDetails userDetails) {
return generateAccessToken(new HashMap<>(), userDetails);
}
// Access 토큰 생성
public String generateAccessToken(
Map<String, Object> extraClaims, // 토큰에 보낼 정보
UserDetails userDetails
) {
return generateToken(extraClaims, userDetails, accessTokenExpiration);
}
// Refresh 토큰 생성 - UserDetail로만 생성
public String generateRefreshToken(UserDetails userDetails) {
return generateRefreshToken(new HashMap<>(), userDetails);
}
// Refresh 토큰 생성
public String generateRefreshToken(
Map<String, Object> extraClaims, // 토큰에 보낼 정보
UserDetails userDetails
) {
return generateToken(extraClaims, userDetails, refreshTokenExpiration);
}
// 토큰 생성
private String generateToken(
Map<String, Object> extraClaims, // 토큰에 보낼 정보
UserDetails userDetails,
long expireTime
) {
return Jwts
.builder()
.setClaims(extraClaims) // 클레임 추가
.setSubject(userDetails.getUsername()) // subject 추가
.setIssuedAt(new Date(System.currentTimeMillis())) // 토큰 발행일
.setExpiration(new Date(System.currentTimeMillis() + expireTime)) // 만료기한
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
// Access Token 유효성 검사
public boolean isAccessTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
// 토큰의 사용자 정보가 DB의 정보와 일치 여부 + 만료 기한 확인
// DB에 사용자 정보가 없다면 여기서 false를 반환하여 유효하지 않음을 확인
return (username.equals(userDetails.getUsername()))
&& !isTokenExpired(token);
}
// Refresh Token 유효성 검사
public boolean isRefreshTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
// DB에 저장된 토큰 정보 가져오기
Token dbToken = tokenRepository.findByRefreshToken(token).orElse(null);
// 요청에 들어온 토큰 정보 유효성
// DB 사용자와 토큰의 사용자 정보 일치 여부, 토큰 만료 여부
boolean isValidRequestToken =
(username.equals(userDetails.getUsername()))
&& !isTokenExpired(token);
// DB에 저장된 토큰 정보 유효성
// DB에 토큰 존재 여부
// 요청 사용자와 DB에 저장된 토큰의 사용자 정보 일치 여부
boolean isValidDbToken = dbToken != null
&& username.equals(dbToken.getEmail())
&& userDetails.getUsername().equals(dbToken.getEmail());
return isValidRequestToken && isValidDbToken;
}
// 토큰 만료 확인
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
// 토큰에서 만료 기한 가져오기
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
// jwt에서 모든 클레임 추출
private Claims extractAllClaims(String token) {
return Jwts
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
// jwt 서명에 사용하는 비밀 키 생성
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
JwtAuthenticationFilter 수정
JwtService의 Token 유효성 검사가 수정되었기에JwtAuthenticationFilter에서 Token의 유효성을 검사하는 메소드를isTokenValid()에서isAccessTokenValid()로 수정한다.- 이
Filter에선 Refresh Token 검사가 아닌 Access Token 검사를 진행하기 때문에isAccessTokenValid()를 사용한다.
- 이
package com.example.security.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
// 요청이 들어왔을 때 처리할 작업
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request, // 요청
@NonNull HttpServletResponse response, // 응답
@NonNull FilterChain filterChain // 필터들
) throws ServletException, IOException {
// 요청으로부터 온 header의 내용 추출
// org.springframework.http.HttpHeaders의 HttpHeaders.AUTHORIZATION도 가능
final String authHeader = request.getHeader("Authorization");
// jwt
final String jwt;
// 사용자 이메일
final String userEmail;
// jwt가 없으면 요청을 이후 필터로 전달
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
// jwt는 Authorization header에 Bearer schema를 사용한다.
filterChain.doFilter(request, response);
return;
}
// token 추출
jwt = authHeader.substring(7); // "Bearer "는 7글자
// jwt로부터 사용자 이메일을 추출
userEmail = jwtService.extractUsername(jwt);
// 검증 절차
// 사용자가 존재하고, 아직 인증을 진행하지 않아 SecurityContextHolder에 저장되지 않았을 때
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// DB에서 해당 사용자 검색
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
// jwt 유효성 확인
if (jwtService.isAccessTokenValid(jwt, userDetails)) {
// Spring SecurityContext에 업데이트에 필요한 객체
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
// 항상 작업이 끝나면 다음 필터로 넘겨줘야 함
filterChain.doFilter(request, response);
}
}
Test
- 애플리케이션을 실행한 후 POSTMAN이나 TalenAPI에서
http://localhost:9000/api/v1/auth/register에 POST 요청으로 회원가입 과정을 수행한다.- 이 때 Response로 Access Token과 Refresh Token을 발급 받고, 사진 오른쪽에 DB에선 Refresh Token이 저장되어 있다.
- 이번엔
http://localhost:9000/api/v1/auth/authenticatePOST 요청으로 로그인을 진행하여 새 Access Token과 Refresh Token을 발급 받았다.- 사진의 오른쪽에선 1번으로 발급 받은 Refresh Token으로 DB에 정보를 조회했더니 아무 데이터가 나오지 않는 것을 볼 수 있다.
- 즉 로그인한 사용자의 이름으로 원래 DB에 저장되어 있던 모든 Refresh Token을 삭제하였다.
- 로그인으로 새로 발급 받은 Refresh Token으로 DB에 Token을 조회하면 Token이 조회 되는 것을 확인할 수 있으며, 이를 통해 로그인을 수행하면 기존 Refresh Token을 제거하고 새로 생성한 Refresh Token을 DB에 저장하는 동작이 잘 이루어 지고 있음을 확인할 수 있다.
- 다음은 Access Token 재발급 요청을 위해
http://localhost:9000/api/v1/auth/authenticatePOST 요청을 작성하고, 2번에서 발급 받은 Refresh Token을 Authorization Bearer Token에 담아 요청을 전송한다. - 요청이 잘 이루어졌다면 새 Access Token과 Refresh Token이 응답으로 온다.
- 기존 Token(여기선 2번에서 발급 받은 Refresh Token)이 삭제되었는지 확인하기 위해 DB에 조회를 하면 Token이 조회되지 않는다.
- 5번에서 발급 받은 Refresh Token으로 DB에 조회하면 데이터가 조회되는 것을 확인할 수 있으며, 3번의 결과와 마찬가지로 재발급 절차를 진행했을 때도 기존 Refresh Token이 제대로 삭제되었음을 알 수 있다.
- 만약 임의로 변형된 Token으로 재발급 요청을 보냈다면 서버에서 Token이 유효하지 않다는 응답을 전송한다.
- 재발급 절차가 실제로 진행되었다면 5번에서 발급 받은 Token이 DB에서 삭제될텐데, 실제로 DB에 조회해보면 Token이 그대로 남아있다.
- 따라서 유효하지 않은 Token으로 재발급 요청을 보냈을 때 재발급이 진행되지 않았음을 확인할 수 있다.