(25.04.23) Spring Security 인증 인가를 통한 Login 기능 구현 & Token 재발급 구현
Gena Labeling Tool 개발기 : 기획부터 PoC 까지
안녕하세요, 저는 Gena Co. 인턴 김현진(Andrew) 입니다. Gena에서 text2sql GenaSQL의 자연어(NL) - SQL query 변환 간 AI 학습을 위한 고품질의 데이터 셋을 만들기 위해, 기존 데이터 셋의 주석과 오류
andrew75313.tistory.com
(25.04.22) Spring Security 인증 인가를 통한 Login 기능 - Filter & JWT에 대해
2025.03.20 - [[Gena Co.] Internship Project/GENA Labeling Tool] - Gena Labeling Tool 개발기 : 기획부터 PoC 까지 Gena Labeling Tool 개발기 : 기획부터 PoC 까지안녕하세요, 저는 Gena Co. 인턴 김현진(Andrew) 입니다. Gena에서 t
andrew75313.tistory.com
- Gena Labeling Tool에 JWT 를 활용한 Login 기능 구현
- Login 요청시 Header에 Authorization : Bearer <Access Token> 발급
- Application 으로 Request 시 Spring Security Filter를 통한 인가 인증 진행
- Login 요청시 Header에 RefreshToken : Bearer <Refresh Token> 발급
- Access Token 재발급 용
- Login 요청시 Header에 Authorization : Bearer <Access Token> 발급
- Spring Security의 AuthorizationFilter(OncePerRequestFilter) 인가 필터와, AuthenticaitonFilter(UsernamePasswordAuthenticationFilter 확장) 인증 필터를 사용한 JWT 인가 인증 구현
Login 후, JWT 발급 과정
적용한 Security Filter 구성 (클래스)
- 위의 실제 클래스를 기반으로 작성
로그인 시도
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public JwtAuthenticationFilter() {
setFilterProcessesUrl("/api/login");
}
- 클라이언트에서 username과 password를 통해 Gena Labeling Tool 로 api/login POST 로그인 요청
- UsernamePasswordAuthenticationFilter 을 확장시킨 JwtAuthenticationFilter 인증 필터가 요청을 가로채 인증을 시도
사용자 및 역할 검증
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
...
}
- attemptAuthentication() 메서드에서 인증 시도 (AuthenticationManager 을 통해 시도)
- Gena Labeling Tool의 MySQL의 User Data 기반 username 과 password가 일치 하는지 확인
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsernameAndIsActiveTrue(username)
.orElseThrow(() -> new UsernameNotFoundException("Can't find user."));
return new UserDetailsImpl(user);
}
- AuthenticationManager.authenticate() 호출
→ 내부적으로 등록된 AuthenticationProvider들을 순차적으로 탐색
→ JWT 토큰 인증을 위해서는 DaoAuthenticationProvider 이 처리
→ 위의 UserDetailsService의 구현체에서 UserRepository에 접근해서 사용자를 찾을 수 있는지 확인(인증과정) 이 진행하게 됨
인증 실패시)
@Component
public class JwtAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
...
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
String json = new ObjectMapper().writeValueAsString(
Map.of("statusCode", 401, "msg", msg)
);
response.getWriter().write(json);
}
}
- UserDetailsService 구현체에서 사용자를 찾지 못할 때 onAuthenticationSuccess 가 정의된 JwtAuthenticationSuccessHandler 실행
- 발생한 예외의 msg를 JSON 형태로 response할 수 있도록 구현
인증 성공시) 토큰 생성
@Component
@RequiredArgsConstructor
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
...
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
String accessToken = jwtUtil.createAccessToken(user);
String refreshToken = jwtUtil.createRefreshToken(user);
...
}
- JwtAuthenticationSuccessHandler 를 실행, 토큰을 생성
public String createAccessToken(User user) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(user.getId().toString())
.claim(AUTHORIZATION_KEY, user.getRole())
.setExpiration(new Date(date.getTime() + jwtProperties.getAccessTokenTime()))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
public String createRefreshToken(User user) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(user.getId().toString())
.claim(AUTHORIZATION_KEY, user.getRole())
.setExpiration(new Date(date.getTime() + jwtProperties.getRefreshTokenTime()))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
- Access Token (JWT) 및 Refresh Token (JWT) 생성 (Secret Key, User ID, User Role 포함)
Refresh Token 저장 (Redis)
@Component
@RequiredArgsConstructor
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
...
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
....
// REDIS에 등록
redisTemplate.opsForValue().set(
"RefreshToken: " + user.getId(),
refreshToken,
Duration.ofMillis(jwtUtil.getRefreshTokenTime())
);
...
}
- "RefreshToken: " + user.getId() 로 RedisTemplate을 통해 Redis에 저장
- getRefreshTokenTime 을 통해서 RefreshToken의 만료 시간만큼만 Redis 에 저장할 수 있도록 함
Refresh Token을 Redis에 저장 기술적 의사결정
- 보안 강화 (토큰 탈취 대응)
- Redis에 존재하지 않는 경우만 확인해 Refresh Token 의 유효성 검증이 가능
- TTL을 적용해 자동으로 삭제될 수 있도록 하기 때문
- 이를 통해 탈취된 Refresh Token 사용을 방지 가능
- 영구적으로 사용할 수 없도록 함
- Redis에 존재하지 않는 경우만 확인해 Refresh Token 의 유효성 검증이 가능
- 토큰 재사용 방지 (Token Rotation 시 유효성 체크)
- 토큰 재발급 시 기존 Refresh Token을 Redis에서 삭제하고 새로 발급하는 방식(Token Rotation)
- 이전 토큰으로 재요청할 경우 Redis에서 존재하지 않으므로 재사용 방지
- 토큰 재발급 시 기존 Refresh Token을 Redis에서 삭제하고 새로 발급하는 방식(Token Rotation)
- 만료 시간 TTL 설정을 통한 자동 만료 처리 Duration.ofMillis()
- Redis에 저장 시 TTL(Time to Live)을 설정해 자동 만료되도록 관리
- DB의 불필요한 지속 저장 없이 메모리 기반으로 만료 관리가 효율적
- Session 방식과 유사하지 않도록
- 상대적으로 빠른 조회 속도 (고성능 인증 처리)
- Redis는 인메모리 데이터 저장소이기 때문에 Refresh Token의 유효성 확인이 매우 빠름
- 로그인 된 사용자가 많아, 재발급 인증 요청이 많은 환경에서 병목 없이 토큰 인증 처리 가능
JWT 응답
@Component
@RequiredArgsConstructor
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
...
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, accessToken);
response.addHeader(JwtUtil.REFRESHTOKEN_HEADER, refreshToken);
response.setContentType("application/json;charset=UTF-8");
String json = new ObjectMapper().writeValueAsString(
Map.of("statusCode", 200, "msg", "Success to Login.")
);
response.getWriter().write(json);
}
}
- Access Token 및 Refresh Token을 Header에 포함한 클라이언트 응답
JWT 저장 & 데이터 요청
- 클라이언트가 JWT (Access Token, Refresh Token) 저장
- 추후, Access Token을 Header (Authorization) 포함한 클라이언트의 데이터 요청
Access Token 기반의 인가 인증
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final JwtAuthenticationEntryPoint authenticationEntryPoint;
private final JwtAccessDeniedHandler accessDeniedHandler;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
// 토큰의 검증
try {
String token = jwtUtil.getJwtFromHeader(request);
// 재발급 요청시에는 RefreshToken만 Header에 있기 때문에
if (!StringUtils.hasText(token)) {
token = jwtUtil.getRefreshJwtFromHeader(request);
}
if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.getClaimFromToken(token);
setAuthentication(claims.getSubject());
}
filterChain.doFilter(request, response);
} catch ...
}
// 권한을 확인하기 위해서 JWT의 Claim 기반으로 인증이 되어 만드는 Authentication
private void setAuthentication(String userId) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(userId);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
private Authentication createAuthentication(String userId) {
UserDetails userDetails = userDetailsService.loadUserByUserId(userId);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
- 요청에 포함된 JWT 토큰을 검증하여 인증 및 권한 확인 (Secret Key 활용해 Payload의 Claim을 확인)
- 모든 요청 (OncePerRequestFilter 상속)
- JwtAuthenticationEntryPoint→ AuthenticationException 처리
- → 인증 실패 시 (ex 토큰 없음, 만료, 위조 등) 작동하게 되어 Access Token , Refresh Token 둘 다 Valid한지 검증을 할 수 있음
- JwtAccessDeniedHandler→ AccessDeniedException 처리
- → 인증은 되었지만, 권한이 부족할 때 작동 (ex 요청에 따른 ROLE 불일치) USER 가 Admin 엔드포인트로 접근하려고 하는 시도
사용자 역할 추출 & API 접근 제어
- 유효한 Access Token으로부터 User Role 추출
- 추출된 User Role 기반 API 접근 권한 제어
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
...
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users").permitAll()
.requestMatchers(HttpMethod.POST, "/api/datasets/upload").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/datasets/download/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/datasets/latest-versions").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/datasets/approve").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/datasets/*/reject").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/groups").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/groups").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/groups/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/groups/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/groups/*/update-reviewers").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/groups/*/update-samples").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/labels").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/labels/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/labels").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/users").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/users").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/users/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/users/*").hasRole("ADMIN")
.anyRequest().authenticated()
);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
return http.build();
}
}
- SecurityConfig에서 SecurityFilterChain 에서 ADMIN 권한을 지정 가능
- 위의 FLOW 이미지 처럼 Filter 순서를 지정해줄 수 있음
- PoC 레벨이기 때문에 따로 /admin/ 엔드포인트를 추가하는 방향을 고려하지 않았기 때문에 수동으로 입력
Access Token 만료 시, JWT 재발급 과정
재발급 요청
// 컨트롤러
@PostMapping("/token")
public ResponseEntity<String> refreshToken(HttpServletRequest request) {
String refreshToken = jwtUtil.getRefreshJwtFromHeader(request);
try {
String newAccessToken = authService.refreshAccessToken(refreshToken);
String newRefreshToken = authService.refreshRefreshToken(refreshToken);
return ResponseEntity.status(HttpStatus.OK)
.header(JwtUtil.AUTHORIZATION_HEADER, newAccessToken)
.header(JwtUtil.REFRESHTOKEN_HEADER, newRefreshToken)
.body("Access token has been refreshed.");
...
}
- 클라이언트가 Refresh Token과 함께 요청할 수 있고, 이때, JwtAuthorizationFilter 실행해 Token이 유효한지 먼저 확인
Refresh Token 검증 & 새 토큰 발급 & 저장 & 클라이언트 응답
// 서비스
@Service
@RequiredArgsConstructor
public class AuthService {
...
@Transactional
public String refreshAccessToken(String refreshToken) {
if (!jwtUtil.validateToken(refreshToken)) {
throw new IllegalArgumentException("Invalid Refresh Token. Login again.");
}
String userId = jwtUtil.getUserIdFromToken(refreshToken);
// REDIS 에 있는지 여부 확인
String redisRefreshToken = redisService.getRefreshToken(userId);
if (redisRefreshToken == null || !redisRefreshToken.equals(refreshToken)) {
throw new IllegalArgumentException("Refresh token not found in Redis or does not match.");
}
User user = userRepository.findById(userId).orElseThrow(
() -> new UsernameNotFoundException("No user found.")
);
return jwtUtil.createAccessToken(user);
}
@Transactional
public String refreshRefreshToken(String refreshToken) {
...
}
@Service
@RequiredArgsConstructor
public class RedisService {
...
public String getRefreshToken(String userId) {
String key = "RefreshToken: " + userId;
return redisTemplate.opsForValue().get(key).substring(7);
}
}
- 서버에서 Secret Key로 유효성 확인 및 Redis 조회
- RedisService 로 역할을 분리했고, RedisTemplate을 통해 key(사용자별 RefreshToken) 가 있는지 없는지 확인
- 위의 데이터 요청시와 똑같이 RefreshToken에 대해 JwtAuthorizationFilter 이 진행이 되었으므로, 새 토큰 발급시 따로 해당 요청에 포함된 JWT 검증, 사용자 검증이 필요없이 단순하게 AccessToken RefreshToken 발급해서 주기만 하면 됨
- 유효하면 Access Token, Refresh Token 새로 생성