[Spring] Spring Security OAuth 2.0 동작 원리

박상민·2025년 4월 9일
1

Spring Security

목록 보기
3/4

OAuth 2.0이란?

인증(Authentication)과 인가(Authorization)의 차이

  • 인증
    • 사용자가 진짜 본인인지 확인하는 단계4
    • 주로 ID/PW, OAuth 로그인, 지문/얼굴 인식 등을 통해 수행
    • 예시: Google 로그인 화면에서 이메일/비밀번호 입력
  • 인가
    • 인증된 사용자가 특정 리소스에 접근할 권한이 있는지 판단
    • 주로 권한(Role), 권한 수준(Scope)을 기반으로 동작
    • 예시: 로그인은 했지만 관리자 전용 페이지에 접근 불가, 학생은 성적 수정 불가능

주요 용어 정리

  • Resource Owner(리소스 소유자)
    • 서비스에 접근 권한을 가진 사용자
    • 실제로 보호된 리소스(예: 사용자 정보, 이메일 등)을 소유한 사람
    • 일반적으로 로그인하는 유저를 뜻함
    • OAuth에서는 이 사용자의 동의/승인을 받아야 Provider에서 리소스를 가져올 수 있음
  • Client
    • 리소스에 접근하려는 외부 애플리케이션
    • 리소스를 직접 가지지 않고, Resource Owner의 승인을 받아서 간접적으로 접근
    • 클라이언트는 Access Token을 통해 리소스를 요청
  • Authorization Server(인가 서버)
    • Access Token을 발급해주는 서버
    • Resource Owner가 로그인 및 동의를 마치면, Client에게 Access Token을 발급
    • OAuth 흐름의 중심이 되는 서버
  • Resource Server
    • 실제 보호된 데이터를 갖고 있는 서버
    • Access Token을 검증한 후, 요청한 리소스를 제공
    • Authorization Server와 별도로 동작 가능 (Google은 따로 나눠져 있음)

간단 흐름도

[사용자] → [Authorization Server 로그인 + 동의]
         → [Client에게 Access Token 전달]
         → [Client는 Resource Server에 API 요청]
         → [Access Token 검증 후 리소스 응답]

OAuth 2.0의 동작 흐름

Gradle 의존성 설정

implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

Authorization Code Grant 방식 예시

1.사용자 로그인 요청

  • 사용자가 소셜 로그인 버튼을 클릭
  1. 사용자 -> Authorization Server 인증
  • 인증 서버(소셜 미디어)에 로그인하고 권한 요청(동의)을 승인함
  1. Authorization Server -> Client로 Authorization Code 전달
  • 인가 코드(code)는 redirect URI를 통해 클라이언트에게 전달됨
  1. Client -> Authorization Server로 Access Token 요청
  • 클라이언트는 받은 code를 이용해, 백엔드에서 access token을 요청
  1. Access Token 수신 -> API 요청에 사용
  • access token을 통해 사용자 정보 등 리소스를 요청 가능

흐름 다이어그램

[사용자] ──▶ [클라이언트] ──▶ [인가 서버: 로그인 + 동의]
                         ⬇︎
              Authorization Code 발급
                         ⬇︎
[클라이언트] ──▶ [인가 서버] (code + secret 전송)
                         ⬇︎
                  Access Token 수신
                         ⬇︎
[클라이언트] ──▶ [리소스 서버] (API 요청)

전체 플로우 다이어그램

Spring Security에서 Authorization Code Grant 흐름

Spring Boot에서는 spring-boot-starter-oauth2-client 모듈을 사용하면, Authorization Code 방식이 자동으로 구성된다.
별도의 컨트롤러 없이도 로그인 요청 -> 인가 코드 교환 -> 사용자 정보 요청까지 내부 필터 체인에서 알아서 처리된다.

Redirect URI, Authorization Code, Access Token 개념 설명

  • Redirect URI: 인가 서버가 인가 코드(authorization code)를 클라이언트에게 전달할 때, 리디렉션할 주소
  • Authorization Code: Access Token을 발급 받기 위한 1회용 임시 코드
    • https://www.cchaksa.com/oauth/callback?code=abc123xyz <- 에서 'abc123xyz'가 Authorization Code
  • Access Token: 보호된 리소스에 접근할 수 있게 해주는 인증 수단
    • 대부분 'Bearer {Token}' 형태로 API 요청 시 헤더에 Authroization로 포함

관계 요약 흐름

1. [사용자 → 로그인/동의]
2. [인가 서버 → Redirect URI로 code 전달]
3. [서버 → code로 access token 요청]
4. [access token으로 API 요청 → 사용자 정보 획득]

Spring Security OAuth 2.0의 구조 이해

Spring Security에서 OAuth 2.0을 어떻게 지원하는가?

spring-security-oauth2-client을 중심으로 OAuth 2.0을 클라이언트 관점에서 완전히 자동화된 방식으로 처리할 수 있게 해준다.

핵심 구성 요소

  • spring-security-oauth2-client: OAuth 2.0 클라이언트 기능의 핵심 모듈
  • OAuth2LoginAuthenticationFilter: 인가 코드 수신 -> Access Token 요청 필터
  • OAuth2AuthorizedClientService: 발급받은 토큰 저장 및 관리
  • OAuth2UserService: 사용자 정보 (user-info-uri)를 가져와서 OAuth2User 생성
  • ClientRegistration: 각 OAuth Provider 설정 정보를 캡슐화 (yml or Java 설정)
  • SecurityFilterChain: 전체 필터 흐름 설정 가능

내부 필터 흐름 (예: OAuth2LoginAuthenticationFilter, AuthenticationSuccessHandler)

내부 필터 흐름을 왜 알아야 할까?

Spring Security는 '자동으로 다 해준다'는 장점이 있지만,
이 흐름을 모르면 원하는 로직을 삽입하거나 수정하는 데 벽에 부딪히게 돼요.

  • 필터 흐름을 이해하면 JWT 발급, DB 저장, 권한 설정 등 커스터마이징이 쉽다
  • 로그인 이후의 다양한 상황에 정확하게 대응할 수 있게 된다.

전체 흐름 요약: Kakao 로그인 시

[1] 사용자 → `/oauth2/authorization/kakao` 요청
 ↓
[2] OAuth2AuthorizationRequestRedirectFilter가 가로챔 
 ↓
[3] Filter는 Kakao 인가 서버로 리디렉션하면서 client_id, redirect_uri, scope 등을 전달
 ↓
[4] 사용자 → Kakao 로그인 + 동의 완료
 ↓
[5] Kakao → redirect_uri + code 리턴
 ↓
[6] OAuth2LoginAuthenticationFilter → 인가 코드(code) 추출,
	- 내부적으로 Access Token을 받기 위한 인증 요청(OAuth2LoginAUthenticationToken) 생성
 ↓
[7] AuthenticationManager -> OAuth2LoginAuthenticationProvider
	- 필터는 인증 요청을 AuthenticationManaget에 위임
 ↓
[7] AuthenticationProvider → Access Token + 사용자 정보 요청
 ↓
[8] OAuth2UserService → 사용자 매핑
	- 기본 OAuth2UserService or CustomUserService가 사용자 정보를 OAuth2User로 변환
    - 이때 DB 저장, 최초 회원가입 처리, 권한 매핑 등을 할 수 있다.
 ↓
[9] AuthenticationSuccessHandler → 로그인 후 후처리(리다이렉션 or JWT 발급)
 ↓
[10] 인증 완료 → SecurityContextHolder 저장
	- 인증이 완료되면 Spring Security는 인증 객체를 세션(SecurityContext)에 저장
    - 이후 요청에서 `@AuthenticationPrincipal` 등으로 사용자 정보에 접근 가능

OAuth2LoginAuthenticationFilter 코드 분석

위치: 패키지: org.springframework.security.oauth2.client.web

전체 동작 흐름 요약

[사용자 로그인 완료 후 Redirect URI로 돌아옴]
      ↓
[OAuth2LoginAuthenticationFilter가 요청 가로챔]
      ↓
[인가 코드(code)를 읽고 → access token 요청]
      ↓
[사용자 정보 받아서 인증 객체 생성 → SecurityContext에 저장]

요청 파라미터 파싱

MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());

리다이렉트된 요청의 파라미터(code, state, error)를 MultiValueMap으로 파싱

만약 파라미터가 정상적인 OAuth 응답이 아니면 예외 발생

if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
    OAuth2Error oauth2Error = new OAuth2Error("invalid_request");
    throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}

------------------------------------------

static boolean isAuthorizationResponse(MultiValueMap<String, String> request) {
        return isAuthorizationResponseSuccess(request) || isAuthorizationResponseError(request);
    }

    static boolean isAuthorizationResponseSuccess(MultiValueMap<String, String> request) {
        return StringUtils.hasText((String)request.getFirst("code")) && StringUtils.hasText((String)request.getFirst("state"));
    }

    static boolean isAuthorizationResponseError(MultiValueMap<String, String> request) {
        return StringUtils.hasText((String)request.getFirst("error")) && StringUtils.hasText((String)request.getFirst("state"));
    }

OAuth 리디렉션 콜백 요청이 인가 응답(authorization response) 으로서 유효한지 판단하는 로직

  • 사용자가 로그인/동의를 완료하고 리디렉션되었을 때 인가 코드(code)나 오류(error)가 제대로 왔는지 확인

세션에서 AuthorizationRequest 복원

OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest(request, response);
  • 인가 요청 당시 세션에 저장해 두었던 OAuth2AuthorizationRequest를 가져옴
  • 이 객체에는 state, redirectUri, registrationId 등이 담겨 있음

요약: 인가 요청시 생성했던 정보(state, client 등)를 복원한다.

클라이언트 등록 정보 확인

String registrationId = (String)authorizationRequest.getAttribute("registration_id");
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
  • 등록된 클라이언트 목록에서 현재 요청에 해당하는 ClientRegistration을 조회
  • 이 객체에는 client_id, token_uri, user_info_uri 등의 정보가 포함

요약: 어떤 OAuth2 provider(Kakao, Google 등)인지 식별하고, 해당 설정을 불러온다.

Authorization Code -> Access Token 요청 준비

String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request)).replaceQuery(null).build().toUriString();
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);
OAuth2AuthorizationExchange authExchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration, authExchange);
  • 받은 code와 이전 요청을 묶어서 OAuth2AuthorizationExchange 생성
    • 이를 기반으로 OAuth2LoginAuthenticationToken 객체를 생성하여 인증 요청 준비

요약: 인가 코드와 클라이언트 정보를 합쳐 인증 요청 객체를 만든다.

인증 처리 위임

OAuth2LoginAuthenticationToken authenticationResult = 
    (OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest);
  • 위에서 만든 인증 요청 객체를 Spring Security의 AuthenticationManager에 위임
  • 실제 토큰 요청, 사용자 정보 조회는 OAuth2LoginAuthenticationProvider에서 수행

요약: Access TOken과 사용자 정보를 요청하는 실질적인 인증 수행 단계이다.

최종 인증 객체 생성 및 저장

OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter.convert(authenticationResult);
oauth2Authentication.setDetails(authenticationDetails);
  • 인증 결과를 OAuth2AuthenticationToken으로 변환 (이 객체가 SecurityContext에 저장됨)
  • Principal, 권한, registrationId가 포함된 최종 인증 객체

요약: Spring Security가 사용하는 최종 사용자 인증 정보를 구성한다.

Access Token 저장

OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(...);
this.authorizedClientRepository.saveAuthorizedClient(...);
  • access token, refresh token, registration 정보 등을 AuthorizedClientRepository에 저장
  • 기본 저장 방식은 세션이지만, Redis나 DB로 커스터마이징 가능

요약: 토큰을 클라이언트 인증 저장소에 저장해, 이후 요청에서도 사용할 수 있게 한다.

최종 인증 객체 반환

return oauth2Authentication;
  • 필터 체인에서 다음 인증 처리로 넘겨지며, 로그인 완료 처리됨

요약: 모든 인증이 완료되었음을 알리며, 사용자 세션이 구성된다.

전체 요약

OAuth2LoginAuthenticaitonFilter의 attemptAuthentication() 메서드는 OAuth 2.0 Authorization Code Grant 방식의 핵심 로직을 처리한다.

이 메서드는 인가 서버로부터 리디렉션된 요청에서 인가 코드(code)를 추출하고, 이를 기반으로 Access Token을 요청한다.

이후 사용자 정보를 가져오고, 인증이 완료되면 최종 인증 객체(OAuth2AuthenticationToken)를 생성하여 SecurityContext에 저장한다.
또한, AccessToken과 RefreshToken은 OAuth2AuthorizedClientRepository를 통해 저장되어, 이후 요청에서도 재사용할 수 있도록 관리한다.

이 모든 과정은 Spring Security의 필터 체인 내에서 자동으로 처리되며, 개발자는 필요한 부분만 커스터마이징하면 된다.

OAuth2 로그인 구현 예제: 코드 중심

의존성 설정

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // JWT 발급 시
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
}

사용자 정보를 처리하는 커스텀 서비스 (OAuth2UserService 구현)

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = new DefaultOAuth2UserService().loadUser(userRequest);

        Map<String, Object> attributes = oauth2User.getAttributes();
        String provider = userRequest.getClientRegistration().getRegistrationId(); // kakao
        String oauthId = String.valueOf(attributes.get("id"));

        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        String email = (String) kakaoAccount.get("email");

        // DB에 유저 없으면 생성
        User user = userRepository.findByOauthId(oauthId)
                .orElseGet(() -> userRepository.save(User.ofKakao(oauthId, email)));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                attributes,
                "id"
        );
    }
}

사용자 정보 처리 및 JWT 발급

OAuth2User로부터 유저 정보 추출

public class KakaoOAuth2UserInfo {
    private final Map<String, Object> attributes;

    public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    public String getOAuthId() {
        return String.valueOf(attributes.get("id")); // 고유 ID
    }

    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        return (String) kakaoAccount.get("email");
    }

    public String getNickname() {
        Map<String, Object> profile = (Map<String, Object>) ((Map<String, Object>) attributes.get("kakao_account")).get("profile");
        return (String) profile.get("nickname");
    }
}

내부 DB 유저와 매핑 처리 (sign in/up)

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = new DefaultOAuth2UserService().loadUser(userRequest);
        KakaoOAuth2UserInfo userInfo = new KakaoOAuth2UserInfo(oauth2User.getAttributes());

        // 1. 고유 OAuth ID 확인
        String oauthId = userInfo.getOAuthId();

        // 2. DB에서 유저 조회 or 신규 생성
        User user = userRepository.findByOauthId(oauthId)
                .orElseGet(() -> userRepository.save(
                        User.ofKakao(oauthId, userInfo.getEmail(), userInfo.getNickname())
                ));

        // 3. 인증 객체 리턴
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                oauth2User.getAttributes(),
                "id"
        );
    }
}

JWT 발급 로직 연결하기

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
        OAuth2User oauth2User = authToken.getPrincipal();

        // 고유 ID를 기준으로 JWT 생성
        String userId = oauth2User.getAttribute("id").toString();
        String jwt = jwtProvider.createToken(userId);

        // JWT를 redirect or cookie 등에 담아 프론트로 전달
        response.sendRedirect("domain url?token=" + jwt);
    }
}

JWT 생성 클래스 (JwtProvider)

@Component
public class JwtProvider {

    @Value("${jwt.secret}")
    private String secret;

    public String createToken(String userId) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + 1000 * 60 * 60 * 24); // 1일

        return Jwts.builder()
                .setSubject(userId)
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(SignatureAlgorithm.HS256, secret)
                .compact();
    }
}

정리

  1. OAuth2UserService -> 사용자 정보 파싱
  2. DB에서 유저 매핑 (없으면 가입)
  3. 성공 핸들러 -> JWT 발급
  4. 클라이언트에게 리디렉션 또는 토큰 응답

토큰 기반 인증 흐름 확장하기

Access Token / Refresh Token 구조 설계

  • Access Token
    • 유저의 인증 정보(ID, 권한 등)를 담고 있는 JWT
    • 클라이언트가 API 호출 시 헤더에 담아 전송
    • 만료 기한: 짧게 (예: 30분)
    // Access Token (JWT)
    {
      "sub": "user-id-uuid",
      "role": "USER",
      "exp": 1713450000
    }
  • Refresh Token
    • Access Token이 만료됐을 때 새로운 Access Token을 발급받는 데 사용
    • DB 또는 Redis에 저장 (보안상 JWT 보단 UUID를 저장 권장)
    • 만료 기한: 길게 (예: 2주)
    // Refresh Token (DB에 저장)
    {
      "userId": "user-id-uuid",
      "refreshToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "expiresAt": "2025-05-01T00:00:00Z"
    }

필터를 통한 JWT 인증 처리 (OncePerRequestFilter 활용)

모든 요청에 대해 JWT가 있는지 검사하고, 유효하면 인증 컨텍스트에 저장

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
        throws ServletException, IOException {

        String token = jwtProvider.resolveToken(request);

        if (token != null && jwtProvider.validateToken(token)) {
            String userId = jwtProvider.getUserId(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
            UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
}

필터 등록: Security Config

http
  .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

로그인 후 토큰 전달 방식
1. 리디렉션 + 쿼리 파라미터 방식
2. JSON 응답 방식 (API 기반)
3. HttpOnly 쿠키 방식

  • 쿠키 방식은 브라우저 보안이 중요한 SPA에 적합

정리 흐름

[OAuth2 로그인 완료]
      ↓
[JWT 발급: Access + Refresh]
      ↓
[Access Token → Authorization: Bearer 헤더로 API 요청]
      ↓
[JwtAuthenticationFilter → 사용자 인증 처리]
      ↓
[Access Token 만료 시 → Refresh Token으로 재발급]

주의 사항

Provider마다 다른 사용자 정보 처리 방식 (Kakao, Google 등)

OAuth2 Provider(Google, Kakao, GitHub 등)는 user-info API의 형식이 서로 다르기 때문에, 사용자 정보를 추출할 때 반드시 provider 별로 구조를 분기하거나 통일된 추상화가 필요합니다.

  • Google
    • 평탄한 구조: email, name, picture 등 바로 접근 가능
  • Kakao
    • 중첩된 구조: kakao_account, profile.nickname, email 등
  • Naver
    • response 내부에 사용자 정보가 있음

유연하게 처리하는 방법

OAuth2UserService에서 registrationId로 provider 식별 후 분기

CSRF와 CORS 설정 유의점

  • CSRF (Cross Site Request Forgery)
    • Spring Security는 기본 설정으로 CSRF 보호를 활성화
    • 하지만 JWT 기반 API 서버는 CSRF를 꺼야 함
      • WHY? JWT는 세션을 사용하지 않으므로 CSRF 설정이 의미 없음
  • CORS (Cross-Origin Resource Sharing)
    • 프론트 도메인과 백엔드 도메인이 다를 경우 반드시 설정 필요
    • 설정 누락 시: 브라우저에서 CORS policy: No 'Access-Control-Allow-Origin' 오류 발생

Redirect URI 허용 정책 관리

대부분의 OAuth Provider(Kakao, Google 등)는 사전 등록된 redirect_uri만 허용 -> 동적 URI 사용 불가, exact match 필요


출처
Spring Security OAuth 공식 문서

0개의 댓글