Web Backend

Spring Security + Jwt + Oauth2 구조 분석

Atriel 2025. 4. 30. 11:21

 

https://github.com/c0de-pirate/tubelens-be.git

 

GitHub - c0de-pirate/tubelens-be: 유튜브 상태 분석 대시보드

유튜브 상태 분석 대시보드. Contribute to c0de-pirate/tubelens-be development by creating an account on GitHub.

github.com

해당 repo의 feature/oauth2 브랜치에서 진행함

package codepirate.tubelensbe.auth.common;

import codepirate.tubelensbe.auth.oauth2.handler.OAuth2AuthenticationFailureHandler;
import codepirate.tubelensbe.auth.oauth2.handler.OAuth2AuthenticationSuccessHandler;
import codepirate.tubelensbe.auth.oauth2.service.CustomOAuth2UserService;
import codepirate.tubelensbe.auth.jwt.JwtAuthenticationEntryPoint;
import codepirate.tubelensbe.auth.jwt.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity//(debug = true)
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    private CustomOAuth2UserService customOAuth2UserService;

    @Autowired
    private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;

    @Autowired
    private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .logout((logout) -> logout
                        .logoutSuccessUrl("/login")
                        .invalidateHttpSession(true))
                .authorizeHttpRequests((authorize) -> authorize
                                .requestMatchers("/**").permitAll()  // 모든 경로에 대해 접근 허용
                )
//                .authorizeHttpRequests((authorize) -> authorize
//                        .requestMatchers("/", "/api/auth/**"
//                                , "/login/oauth2/google/url","/api/oauth2/google/callback").permitAll()
//                        .anyRequest().authenticated())
                .exceptionHandling((exceptionHandling) ->
                        exceptionHandling.authenticationEntryPoint(unauthorizedHandler)
                )
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2UserService)
                        )
                        .successHandler(oAuth2AuthenticationSuccessHandler)
                        .failureHandler(oAuth2AuthenticationFailureHandler)
                );


        return http.build();
    }
}

SecurtiyConfig 파일이다
중요한부분만보면
앞부분에 addFilterBefore로 시큐리티 필터체인 단계에서
시큐리티 필터체인을 호출하는 proxy전 단계에
JwtAuthenticationFilter를 추가해줬다
시큐리티 필터체인이 호출되기전에 JwtAuthenticationFilter에서 Jwt인증관련 처리를 먼저 하게 지정함


JwtToken을 사용할것이니 session은 미사용(STATELESS) 해주고

authorizeHttpRequests에서는 모든 요청에대해 접근을 허용했다
(원래는 아래 주석처리처럼 로그인하기전에 접근 가능한 페이지에 대해서만 접근해주는게 맞는데
개발 테스트용으로 임시로 모두 허용)

exceptionHandling 내부코드는
인증되지 않은 사용자에 대해 보호되있는 리소스에 접근할때 예외를 발생시키는 코드이다
unauthorizedHandler를 엔드포인트로 호출하는데

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        
        // JSON 형태로 에러 응답 커스터마이징
        String jsonResponse = String.format(
                "{\"timestamp\": \"%s\", \"status\": %d, \"error\": \"Unauthorized\", \"message\": \"%s\", \"path\": \"%s\"}",
                new java.util.Date(),
                HttpServletResponse.SC_UNAUTHORIZED,
                authException.getMessage() != null ? authException.getMessage() : "인증이 필요한 리소스입니다.",
                request.getRequestURI()
        );
        
        response.getWriter().write(jsonResponse);
    }
}


위의 인증되지않은 사용자가 접근할때 호출되는
AuthenticationEntryPoint 구현체를 impl해서 커스텀한 JwtAuthenticationEntryPoint에서
401에러를 반환해준다

다시 Security로 돌아가서
마지막쪽에 .oauth2Login을 호출하는데
.userInfoEndpoint에서 
클라이언트가 OAuth2인증을 성공했을때 소셜제공자(여기선 Google)로 부터 사용자 정보를 가져오는
엔드포인트를 지정해준다. OAuth2에서 기본으로제공해주지만
이 코드에선 기본제공자를 상속받은 CustomOauth2UserService를 사용했다.

public class CustomOAuth2UserService extends DefaultOAuth2UserService

이 서비스는 Google이 반환해준 정보를 바탕으로 사용자를 우리 백엔드orDB에 생성하고 업데이트하는 역할을 함.


이제 Config파일을 봤으니 전체적인 인증 흐름을 보겠다

전체 인증 흐름

요청에 Jwt인증토큰이없는 경우

토큰이 없다는건 (Access만 없음) -> 처음 로그인(or Refresh도 만료) or Refresh Token으로 재로그인

1.프론트에서 구글 OAuth2 인증 리다이렉트 URL로 연결된 로그인 버튼을 누르고
구글에서 제공하는 창에서 로그인을 진행함 (외부인증)

2. 인증 후 Google이 지정해준 백엔드 Callback Url로 리다이렉트 해줌

3. Spring Security의 OAuth2 기능이 자동으로 콜백처리를 해준 후  아까 Config파일에서 지정한
CustomOAuth2UserService가 구글에서 받은 사용자정보를 처리해줌 (DB에 저장&업데이트)
(Custom지정 안할시 DefaultOAuth2UserService호출)

4. OAuth2AuthenticationSuccessHandler가 호출되고 사용자 정보를 기반으로 Jwt access토큰을 생성
Refresh 토큰이 만료된 사용자일수도 있으니 유저 ID를 기반으로 Refresh Token이있나 DB에서 조회해보고
만료됬는지 검사후 만료됬거나 Refresh토큰이없는 사용자(첫로그인)일시  Refresh 재발급
아니면 기존 Refresh를 사용. 프론트에게 JWT토큰을 돌려주고 프론트는 이를 localStorage에 저장

이후 프론트는 인증요청 시 Axios로 헤더에 토큰을 포함(Authorization 헤더)해서 백엔드 api를 호출하게 됨


 

요청에 Jwt인증토큰이없는 경우

1. 프론트에서 Authorization: Bearer {token} 헤더에 토큰을 담아 api를 호출

2. 인증요청이 필터과정에서 JwtAuthenticationFilter를 통과

package codepirate.tubelensbe.auth.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        try {
            // 쿠키에서 토큰을 가져오는 대신 헤더에서 가져옴
            String token = extractTokenFromHeader(request);

            if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
                UserDetails userDetails = jwtTokenProvider.getUserDetails(token);
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error("JWT 인증 처리 중 오류 발생", e);
            SecurityContextHolder.clearContext(); // 인증 실패 시 컨텍스트 클리어
        }

        filterChain.doFilter(request, response);
    }

    private String extractTokenFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}


필터안에 doFilterInternal이 실행되고
요청에서 토큰을 추출해서
provider에서 제공하는 validateToken함수를 사용

package codepirate.tubelensbe.auth.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.security.core.userdetails.User;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class JwtTokenProvider {
    private final Key secretKey;
    private static final long tokenValidityInMs = 30 * 60 * 1000; // Access Token 유효 시간 (30분)

    public JwtTokenProvider(@Value("${jwt.secret.key}") String key) {
        this.secretKey = Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
    }

    public String generateToken(Authentication authentication) {
        String username;
        List<String> roles;

        if (authentication.getPrincipal() instanceof UserDetails) {
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            username = userDetails.getUsername();
            roles = userDetails.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toList());
        } else {
            // String이나 다른 타입의 principal인 경우
            username = authentication.getPrincipal().toString();
            roles = authentication.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toList());
        }

        return getToken(username, roles);
    }

    private String getToken(String username, List<String> roles) {
        Date now = new Date();
        Date validity = new Date(now.getTime() + tokenValidityInMs);

        return Jwts.builder()
                .setSubject(username)
                .claim("roles", roles)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(secretKey) // 기본 서명 알고리즘은 HS256
                .compact();
    }

    public UserDetails getUserDetails(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();

        String username = claims.getSubject();
        List<String> roles = claims.get("roles", List.class);

        return new User(username, "",
                roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()));
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public String getUsername(String token) {
        return getClaims(token).getSubject();
    }

    public boolean isExpired(String token) {
        return getClaims(token).getExpiration().before(new Date());
    }

    private Claims getClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}


Jwt내부의 <Header>.<Payload>.<Signature> 세가지 정보를 추출해서
Header에서 인증방식을 찾고 (여기선 대칭키방식( HMAC-SHA256)을 사용함)
그 방식대로 Payload(유저정보)를 여기선 대칭키 방식이니까
백엔드에서만 유출되지않게 잘보관하고있는 secretKey로 서명을 한후
서명된 Payload값을 이미서명된 Signature값과 동일한지 비교를통해 검증을 함

검증 후 유저정보를 UserDetail 객체에 담아서반환하고
이걸로 Authentication 객체를 생성

SecurityContextHolder.getContext().setAuthentication(authentication);


생성한 정보를 보안컨텍스트 홀더(SecurityContextHolder)에 담아서 모든 요청과정에서 사용할수 있게 함.
여기서 모든 요청과정이란
API를호출해서 필터 → 인터셉터 → 컨트롤러 → 서비스 → 리포지토리 등을 거치는
전체 처리 요청과정에서 해당 인증 정보를 사용할수 있게 함
(SecurityContextHolder.getContext().getAuthentication()로 어디서든 호출가능)
(SecurityContextHolder는 기본적으로 ThreadLocal 기반으로 동작해서 다른요청에 영향 안감)

이후 filterChain.dofillter로 다음 필터에 요청을 전송

3. 시큐리티 필터에서 권한 인증 후 Dispatcher Servelt을 거쳐서 컨트롤러로 넘어감
컨트롤러가 요청을 처리하고 응답을 반환
세션이 없기때문에(Config에서 StateLess로 설정) 각 요청은 독립적임



위 예시코드는 리팩토링 전이라서
현재는 더 깔끔하게 정리되어있음