Spring Security ‐ OAuth 2.0 oauth2Login - woojin-playground/Backend-PlayGround GitHub Wiki

OAuth2LoginConfigurer 초기화 이해

스크린샷 2025-08-31 오후 3 00 33 스크린샷 2025-08-31 오후 3 00 48 스크린샷 2025-08-31 오후 3 01 00
  • SecurityFilterChain 타입의 빈을 생성해서 보안 필터를 구성한다.
  • HttpSecurity에 있는 oauth2Login()과 oauth2Client() API를 정의하고 빌드한다.

OAuth2 로그인 페이지

  • 기본적으로 OAuth2 Login 페이지는 DefaultLoginPageGeneratingFilter가 자동으로 생성해준다.
  • 이 디폴트 페이지는 OAuth 2.0 클라이언트명을 보여준다.
  • 링크를 누르면 인가 요청을 시작할 수 있게 된다.
  • 요청 매핑 URL : /oauth2/authorization/{registrationId}
  • 디폴트 로그인 페이지를 재정의하려면 oauth2Login().loginPage()를 재정의하면 된다.

✅ DefaultLoginPageGeneratingFilter

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
    public static final String DEFAULT_LOGIN_PAGE_URL = "/login";
    public static final String ERROR_PARAMETER_NAME = "error";
    private String loginPageUrl;
    private String logoutSuccessUrl;
    private String failureUrl;
    private boolean formLoginEnabled;
    private boolean oauth2LoginEnabled;
    private boolean saml2LoginEnabled;
    private boolean passkeysEnabled;
    private boolean oneTimeTokenEnabled;
    private String authenticationUrl;
    private String generateOneTimeTokenUrl;
    private String usernameParameter;
    private String passwordParameter;
    private String rememberMeParameter;
    private Map<String, String> oauth2AuthenticationUrlToClientName;
    private Map<String, String> saml2AuthenticationUrlToProviderName;
    private Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs = (request) -> {
        return Collections.emptyMap();
    };
    private Function<HttpServletRequest, Map<String, String>> resolveHeaders = (request) -> {
        return Collections.emptyMap();
    };
    private static final String CSRF_HEADERS = "{\"{{headerName}}\" : \"{{headerValue}}\"}";
    private static final String PASSKEY_SCRIPT_TEMPLATE = "\t<script type=\"text/javascript\" src=\"{{contextPath}}/login/webauthn.js\"></script>\n\t<script type=\"text/javascript\">\n\t<!--\n\t\tdocument.addEventListener(\"DOMContentLoaded\",() => setupLogin({{csrfHeaders}}, \"{{contextPath}}\", document.getElementById('passkey-signin')));\n\n\t//-->\n\t</script>\n";
    private static final String PASSKEY_FORM_TEMPLATE = "<div class=\"login-form\">\n<h2>Login with Passkeys</h2>\n<button id=\"passkey-signin\" type=\"submit\" class=\"primary\">Sign in with a passkey</button>\n</div>\n";
    private static final String LOGIN_PAGE_TEMPLATE = "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n    <meta name=\"description\" content=\"\">\n    <meta name=\"author\" content=\"\">\n    <title>Please sign in</title>\n    <link href=\"{{contextPath}}/default-ui.css\" rel=\"stylesheet\" />{{javaScript}}\n  </head>\n  <body>\n    <div class=\"content\">\n{{formLogin}}\n{{oneTimeTokenLogin}}{{passkeyLogin}}\n{{oauth2Login}}\n{{saml2Login}}\n    </div>\n  </body>\n</html>";
    private static final String LOGIN_FORM_TEMPLATE = "      <form class=\"login-form\" method=\"post\" action=\"{{loginUrl}}\">\n        <h2>Please sign in</h2>\n{{errorMessage}}{{logoutMessage}}\n        <p>\n          <label for=\"username\" class=\"screenreader\">Username</label>\n          <input type=\"text\" id=\"username\" name=\"{{usernameParameter}}\" placeholder=\"Username\" required autofocus>\n        </p>\n        <p>\n          <label for=\"password\" class=\"screenreader\">Password</label>\n          <input type=\"password\" id=\"password\" name=\"{{passwordParameter}}\" placeholder=\"Password\" {{autocomplete}}required>\n        </p>\n{{rememberMeInput}}\n{{hiddenInputs}}\n        <button type=\"submit\" class=\"primary\">Sign in</button>\n      </form>";
    private static final String HIDDEN_HTML_INPUT_TEMPLATE = "<input name=\"{{name}}\" type=\"hidden\" value=\"{{value}}\" />\n";
    private static final String ALERT_TEMPLATE = "<div class=\"alert alert-danger\" role=\"alert\">{{message}}</div>";
    private static final String OAUTH2_LOGIN_TEMPLATE = "<h2>Login with OAuth 2.0</h2>\n{{errorMessage}}{{logoutMessage}}\n<table class=\"table table-striped\">\n  {{oauth2Rows}}\n</table>";
    private static final String OAUTH2_ROW_TEMPLATE = "<tr><td><a href=\"{{url}}\">{{clientName}}</a></td></tr>";
    private static final String SAML_LOGIN_TEMPLATE = "<h2>Login with SAML 2.0</h2>\n{{errorMessage}}{{logoutMessage}}\n<table class=\"table table-striped\">\n  {{samlRows}}\n</table>";
    private static final String SAML_ROW_TEMPLATE = "<tr><td><a href=\"{{url}}\">{{clientName}}</a></td></tr>";
    private static final String ONE_TIME_TEMPLATE = "      <form id=\"ott-form\" class=\"login-form\" method=\"post\" action=\"{{generateOneTimeTokenUrl}}\">\n        <h2>Request a One-Time Token</h2>\n{{errorMessage}}{{logoutMessage}}\n        <p>\n          <label for=\"ott-username\" class=\"screenreader\">Username</label>\n          <input type=\"text\" id=\"ott-username\" name=\"username\" placeholder=\"Username\" required>\n        </p>\n{{hiddenInputs}}\n        <button class=\"primary\" type=\"submit\" form=\"ott-form\">Send Token</button>\n      </form>\n";
  ...
}

OAuth2 Authorization Code 요청

✅ OAuth2AuthorizationRequestRedirectFilter

  • 클라이언트는 사용자 브라우저를 통해 인가 서버 권한 부여 엔드포인트로 리다이렉트해 권한 코드 부여 흐름을 시작한다.
스크린샷 2025-08-31 오후 3 36 07
  • 요청 매핑 URL
    • AuthorizationRequestMatcher : /oauth2/authorization/{registrationId}
    • AuthorizationEndpointConfig.authorizationRequestBaseUri를 통해 재정의가 가능하다.

✅ DefaultOAuth2AuthorizationRequestResolver

  • 웹 요청에 대해 OAuth2AuthorizationRequest 객체를 최종 완성한다.
  • /oauth2/authorization/{registrationId}와 일치하는지 확인해서 일치하면 registrationId를 추출하고 이를 사용해서 ClientRegistration을 가져와 OAuth2AuthorizationRequest를 빌드한다.
스크린샷 2025-08-31 오후 3 39 48

✅ OAuth2AuthorizationRequest

  • 토큰 엔드포인트 요청 파라미터를 담은 객체로서 인가 응답을 연계하고 검증할 때 사용한다.
스크린샷 2025-08-31 오후 3 40 25

✅ OAuth2AuthorizationRequestRepository

  • 인가 요청을 시작한 시점부터 인가 요청을 받는 시점까지 OAuth2AuthorizationRequest를 유지해준다.
스크린샷 2025-08-31 오후 3 41 16

OAuth2 Access Token 교환

✅ OAuth2LoginAuthenticationFilter

  • 인가서버로부터 리다이렉트되면서 전달된 code를 인가 서버의 Access Token으로 교환하고 Access Token이 저장된 OAuth2LoginAuthenticationToken을 AuthenticationManager에 위임하여 UserInfo 정보를 요청해서 최종 사용자에 로그인한다.
  • OAuth2AuthorizedClientRepository를 사용해 OAuth2AuthorizedClient를 저장한다.
  • 인증에 성공하면 OAuth2AuthenticationToken이 생성되고 SecurityContext에 저장되면서 인증 처리를 완료한다.
스크린샷 2025-08-31 오후 3 50 08
  • 요청 매핑 URL : /login/oauth2/code/*

✅ OAuth2LoginAuthenticationProvider

  • 인가 서버로부터 리다이렉트 된 이후 프로세스를 처리하며 Access Token으로 교환하고 이 토큰을 사용해 UserInfo 처리를 담당한다.
  • Scope에 openId가 포함되어 있으면 OidcAuthorizationCodeAuthenticationProvider를 호출하고 아니면 OAuth2AuthorizationCodeAuthenticationProvider를 호출하도록 제어한다.
스크린샷 2025-08-31 오후 3 53 16

✅ OAuth2AuthorizationCodeAuthenticationProvider

  • 권한 코드 부여 흐름을 처리하는 AuthenticationProvider
  • 인가 서버에 Authorization Code와 Access Token 교환을 담당하는 클래스
스크린샷 2025-08-31 오후 3 54 01

✅ OidcAuthorizationCodeAuthenticationProvider

  • OpenID Connect Core 1.0 권한 코드 부여 흐름을 처리하는 AuthenticationProvider이며 요청 Scope에 openId가 존재할 경우 실행된다.
스크린샷 2025-08-31 오후 3 55 20

✅ DefaultAuthorizationCodeTokenResponseClient

  • 인가 서버의 token 엔드포인트로 통신을 담당하며 Access Token을 받은 후 OAuth2AccessTokenResponse에 저장하고 반환한다.
스크린샷 2025-08-31 오후 3 56 09 스크린샷 2025-08-31 오후 3 56 23 스크린샷 2025-08-31 오후 3 56 47 스크린샷 2025-08-31 오후 3 59 16

OAuth2 User 모델

✅ OAuth2UserService

  • 액세스 토큰을 사용해서 UserInfo 엔드포인트 요청으로 최종 사용자(=리소스 소유자)의 속성을 가져오며 OAuth2User 타입의 객체를 리턴한다.
  • 구현체로 DefaultOAuth2UserService와 OidcUserService가 제공된다.

✅ DefaultOAuth2UserService

  • 표준 OAuth 2.0 Provider를 지원하는 OAuth2UserService 구현체이다.
  • OAuth2UserRequest에 Access Token을 담아 인가 서버와 통신 후 사용자의 속성을 가져온다.
  • 최종 OAuht2User 타입의 객체를 반환한다.

✅ OidcUserService

  • OpenID Connect 1.0 Provider를 지원하는 OAuth2UserService 구현체이다.
  • OidcUserRequest에 있는 ID Token을 통해 인증 처리를 하며 필요시 DefaultOAuth2UserService를 사용해서 UserInfo 엔드포인트의 사용자 속성을 요청한다.
  • 최종 OidcUser 타입의 객체를 반환한다.
스크린샷 2025-09-09 15 44 18 스크린샷 2025-09-13 오후 12 11 23
  • DefaultOAuth2UseService는 OAuth2User 타입의 객체를 반환한다.
  • OidcUserService는 OidcUser 타입의 객체를 반환한다.
  • OidcUserRequest의 승인된 토큰에 포함되어 있는 scope 값이 accessibleScopes의 값들 중 하나 이상 포함되어 있을 경우 UserInfo 엔드포인트를 요청한다.

✅ OAuth2User & OidcUser

  • 시큐리티는 UserAttributes 및 ID Token Claims을 집계 & 구성하여 OAuth2User와 OidcUser 타입의 클래스를 제공한다.
  • OAuth2User
    • OAuth 2.0 Provider에 연결된 사용자 주체를 나타낸다.
    • 최종 사용자 인증에 대한 정보인 Attributes를 포함하고 first_name, middle_name, last_name, email, phone_number, address 등으로 구성된다.
    • 기본 구현체는 DefaultOAuth2User이며 인증 이후 Authentication의 principal 속성에 저장된다.
  • OidcUser
    • OAuth2User를 상속한 인터페이스이며 OIDC Provider에 연결된 사용자 주체를 나타낸다.
    • 최종 사용자 인증에 대한 정보인 Claims를 포함하고 있으며 OidcIdToken 및 OidcUserInfo에서 집계 및 구성된다.
    • 기본 구현체는 DefaultOidcUser이며 DefaultOAuth2User를 상속하고 있으며 인증 이후 Authentication의 principal 속성에 저장된다.
스크린샷 2025-09-13 오후 12 21 00 스크린샷 2025-09-13 오후 12 21 11
  • OAuth 2.0 로그인을 통해 인증받은 최종 사용자의 Principal에는 OAuth2User 혹은 OidcUser 타입의 객체가 저장된다.
  • 권한 부여 요청 시 scope 파라미터에 openid를 포함했다면 OidcUser 타입의 객체가 생성되며 OidcUser는 OidcUserInfo와 idToken을 가지고 있으며 최종 사용자에 대한 Claims 정보를 포함하고 있다.
  • OAuth2UserAuthority는 인가서버로부터 수신한 scope 정보를 집계해서 권한 정보를 구성한다.
  • OidcUser 객체를 생성할 때 ID 토큰이 필요한데 이 떄, JWT로 된 ID 토큰은 JWS로 서명이 되어 있기 때문에 반드시 정해진 알고리즘에 의한 검증이 성공하면 OidcUser 객체를 생성해야 한다.
스크린샷 2025-09-13 오후 12 54 03

OAuth2 UserInfo 엔드포인트

스크린샷 2025-09-13 오후 1 03 32 스크린샷 2025-09-13 오후 1 03 52

OAuth2 OpenID Connect 로그아웃

  • 클라이언트는 로그아웃 엔드포인트를 사용하여 웹 브라우저에 대한 세션과 쿠키를 지운다.
  • 클라이언트 로그아웃 성공 후 OidcClientInitiatedLogoutSuccessHandler를 호출하여 OpenID Provider 세션 로그아웃을 요청한다.
  • OpenID Provider 로그아웃이 성공하면 지정된 위치로 리다이렉트한다.
  • 인가서버 메타데이터 사양에 있는 로그아웃 엔드포인트는 end_session_endpoint로 정의되어 있다.
    • endSessionEndpoint = http://localhost:8080/realms/oauth2/protocol/openid-connect/logout

✅ Spring MVC에서 인증 객체 참조하는 방법

  • Authentication
    • oauth2Login()로 인증을 받게 되면 Authentication은 OAuth2AuthenticationToken 타입의 객체로 바인딩된다.
    • principal은 OAuth2User 타입 혹은 OidcUser 타입의 구현체가 저장된다.
    • DefaultOAuth2User는 /userInfo 엔드포인트 요청으로 받은 User 클레임 정보로 생성된 객체이다.
    • DefaultOidcUser는 OpenID Connect 인증을 통해 ID Token 및 클레임 정보가 포함된 객체이다.
  • @AuthenticationPrincipal
    • AuthenticationPrincipalArgumentResolver 클래스에서 요청을 가로채어 바인딩 처리를 한다.
    • Authentication을 SecurityContext에서 꺼내 Principal 속성에 OAuth2User 혹은 OidcUser 타입의 객체를 저장한다.

Authorization BaseUri & Redirect BaseUri

http
    .oauth2Login(oauth2 -> oauth2
    .loginPage("/login")
    .loginProcessingUrl("/login/v1/oauth2/code/*")
    .authorizationEndpoint(authorizationEndpointConfig -> authorizationEndpointConfig.baseUri("/oauth2/v1/authorization")) // 권한 부여 요청 BaseUri를 커스텀
    .redirectionEndpoint(redirectionEndpointConfig -> redirectionEndpointConfig.baseUri("/login/v1/oauth2/code/*")));  // 인가 응답의 BaseUri를 커스텀
스크린샷 2025-09-13 오후 4 28 09
  • 권한 부여 요청을 처리하는 OAuth2AuthorizationRequestRedirectFilter 에서 요청에 대한 매칭여부를 판단한다.
  • 설정에서 변경한 값이 클라이언트의 링크 정보와 일치하도록 맞추어야 한다.
스크린샷 2025-09-13 오후 4 28 46
  • Token 요청을 처리하는 OAuth2LoginAuthenticationFilter 에서 요청에 대한 매칭여부를 판단한다.
  • application.yml 설정 파일에서 registration 속성의 redirectUri 설정에도 변경된 값을 적용해야 한다.
  • 인가서버의 redirectUri 설정에도 변경된 값을 적용해야한다.

OAuth2AuthorizationRequestResolver

  • Authorization Code Grant 방식에서 클라이언트가 인가 서버로 권한 부여 요청할 때 실행되는 클래스
  • OAuth2AuthorizationRequestResolver는 OAuth 2.0 인가 프레임워크에 정의된 표준 파라미터 외에 다른 파라미터를 추가하는 식으로 인가 요청을 할 때 사용한다.
  • DefaultOAuth2AuthorizationRequestResolver가 디폴트 구현체로 제공되며 Consumer 속성에 커스텀할 내용을 구현한다.
@Bean
SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeRequests((requests) -> requests.antMatchers("/home").permitAll()
        .anyRequest().authenticated());
    http.oauth2Login(authLogin -> authLogin.authorizationEndpoint(authEndpoint -> authEndpoint.authorizationRequestResolver(customOAuth2AuthenticationRequestResolver())));
    return http.build();
}

private OAuth2AuthorizationRequestResolver customOAuth2AuthenticationRequestResolver() {
    return new CustomOAuth2AuthorizationRequestResolver(clientRegistrationRepository, "/oauth2/authorization");
}
⚠️ **GitHub.com Fallback** ⚠️