회원가입/로그인 기능을 개발하며 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일
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();
}
}
}
JwtTokenProvider
토큰 생성 및 리턴을 관리하는 클래스이다. 참고로 jwt 0.12.6부터는 hmacShaKeyFor 함수가 일반 바이트 배열도 넘겨받을 수 있도록 변경되면서 Base64로 인코딩/디코딩을 거치지 않아도 HMAC 알고리즘 보안 키를 생성할 수 있다. (전달된 바이트 배열을 HMAC-SHA용 키로 직접 사용)
Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
private SecretKey key;
public static final String REFRESH = "Refresh";
public static final String BEARER_PREFIX = "Bearer ";
public static final String AUTHORIZATION = "Authorization";
// 토큰 유효시간(3시간)
@Value("${jwt.token.access-expiration-time}")
private long accessExpirationTime;
// refreshToken 유효시간 7일
@Value("${jwt.token.refresh-expiration-time}")
private long refreshExpirationTime;
// @Value 통해서 yml 파일에 secret-key 값 읽어와서 키 값 초기화
// public JwtTokenProvider(@Value("${jwt.secret-key}") String key) {
// // secret-key 다시 base64 디코딩하여 토큰 생성 시 필요한 키로 만듬
// this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
// }
public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) {
// Keys.hmacShaKeyFor()가 일반 바이트 배열도 받을 수 있음. (jwt 0.12.6) Base64 디코딩 작업 불필요. 라이브러리 내부에서 자동처리
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
/**
subject를 담아 해당 키로 사인함. jwt 토큰 생성
만료시점: 현재로부터 3시간 뒤
*/
private TokenInfoDto generateToken(String subject) {
Instant now = Instant.now();
Instant accessExpireAt = now.plusMillis(accessExpirationTime);
Instant refreshExpireAt = now.plusMillis(refreshExpirationTime);
String accessToken = Jwts.builder().subject(subject)
.issuedAt(Date.from(now))
.expiration(Date.from(accessExpireAt))
.signWith(key)
.compact();
String refreshToken = Jwts.builder().subject(subject)
.issuedAt(Date.from(now))
.expiration(Date.from(refreshExpireAt))
.signWith(key)
.compact();
return TokenInfoDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
/**
jwt 토큰 복호화하여 subject 추출 (username)
*/
public 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 TokenInfoDto generateAccessToken(UserDetailsImpl userDetails) {
return generateToken(userDetails.getUsername());
}
/**
* accessToken으로부터 subject(=username)을 추출하는 함수
*/
public String getUsername(String accessToken) {
return getSubject(accessToken);
}
/**
* 토큰의 유효성과 만료 여부 확인
*/
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(jwtToken);
logger.info("JWT Expiration :", claims.getPayload().getExpiration());
// exp 날짜가 현재 날짜보다 전에 있지 않으면 토큰 만료
return !claims.getPayload().getExpiration().before(new Date());
} catch (Exception e) {
logger.error("JWT Exception :", e);
return false;
}
}
// 액세스 토큰 헤더 설정
public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
response.setHeader(AUTHORIZATION, BEARER_PREFIX + accessToken);
}
// 리프레쉬 토큰 헤더 설정
public void setHeaderRefreshToken(HttpServletResponse response, String refreshToken) {
response.setHeader(REFRESH, refreshToken);
}
// 헤더 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;
}
// 헤더 refreshToken 리턴
public String getHeaderRefreshToken(HttpServletRequest request) {
String refreshToken = request.getHeader(REFRESH);
if (refreshToken != null) {
return refreshToken;
}
return null;
}
}
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(지원하지 않는 형식) 등..
CustomUsernamePasswordAuthenticationFilter
클라이언트로부터의 로그인 요청을 처리하는 Spring Security 필터이다. 사용자의 인증 정보를 검증하고, 인증 성공 시 JWT 토큰 (Access와 Refresh)을 발급하여 응답하는 역할을 한다.
/**
* 클라이언트의 로그인 요청을 처리해주는 필터를 커스텀한 클래스
*/
@RequiredArgsConstructor
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private ObjectMapper objectMapper;
/**
* 로그인 요청이 들어올때 인증 과정을 처리하는 메소드
* request에서 inputStream을 얻은 후, inputStream을 통해 requestBody값을 문자열로 넘겨받음. LoginRequestDto로 객체로 파싱하여 토큰 생성
* 이후, 생성한 토큰 인증 객체를 AuthenticationManager에게 넘겨주어 인증 과정을 위임
* @param request from which to extract parameters and perform the authentication
* @param response the response, which may be needed if the implementation has to do a
* redirect as part of a multi-stage authentication process (such as OIDC).
* @return
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
// request body GET
objectMapper = new ObjectMapper();
ServletInputStream servletInputStream;
String requestBody;
try {
servletInputStream = request.getInputStream();
requestBody = StreamUtils.copyToString(servletInputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
// Json data parsing
LoginRequestDto loginDto;
try {
loginDto = objectMapper.readValue(requestBody, LoginRequestDto.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());
return authenticationManager.authenticate(authToken);
}
/**
* 인증이 성공적으로 완료되면 실행되는 메소드
* @param request
* @param response
* @param chain
* @param authResult the object returned from the <tt>attemptAuthentication</tt>
* method.
* @throws IOException
* @throws ServletException
*/
@Override
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 토큰 생성
UserDetailsImpl userDetails = (UserDetailsImpl) authResult.getPrincipal();
TokenInfoDto tokenDto = jwtTokenProvider.generateAccessToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
// 헤더에 액세스 토큰 추가
jwtTokenProvider.setHeaderAccessToken(response, accessToken);
jwtTokenProvider.setHeaderRefreshToken(response, refreshToken);
objectMapper = new ObjectMapper();
String jsonResponse = objectMapper.writeValueAsString(tokenDto);
// JSON 타입 객체 응답
response.setContentType("application/json");
response.getWriter().write(jsonResponse);
}
}
attemptAuthentication
로그인 요청이 들어올 때 인증 과정을 수행하는 메서드
- request.getInputStream()을 사용하여 바디 데이터를 문자열로 읽어와 LoginRequestDto 객체로 변환
- UsernamePasswordAuthenticationToken 객체를 생성하여 AuthenticationManager의 authenticate() 메서드를 통해 인증 요청
- (AuthenticationManager가 내부적으로 UserDetailsService를 사용하여 사용자를 조회하고 비밀번호 검증을 수행. 성공 시 인증된 Authentication 객체를 반환)
successfulAuthentication
- 인증 완료 후 실행되는 메서드
- JWT 액세스 토큰과 리프레쉬 토큰을 생성
- 생성된 JWT 토큰을 HTTP 응답 헤더에 추가하여 클라이언트가 이후 요청에서 사용할 수 있도록 함
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());
}
}
}
doFilterInternal()
- 클라이언트가 Authorization: Bearer <Access Token>을 포함하여 API 요청을 하면 JwtVerificationFilter를 거치면서 실행되는 메서드
- request의 헤더에서 액세스 토큰값과 리프레쉬 토큰값을 가져와 토큰 값이 유효한지, 비어있지 않은지를 검증
- 토큰이 검증이 정상적으로 완료되었으면 setAuthentication()을 호출하여 SecurityContext에 인증 정보를 저장 (현재 요청이 인증되었음을 SecurityContext에 저장하여 이후 요청에서 사용자가 인증된 상태로 인식될 수 있도록 한다)
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtVerificationFilter jwtVerificationFilter;
private final JwtExceptionFilter jwtExceptionFilter;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
configuration.setAllowedHeaders(List.of("*"));
/**
* UrlBasedCorsConfigurationSource : configuration을 특정 url 패턴에서만 적용할 수 있게 함
*/
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/v1/**", configuration);
return source;
}
// AuthenticationManager의 Bean을 얻기 위한 authConfiguration 객체
private final AuthenticationConfiguration authenticationConfiguration;
/**
* AuthenticationConfiguration로부터 AuthenticationManager 객체 가져오는 메서드
* @param authenticationConfiguration
* @return
* @throws Exception
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception {
// 커스텀 필터 등록
// 로그인 경로 설정 후, 로그인 필터 등록
CustomUsernamePasswordAuthenticationFilter filter = new CustomUsernamePasswordAuthenticationFilter(authenticationManager(authenticationConfiguration), jwtTokenProvider);
filter.setFilterProcessesUrl("/api/v1/login"); // 로그인 필터가 작동될 경로 설정
http
.cors(Customizer.withDefaults())
.authorizeHttpRequests((requests) ->
requests
.requestMatchers(HttpMethod.POST, "/api/*/user/register", "/api/*/user/login")
.permitAll()
.anyRequest()
.authenticated()
)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable) // CSRF 공격 방어 임시 해제
.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(jwtVerificationFilter, CustomUsernamePasswordAuthenticationFilter.class) // jwt 검증 필터 등록
.addFilterAfter(jwtExceptionFilter, jwtVerificationFilter.getClass())
.httpBasic(HttpBasicConfigurer::disable) // 기본 로그인창 disable
.formLogin(FormLoginConfigurer::disable); // UsernamePasswordAuthenticationFilter disable
return http.build();
}
}
authenticationManager()
- Spring Security의 AuthenticationManager를 Bean으로 등록
- 로그인 인증을 처리하는 핵심 객체
- CustomUsernamePasswordAuthenticationFilter에서 사용된다
.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class
JWT 로그인 필터 추가
.addFilterAfter(jwtVerificationFilter, CustomUsernamePasswordAuthenticationFilter.class
로그인 이후, JWT 검증 필터 추가
.addFilterAfter(jwtExceptionFilter, jwtVerificationFilter.getClass())
JWT 관련 예외 처리 필터 추가
정리
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에 저장하는 방식을 적용하기로 하였다.
다음 포스팅에서는 Redis에 Refresh Token을 저장하는 방식을 다룰 예정이다.