OAuth2LoginConfigurer 초기화 이해
- SecurityFilterChain 타입의 빈을 생성해서 보안 필터를 구성한다.
- HttpSecurity에 있는 oauth2Login()과 oauth2Client() API를 정의하고 빌드한다.
- 기본적으로 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
- 클라이언트는 사용자 브라우저를 통해 인가 서버 권한 부여 엔드포인트로 리다이렉트해 권한 코드 부여 흐름을 시작한다.
- 요청 매핑 URL
- AuthorizationRequestMatcher :
/oauth2/authorization/{registrationId}
- AuthorizationEndpointConfig.authorizationRequestBaseUri를 통해 재정의가 가능하다.
✅ DefaultOAuth2AuthorizationRequestResolver
- 웹 요청에 대해 OAuth2AuthorizationRequest 객체를 최종 완성한다.
-
/oauth2/authorization/{registrationId}와 일치하는지 확인해서 일치하면 registrationId를 추출하고 이를 사용해서 ClientRegistration을 가져와 OAuth2AuthorizationRequest를 빌드한다.
✅ OAuth2AuthorizationRequest
- 토큰 엔드포인트 요청 파라미터를 담은 객체로서 인가 응답을 연계하고 검증할 때 사용한다.
✅ OAuth2AuthorizationRequestRepository
- 인가 요청을 시작한 시점부터 인가 요청을 받는 시점까지 OAuth2AuthorizationRequest를 유지해준다.
✅ OAuth2LoginAuthenticationFilter
- 인가서버로부터 리다이렉트되면서 전달된 code를 인가 서버의 Access Token으로 교환하고 Access Token이 저장된 OAuth2LoginAuthenticationToken을 AuthenticationManager에 위임하여 UserInfo 정보를 요청해서 최종 사용자에 로그인한다.
- OAuth2AuthorizedClientRepository를 사용해 OAuth2AuthorizedClient를 저장한다.
- 인증에 성공하면 OAuth2AuthenticationToken이 생성되고 SecurityContext에 저장되면서 인증 처리를 완료한다.
- 요청 매핑 URL :
/login/oauth2/code/*
✅ OAuth2LoginAuthenticationProvider
- 인가 서버로부터 리다이렉트 된 이후 프로세스를 처리하며 Access Token으로 교환하고 이 토큰을 사용해 UserInfo 처리를 담당한다.
- Scope에 openId가 포함되어 있으면 OidcAuthorizationCodeAuthenticationProvider를 호출하고 아니면 OAuth2AuthorizationCodeAuthenticationProvider를 호출하도록 제어한다.
✅ OAuth2AuthorizationCodeAuthenticationProvider
- 권한 코드 부여 흐름을 처리하는 AuthenticationProvider
- 인가 서버에 Authorization Code와 Access Token 교환을 담당하는 클래스
✅ OidcAuthorizationCodeAuthenticationProvider
- OpenID Connect Core 1.0 권한 코드 부여 흐름을 처리하는 AuthenticationProvider이며 요청 Scope에 openId가 존재할 경우 실행된다.
✅ DefaultAuthorizationCodeTokenResponseClient
- 인가 서버의 token 엔드포인트로 통신을 담당하며 Access Token을 받은 후 OAuth2AccessTokenResponse에 저장하고 반환한다.
- 액세스 토큰을 사용해서 UserInfo 엔드포인트 요청으로 최종 사용자(=리소스 소유자)의 속성을 가져오며 OAuth2User 타입의 객체를 리턴한다.
- 구현체로 DefaultOAuth2UserService와 OidcUserService가 제공된다.
✅ DefaultOAuth2UserService
- 표준 OAuth 2.0 Provider를 지원하는 OAuth2UserService 구현체이다.
- OAuth2UserRequest에 Access Token을 담아 인가 서버와 통신 후 사용자의 속성을 가져온다.
- 최종 OAuht2User 타입의 객체를 반환한다.
- OpenID Connect 1.0 Provider를 지원하는 OAuth2UserService 구현체이다.
- OidcUserRequest에 있는 ID Token을 통해 인증 처리를 하며 필요시 DefaultOAuth2UserService를 사용해서 UserInfo 엔드포인트의 사용자 속성을 요청한다.
- 최종 OidcUser 타입의 객체를 반환한다.
- DefaultOAuth2UseService는 OAuth2User 타입의 객체를 반환한다.
- OidcUserService는 OidcUser 타입의 객체를 반환한다.
- OidcUserRequest의 승인된 토큰에 포함되어 있는 scope 값이 accessibleScopes의 값들 중 하나 이상 포함되어 있을 경우 UserInfo 엔드포인트를 요청한다.
- 시큐리티는 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 속성에 저장된다.
- OAuth 2.0 로그인을 통해 인증받은 최종 사용자의 Principal에는 OAuth2User 혹은 OidcUser 타입의 객체가 저장된다.
- 권한 부여 요청 시 scope 파라미터에 openid를 포함했다면 OidcUser 타입의 객체가 생성되며 OidcUser는 OidcUserInfo와 idToken을 가지고 있으며 최종 사용자에 대한 Claims 정보를 포함하고 있다.
- OAuth2UserAuthority는 인가서버로부터 수신한 scope 정보를 집계해서 권한 정보를 구성한다.
- OidcUser 객체를 생성할 때 ID 토큰이 필요한데 이 떄, JWT로 된 ID 토큰은 JWS로 서명이 되어 있기 때문에 반드시 정해진 알고리즘에 의한 검증이 성공하면 OidcUser 객체를 생성해야 한다.
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를 커스텀
- 권한 부여 요청을 처리하는 OAuth2AuthorizationRequestRedirectFilter 에서 요청에 대한 매칭여부를 판단한다.
- 설정에서 변경한 값이 클라이언트의 링크 정보와 일치하도록 맞추어야 한다.
- 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");
}