[Gena Co.] Internship Project/GENA Labeling Tool

(25.04.23) Spring Security 인증 인가를 통한 Login 기능 구현 & Token 재발급 구현

Genie; 2025. 4. 23. 23:17

JWT를 활용한 로그인 기능 구현 간단한 계획

 

 

2025.03.20 - [[Gena Co.] Internship Project/GENA Labeling Tool] - Gena Labeling Tool 개발기 : 기획부터 PoC 까지

 

Gena Labeling Tool 개발기 : 기획부터 PoC 까지

안녕하세요, 저는 Gena Co. 인턴 김현진(Andrew) 입니다.   Gena에서 text2sql GenaSQL의 자연어(NL) - SQL query 변환 간 AI 학습을 위한 고품질의 데이터 셋을 만들기 위해, 기존  데이터 셋의  주석과 오류

andrew75313.tistory.com

2025.04.22 - [Develop Study/Spring] - (25.04.22) Spring Security 인증 인가를 통한 Login 기능 - Filter & JWT에 대해

 

(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  재발급 용
  • Spring Security의 AuthorizationFilter(OncePerRequestFilter) 인가 필터와, AuthenticaitonFilter(UsernamePasswordAuthenticationFilter 확장) 인증 필터를 사용한  JWT 인가 인증 구현

 

Login 후, JWT 발급 과정

Access Token 과 Refresh Token 발급 후 Request 까지의 Flow

적용한 Security Filter 구성 (클래스)

Gena Labeling Tool의 Security Filter에 적용된 Filter와 FilterChain, 인증 성공 실패의 실제 클래스

  • 위의 실제 클래스를 기반으로 작성

로그인 시도

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에 저장 기술적 의사결정

  1. 보안 강화 (토큰 탈취 대응)
    • Redis에 존재하지 않는 경우만 확인해 Refresh Token 의 유효성 검증이 가능
      • TTL을 적용해 자동으로 삭제될 수 있도록 하기 때문
    • 이를 통해 탈취된 Refresh Token 사용을 방지 가능
      • 영구적으로 사용할 수 없도록 함
  2. 토큰 재사용 방지 (Token Rotation 시 유효성 체크)
    • 토큰 재발급 시 기존 Refresh Token을 Redis에서 삭제하고 새로 발급하는 방식(Token Rotation)
      • 이전 토큰으로 재요청할 경우 Redis에서 존재하지 않으므로 재사용 방지
  3. 만료 시간 TTL 설정을 통한 자동 만료 처리 Duration.ofMillis()
    • Redis에 저장 시 TTL(Time to Live)을 설정해 자동 만료되도록 관리
    • DB의 불필요한 지속 저장 없이 메모리 기반으로 만료 관리가 효율적
      • Session 방식과 유사하지 않도록
  4. 상대적으로 빠른 조회 속도 (고성능 인증 처리)
    • 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 새로 생성