회원가입/로그인 기능을 개발하며 Access Token/Refresh Token 인증 방식을 적용하는 과정을 정리한 글입니다.
서론
JWT 기반의 Access Token을 이용한 로그인 방식은 세션을 서버에서 관리하지 않아도 되기 때문에 서버 부담이 적은 편이다.
또한 자체적으로 사용자 정보를 포함하고 있으므로 서버가 상태를 유지할 필요 없이 토큰만 검증하면 되기 때문에 요청마다 사용자 인증을 빠르게 할 수 있다는 장점이 있다. 그러나 사용자가 http 헤더로 보내는 토큰을 기반으로 유저가 판단되기 때문에 공격자에 의해 토큰이 탈취당할 경우, 이 토큰이 만료될 때까지 공격자가 사용자의 권한을 가질 수 있으므로 보안적으로 매우 위험하다고 볼 수 있다.
그렇기 때문에 Access Token의 유효 기간을 짧게 설정하고, Refresh Token을 추가적으로 활용하여 새로운 Access Token을 발급하는 방식이 널리 쓰이게 되었다.
(Refresh Token을 이용하여, Access Token이 만료되면 자동으로 새로운 Access Token을 발급받을 수 있고, 이로 인해 사용자는 매번 재로그인하는 번거로움 없이 서비스를 이용할 수 있다.)

이러한 방식은 다음과 같이 이루어진다.
- 로그인 시 Access Token + Refresh Token을 발급
- Refresh Token을 DB 또는 Redis에 저장하여 관리
- Access Token이 만료되면, 클라이언트가 Refresh Token을 이용해 새 Access Token을 요청
- DB/Redis에서 Refresh Token을 검증한 후 새 Access Token을 발급
상세 내용
버전 및 패키지
- Spring Boot version '3.2.4'
- java 21
- gradle
Dependencies
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
application.yml
jwt:
secret-key: <BASE64로 인코딩한 값>
token:
access-expiration-time: 10800000 # 3시간
refresh-expiration-time: 604800000 # 7일
Refresh Token DB 저장 방식
RefreshToken
@Getter
@Setter
@NoArgsConstructor
@Entity
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String refreshToken;
public RefreshToken(String email, String refreshToken) {
this.email = email;
this.refreshToken = refreshToken;
}
public RefreshToken updateToken(String refreshToken) {
this.refreshToken = refreshToken;
return this;
}
}
RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
// DB에서 유저의 정보로 Refresh Token을 찾기 위함
Optional<RefreshToken> findByEmail(String email);
}
TokenInfoDto
토큰 관련된 정보들을 담는 DTO
@Builder
@Data
public class TokenInfoDto {
private String grantType;
private String accessToken;
private String refreshToken;
}
UserService
login 시 토큰 정보를 가져와 리프레쉬 토큰의 존재 여부를 확인하고, 토큰이 존재하지 않는다면 새로 만들고 DB에 저장한다.
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
@Override
public LoginResponseDto login(LoginRequestDto requestBody) {
var userEntity = getUserEntity(requestBody.getEmail());
if (passwordEncoder.matches(requestBody.getPassword(), userEntity.getPassword())) {
var tokenDto = jwtTokenProvider.generateAccessToken(UserDetailsImpl.from(userEntity));
var accessToken = tokenDto.getAccessToken();
var refreshToken = refreshTokenRepository.findByEmail(userEntity.getEmail());
if (!refreshToken.isPresent()) {
// 없으면 토큰 새로 만들고 DB 저장
var newToken = new RefreshToken(requestBody.getEmail(), tokenDto.getRefreshToken());
refreshTokenRepository.save(newToken);
}
return new LoginResponseDto(accessToken);
} else {
throw new UserNotFoundException();
}
}
}
JwtVerificationFilter
클라이언트의 요청에 대한 인가 작업을 할 때, 헤더의 JWT 토큰을 검증하는 필터 클래스이다.
- Access Token이 유효하면 SecurityContext에 인증 정보를 저장하여 인가(Authorization) 처리를 함
- Access Token이 만료되었으나 RefreshToken이 유효할 경우에는 새로운 Access Token을 발급
(이 과정에서 RefreshToken을 DB(또는 Redis)에서 검증) - DB에 저장된 RefreshToken 토큰 값과 비교하여 검증
@Component
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserService userService;
private final RefreshTokenRepository refreshTokenRepository;
/**
* 요청이 해당 필터를 거치면 실행되는 메소드
* 클라이언트는 토큰값을 헤더에 담아 전송해야함.
* 토큰은 앞에 Bearer를 붙여서 전달해야 함
* 토큰 값 검증 (토큰 유효기간 만료 여부 검증)
* 토큰 검증 완료되었으면 SecurityContextHolder에 context, authenticationToken 세팅
*
* 클라이언트가 header에 토큰값을 실어보내면 doFilterInternal 안에서 토큰 검증 실행
* 인증 객체 생성 후, Security Context에 정보 저장
*
* @param request
* @param response
* @param filterChain
* @throws ServletException
* @throws IOException
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String BEARER_PREFIX = JwtTokenProvider.BEARER_PREFIX;
String accessTokenFromHeader = jwtTokenProvider.getHeaderAccessToken(request);
String refreshTokenFromHeader = jwtTokenProvider.getHeaderRefreshToken(request);
var securityContext = SecurityContextHolder.getContext();
if (accessTokenFromHeader != null) {
// 액세스토큰 값이 유효하면 setAuthentication 통해 securityContext에 인증 정보 저장
if (jwtTokenProvider.validateToken(accessTokenFromHeader)) {
var username = jwtTokenProvider.getUsername(accessTokenFromHeader);
var userDetails = userService.loadUserByUsername(username);
setAuthentication((UserDetailsImpl) userDetails, request, securityContext);
} else if (refreshTokenFromHeader != null) {
// 리프레쉬 토큰 검증 && 리프레쉬 토큰 DB에서 토큰 존재유무 확인
boolean isRefreshToken = validateRefreshToken(refreshTokenFromHeader);
// 리프레쉬 토큰이 유효하고 DB에 있는 것과 비교했을 때 같으면
if (isRefreshToken) {
String username = jwtTokenProvider.getSubject(refreshTokenFromHeader);
// 새로운 액세스 토큰 발급
var userDetails = userService.loadUserByUsername(username);
var newAccessToken = jwtTokenProvider.generateAccessToken((UserDetailsImpl) userDetails);
// 헤더에 액세스 토큰 추가
jwtTokenProvider.setHeaderAccessToken(response, newAccessToken.getAccessToken());
// Security context에 인증 정보 넣기
setAuthentication((UserDetailsImpl) userDetails, request, securityContext);
}
} else {
// 리프레쉬 토큰 만료되었거나 DB에 있는 것과 같지 않다면
jwtExceptionHandler(response, "RefreshToken Expired", HttpStatus.BAD_REQUEST);
return;
}
}
filterChain.doFilter(request, response);
}
public void setAuthentication(UserDetailsImpl userDetails, HttpServletRequest request, SecurityContext securityContext) {
var authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
securityContext.setAuthentication(authenticationToken);
}
/**
* 토큰의 유효성과 만료일자 확인
* refreshToken 토큰 검증
* db에 저장되어 있는 token과 비교
*/
private boolean validateRefreshToken(String jwtToken) {
if (!jwtTokenProvider.validateToken(jwtToken)) return false;
var username = jwtTokenProvider.getUsername(jwtToken);
var refreshToken = refreshTokenRepository.findByEmail(username);
return refreshToken.isPresent() && jwtToken.equals(refreshToken.get().getRefreshToken());
}
// JWT 예외처리
public void jwtExceptionHandler(HttpServletResponse response, String message, HttpStatus status) {
response.setStatus(status.value());
try {
String json = new ObjectMapper().writeValueAsString(HttpStatus.valueOf(status.value()));
response.getWriter().write(json);
} catch (Exception e) {
logger.error(e.getMessage());
}
}
}
Refresh Token Redis 저장 방식
JwtTokenProvider
토큰 생성 및 리턴을 관리하는 클래스이다. 참고로 jwt 0.12.6부터는 hmacShaKeyFor 함수가 일반 바이트 배열도 넘겨받을 수 있도록 변경되면서 Base64로 인코딩/디코딩을 거치지 않아도 HMAC 알고리즘 보안 키를 생성할 수 있다. (전달된 바이트 배열을 HMAC-SHA용 키로 직접 사용)
Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
@Component
public class JwtTokenProvider {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
public static final String BEARER = "Bearer";
public static final String BEARER_PREFIX = "Bearer ";
public static final String AUTHORIZATION = "Authorization";
private final SecretKey key;
private final RedisService redisService;
@Value("${jwt.token.access-expiration-time}")
private long accessExpirationTime;
@Value("${jwt.token.refresh-expiration-time}")
private long refreshExpirationTime;
public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey, AESUtils aesUtils,
RedisService redisService) {
try {
String decryptedSecretKey = aesUtils.decryptWithAesKey(secretKey);
this.key = Keys.hmacShaKeyFor(decryptedSecretKey.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new RuntimeException(ErrorMsg.JWT_SECRET_DECRYPT_ERROR, e);
}
this.redisService = redisService;
}
/**
* subject를 담아 해당 키로 사인함. jwt 토큰 생성 Access Token 만료시점: 현재로부터 3시간 뒤
*/
private TokenDto generateAccessRefreshToken(String subject) {
Instant now = Instant.now();
Instant accessExpiresAt = now.plusMillis(accessExpirationTime);
Instant refreshExpiresAt = now.plusMillis(refreshExpirationTime);
String accessToken = Jwts.builder()
.subject(subject)
.signWith(key)
.issuedAt(Date.from(now))
.expiration(Date.from(accessExpiresAt))
.compact();
String refreshToken = Jwts.builder().subject(subject)
.signWith(key)
.issuedAt(Date.from(now))
.expiration(Date.from(refreshExpiresAt))
.compact();
return TokenDto.builder()
.grantType(BEARER)
.authType(AUTHORIZATION)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
/**
* jwt 토큰 복호화하여 subject 추출 (username)
*/
private String getSubject(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
} catch (JwtException e) {
logger.error("JWT Exception :", e);
throw e;
}
}
/**
* 실제 jwt 인증에서 사용될 함수
*/
public TokenDto generateToken(UserDetails userDetails) {
return generateAccessRefreshToken(userDetails.getUsername());
}
/**
* accessToken으로부터 subject(=username)을 추출하는 함수
*/
public String getUsername(String token) {
return getSubject(token);
}
/**
* 토큰의 유효성과 만료 여부 확인 이미 만료된 토큰에서 페이로드를 파싱하는 과정에서 에러가 발생하기 때문에 예외 처리
*/
public void validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(jwtToken);
logger.info("JWT Expiration :", claims.getPayload().getExpiration());
// exp 날짜가 현재 날짜보다 전에 있지 않으면 토큰 만료
if (claims.getPayload().getExpiration().before(new Date())) {
throw new ExpiredJwtException(null, claims.getPayload(), "JWT Token Expired");
}
} catch (ExpiredJwtException e) {
// 만료된 토큰 예외 던지기
logger.error("Expired JWT token: " + e.getMessage());
throw e;
} catch (JwtException e) {
// 기타 JWT 관련 예외
logger.error("JWT Exception: ", e);
throw new JwtException("Invalid JWT token", e); // 일반적인 JWT 오류 예외 던지기
}
}
/**
* refreshToken 토큰 검증
* redis에 저장된 토큰을 불러와서 비교
*/
public boolean isRefreshTokenMatched(String refreshToken, String redisRefreshToken) {
return StringUtils.hasText(refreshToken) && refreshToken.equals(redisRefreshToken);
}
public void validateRefreshToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(jwtToken);
logger.info("JWT Expiration :", claims.getPayload().getExpiration());
// exp 날짜가 현재 날짜보다 전에 있지 않으면 토큰 만료
if (claims.getPayload().getExpiration().before(new Date())) {
throw new UnauthorizedException();
}
} catch (JwtException e) {
// 기타 JWT 관련 예외
logger.error("JWT Exception: ", e);
throw new UnauthorizedException();
}
}
// 액세스 토큰 헤더 설정
public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
response.setHeader(AUTHORIZATION, BEARER_PREFIX + accessToken);
}
// 헤더 accessToken 리턴
public String getHeaderAccessToken(HttpServletRequest request) {
String accessToken = request.getHeader(AUTHORIZATION);
if (accessToken != null && accessToken.startsWith(BEARER_PREFIX)) {
return accessToken.substring(BEARER_PREFIX.length());
}
return null;
}
/**
* 로그아웃을 하였으나 이전에 사용된 액세스 토큰이 아직 유효한 경우,
* 로그아웃 시에 레디스에 저장한 액세스 토큰값을 불러와 해당 값이 존재하면 (값이 "logout")
* true를 반환
* (JWT 토큰의 유효성을 서버에서 강제로 무효화시킬 수 없기에 redis에 따로 저장해두고 요청 시 확인)
*/
public boolean isAccessTokenLogout(String accessToken) {
Optional<String> token = redisService.getValues(accessToken);
return token.isPresent() && token.get().equals(LoggingMsg.LOGOUT_FLAG);
}
}
private SecretKey key;
토큰을 서명할 때 사용되는 비밀키를 담은 객체
Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
- HMAC-SHA 기반의 키 객체를 생성하는 메서드
- 내부적으로 SHA 알고리즘에 적합한 Key 객체로 변환해줌
- Jwt 서명에 필요한 비밀키 생성
boolean validateToken(String jwtToken)
- 토큰의 유효성과 만료 여부를 확인하는 메서드
- JWT 토큰을 파싱하고 검증하는 과정에서 발생하는 여러 가지 예외를 처리하기 위해 try catch로 감싼 형태
- ExpiredJwtException(토큰 만료), MalformedJwtException(포맷 이상), UnsupportedJwtException(지원하지 않는 형식) 등..
JwtUsernamePasswordAuthFilter
클라이언트로부터의 로그인 요청을 처리하는 Spring Security 필터이다. 사용자의 인증 정보를 검증하고, 인증 성공 시 JWT 토큰 (Access와 Refresh)을 발급하여 응답하는 역할을 한다.
/**
* 클라이언트의 로그인 요청을 처리해주는 인증 필터
* UsernamePasswordAuthenticationFilter를 확장하여 JWT 기반 인증을 수행
*/
//@Component
public class JwtUsernamePasswordAuthFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
private final RedisService redisService;
private final AESUtils aesUtils;
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${aes.key}")
private static String encryptedAesKey;
public JwtUsernamePasswordAuthFilter(AuthenticationManager authenticationManager,
AuthService authService, JwtTokenProvider jwtTokenProvider, RedisService redisService,
AESUtils aesUtils) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
this.authService = authService;
this.redisService = redisService;
this.aesUtils = aesUtils;
}
/**
* 사용자의 인증을 수행하는 메서드
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
try {
ServletInputStream servletInputStream = request.getInputStream();
String requestBody = StreamUtils.copyToString(servletInputStream, StandardCharsets.UTF_8);
// Json data parsing
LoginRequest loginDto = objectMapper.readValue(requestBody, LoginRequest.class);
String requestURI = request.getRequestURI();
// 소셜 로그인
if (requestURI.contains(URL.KAKAO_LOGIN_URL)) {
return authenticateSocialLogin(loginDto.email());
} else {
// 일반 로그인
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(loginDto.email(), loginDto.password());
return authenticationManager.authenticate(authToken);
}
} catch (IOException e) {
logger.error(e);
throw new AuthenticationServiceException(e.getMessage(), e);
}
}
/**
* Spring Security가 인증 과정에서 예외를 던지면 자동으로 호출되는 메서드
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
if (failed instanceof UsernameNotFoundException) {
setErrorResponse(response, HttpStatus.NOT_FOUND, ErrorMsg.USER_NOT_FOUND);
} else if (failed instanceof BadCredentialsException) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED, ErrorMsg.INVALID_CREDENTIALS);
} else {
setErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, failed.getMessage());
}
}
/**
* 인증이 성공적으로 완료되면 실행되는 메소드
*/
@Override
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 토큰 생성
UserDetailsImpl userDetails = (UserDetailsImpl) authResult.getPrincipal();
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
tokenDto.setEncryptedRefreshToken(aesUtils.encryptWithAesKey(refreshToken));
// 헤더에 액세스 토큰 추가
jwtTokenProvider.setHeaderAccessToken(response, accessToken);
// Redis에 Refresh Token 저장 (key = email)
redisService.setStringValue(userDetails.getUsername(), tokenDto.getRefreshToken(),
jwtTokenProvider.getRefreshExpirationTime());
setResponseEncoding(response);
String jsonResponse = objectMapper.writeValueAsString(tokenDto);
response.getWriter().write(jsonResponse);
}
private void setErrorResponse(HttpServletResponse response, HttpStatus status, String message) {
response.setStatus(status.value());
setResponseEncoding(response);
try {
ErrorResponse errorResponse = new ErrorResponse(status, message);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
} catch (IOException e) {
logger.error(e);
}
}
private void setResponseEncoding(HttpServletResponse response) {
response.setContentType("application/json; charset=utf-8");
response.setCharacterEncoding("UTF-8");
}
private Authentication authenticateSocialLogin(String email) {
UserDetails userDetails = authService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
return authToken;
}
}
attemptAuthentication
로그인 요청이 들어올 때 인증 과정을 수행하는 메서드
- request.getInputStream()을 사용하여 바디 데이터를 문자열로 읽어와 LoginRequestDto 객체로 변환
- UsernamePasswordAuthenticationToken 객체를 생성하여 AuthenticationManager의 authenticate() 메서드를 통해 인증 요청
- (AuthenticationManager가 내부적으로 UserDetailsService를 사용하여 사용자를 조회하고 비밀번호 검증을 수행. 성공 시 인증된 Authentication 객체를 반환)
successfulAuthentication
- 인증 완료 후 실행되는 메서드
- JWT 액세스 토큰과 리프레쉬 토큰을 생성
- 생성된 JWT 토큰을 HTTP 응답 헤더에 추가하여 클라이언트가 이후 요청에서 사용할 수 있도록 함
unsuccessfulAuthentication
- Spring Security가 인증 과정에서 예외를 던지면 자동으로 호출되는 메서드
JwtVerificationFilter
클라이언트의 요청에 대한 인가 작업을 할 때, 헤더의 JWT 토큰을 검증하는 필터 클래스이다.
- Access Token이 유효하면 SecurityContext에 인증 정보를 저장하여 인가(Authorization) 처리를 함
@Component
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
public JwtVerificationFilter(JwtTokenProvider jwtTokenProvider, AuthService authService) {
this.jwtTokenProvider = jwtTokenProvider;
this.authService = authService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI().trim().toLowerCase();
var accessToken = jwtTokenProvider.getHeaderAccessToken(request);
// Access Token이 없는 경우 바로 요청 통과
if (accessToken == null) {
logger.warn(LoggingMsg.ACCESS_TOKEN_MISSING + requestURI);
filterChain.doFilter(request, response);
return;
}
try {
var securityContext = SecurityContextHolder.getContext();
// 유효한 토큰인지 검사 (유효하지 않으면 예외 발생)
jwtTokenProvider.validateToken(accessToken);
// 로그아웃 이후에도 유효한 액세스 토큰인지 검사 (예외)
if (jwtTokenProvider.isAccessTokenLogout(accessToken)) {
throw new UnauthorizedException(HttpStatus.UNAUTHORIZED, ErrorMsg.LOGIN_EXPIRED);
}
var username = jwtTokenProvider.getUsername(accessToken);
var userDetails = authService.loadUserByUsername(username);
// 액세스토큰 값이 유효하면 setAuthentication 통해 securityContext에 인증 정보 저장
setAuthentication((UserDetailsImpl) userDetails, request, securityContext);
} catch (ExpiredJwtException e) {
// 만료된 토큰일 경우
SecurityContextHolder.clearContext();
// JwtExceptionFilter에서 예외 처리하도록 던짐
throw e;
} catch (JwtException e) {
// 기타 JWT 예외일 경우
throw e;
}
filterChain.doFilter(request, response);
}
public void setAuthentication(UserDetailsImpl userDetails, HttpServletRequest request, SecurityContext securityContext) {
var authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContext에 인증 정보 저장
securityContext.setAuthentication(authenticationToken);
}
// ALLOWED_URLS 매칭 메서드 (ALLOWED_URLS에 포함된 요청이면 JWT 검증을 건너뛰고 다음 필터로 진행)
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return Arrays.stream(URL.ALLOWED_URLS)
.anyMatch(allowedUrl -> allowedUrl.equals(requestURI));
}
}
doFilterInternal()
- 클라이언트가 Authorization: Bearer <Access Token>을 포함하여 API 요청을 하면 JwtVerificationFilter를 거치면서 실행되는 메서드
- request의 헤더에서 액세스 토큰값과 리프레쉬 토큰값을 가져와 토큰 값이 유효한지, 비어있지 않은지를 검증
- 토큰이 검증이 정상적으로 완료되었으면 setAuthentication()을 호출하여 SecurityContext에 인증 정보를 저장 (현재 요청이 인증되었음을 SecurityContext에 저장하여 이후 요청에서 사용자가 인증된 상태로 인식될 수 있도록 한다)
SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtVerificationFilter jwtVerificationFilter;
private final JwtExceptionFilter jwtExceptionFilter;
private final AuthService authService;
private final RedisService redisService;
private final AESUtils aesUtils;
// AuthenticationManager의 Bean을 얻기 위한 authConfiguration 객체
private final AuthenticationConfiguration authenticationConfiguration;
public SecurityConfig(JwtVerificationFilter jwtVerificationFilter,
JwtExceptionFilter jwtExceptionFilter, AuthService authService,
AuthenticationConfiguration authenticationConfiguration,
RedisService redisService, AESUtils aesUtils) {
this.jwtVerificationFilter = jwtVerificationFilter;
this.jwtExceptionFilter = jwtExceptionFilter;
this.authService = authService;
this.redisService = redisService;
this.authenticationConfiguration = authenticationConfiguration;
this.aesUtils = aesUtils;
}
/**
* AuthenticationConfiguration로부터 AuthenticationManager 객체 가져오는 메서드
*/
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public JwtUsernamePasswordAuthFilter jwtUsernamePasswordAuthFilter(AuthenticationManager authenticationManager, JwtTokenProvider jwtTokenProvider, RedisService redisService)
throws Exception {
var filter = new JwtUsernamePasswordAuthFilter(authenticationManager, authService, jwtTokenProvider, redisService, aesUtils);
filter.setAuthenticationManager(authenticationManager());
return filter;
}
@Bean
public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE"));
configuration.setAllowedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception {
// 일반 로그인 필터 (일반 로그인 경로에만 적용)
JwtUsernamePasswordAuthFilter loginFilter = new JwtUsernamePasswordAuthFilter(authenticationManager(), authService, jwtTokenProvider, redisService, aesUtils);
loginFilter.setFilterProcessesUrl(URL.NORMAL_LOGIN_URL);
// 카카오 로그인 필터 (카카오 로그인 경로에만 적용)
JwtUsernamePasswordAuthFilter socialLoginFilter = new JwtUsernamePasswordAuthFilter(authenticationManager(), authService, jwtTokenProvider, redisService, aesUtils);
socialLoginFilter.setFilterProcessesUrl(URL.KAKAO_LOGIN_URL);
// OncePerRequestFilter 등록하여 경로에 따라 필터를 분기 처리
UrlBasedAuthenticationFilter filter = new UrlBasedAuthenticationFilter(loginFilter, socialLoginFilter);
http
.cors(Customizer.withDefaults())
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((requests) ->
requests
.requestMatchers(HttpMethod.POST, URL.ALLOWED_URLS)
.permitAll()
.anyRequest()
.authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(jwtVerificationFilter, JwtUsernamePasswordAuthFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtVerificationFilter.class)
.httpBasic(HttpBasicConfigurer::disable)
.formLogin(FormLoginConfigurer::disable);
return http.build();
}
}
authenticationManager()
- Spring Security의 AuthenticationManager를 Bean으로 등록
- 로그인 인증을 처리하는 핵심 객체
- CustomUsernamePasswordAuthenticationFilter에서 사용된다
정리
JWT 기반 인증에서 RefreshToken을 DB에 저장하는 방식은 사용자 세션을 더 효과적으로 관리할 수 있도록 도와주는 역할을 한다. 예를 들어 A라는 유저가 계정 탈취로 인해 RefreshToken을 탈취당한 경우, DB에서 RefreshToken을 관리한다면 RefreshToken을 삭제(무효화)해버리는 식으로 보안적인 측면에서 도움을 받을 수 있다.
Access Token은 만료될 때까지 유효하지만, Refresh Token을 DB에서 관리하면 필요한 상황이 발생했을 시 바로 폐기 가능
그러나 Refresh Token은 DB에서 확인해야 하므로, 매번 검증을 요청할 때마다 DB 조회가 필요하다. 예를 들어, 백만 단위의 여러 사용자가 Refresh Token을 동시에 요청하게 된다면 DB 부하가 급증하고 성능이 하락할 수 있다는 단점이 있다. 이러한 문제점을 개선하기 위해 Redis와 같은 인메모리 DB에 Refresh Token을 저장하는 방식이 나오게 되었다.
Refresh Token은 단기적인 세션 관리 용도이므로, 빠르게 저장/조회할 수 있어야 함
위와 같은 이유로 Redis에 저장하는 방식을 적용하였다.