
OAuth 2.0 Client
OAuth 2.0 인가 프레임워크의 역할 중 인가 서버 및 리소스 서버와의 통신을 담당하는 클라이언트의 기능을 필터 기반으로 구현한 모듈
OAuth 2.0 Login API
어플리케이션 사용자를 외부 OAuth2.0 Provider나 OpenID Connect 1.0 Provider 계정으로 로그인 할 수 있는 기능 제공
- 권한 부여 유형 중 Authorization Code 방식을 사용
OAuth 2.0 Client API
인가 서버의 권한 부여 유형에 따른 엔드 포인트와 직접 통신할 수 있는 API 제공
- 권한 부여 종류
- Client Credentials
- Resource Owner Password Credentials
- Refresh Token
- 리소스 서버와 연동해서 Token을 가지고 원하는 데이터를 가지고 오는 기능 구현 가능
OAuth 2.0 Client Fundamentals
Client 권한 설정 과정
- application.yml에 환경 설정 파일에 클라이언트, 인가서버 엔드포인트 설정
- 환경설정에 있는 정보가 OAuth2ClientProperties의 각 속성에 바인딩
- ClientRegistration클래스에 인가서버로 권한부여 요청을 하기 위한 OAuth2ClientProperties가 저장된다.
- OAuth2Client는 ClientRegistration을 참조해 권한 요청을 위한 매개변수를 구성하고 인가 서버와 통신한다.
Client Registration 초기화 과정
-
OAuth2ClientRegistrationRepositoryConfiguration에서
OAuth2ClientPropertiesRegistrationAdapter의 getClientRegistration함수를 실행- (ClientRegistrationRepository 빈이 존재하지 않을 경우 실행)
-
getClientRegistration 함수 내에서 ClientRegistrations객체의
fromIssuerLocation가 실행되는데 application.yml에 issuer-uri의 OIDC, AUTH 경로에 요청해 필요한 메타데이터 값을 가져온다
요약
- Client Registration은 인가 서버에 요청해 메타 데이터값을 가져오는데 yml 설정으로 값을 수동으로 지정해 줄 수 있다.
- ClientRepository를 @Bean으로 등록해 yml 설정 없이 코드로 설정 가능
ClientRegistration 상세 값
title | Description |
---|---|
registrationId | ClientRegistration을 식별할 수 있는 유니크한 ID. |
clientId | 클라이언트 식별자. |
clientSecret | 클라이언트 secret. |
clientAuthenticationMethod | provider에서 클라이언트를 인증 시 사용 메소드 basic, post, none (public 클라이언트). |
authorizationGrantType | 네 가지 권한 부여 타입 정의 (authorization_code, implicit, client_credentials, password) |
redirectUriTemplate | 클라이언트에 등록한 리다이렉트 URL 사용자의 인증으로 클라이언트에 접근 권한을 부여하고 나면, 인가 서버가 이 URL로 최종 사용자의 브라우저를 리다이렉트 |
Scopes | 인가 요청 플로우에서 클라이언트가 요청한 openid, 이메일, 프로필 등의 scope. |
clientName | 클라이언트를 나타내는 이름 |
authorizationUri | 인가 서버의 인가 엔드포인트 URI. |
tokenUri | 인가 서버의 토큰 엔드포인트 URI. |
jwkSetUri | 인가 서버에서 JSON 웹 키 (JWK) 셋을 가져올 때 사용할 URI. 이 키 셋엔 ID 토큰의 JSON Web Signature (JWS) 를 검증할 때 사용할 암호키가 있으며, UserInfo 응답을 검증할 때도 사용할 수 있다. |
configurationMetadata | OpenID Provider 설정 정보로서 application.properties 에 spring.security.oauth2.client.provider.[providerId].issuerUri를 설정했을 때만 사용할 수 있다. |
Client Repository
OAuth2.0 & OpenID Connect1.0의 ClientRegistration 저장소 역할
- 인가 서버에 일차적으로 저장된 클라이언트 등록 정보의 일부를 검색하는 기능 제공
- 자동 설정을 사용하면 ClientRegistrationRepository도 ApplicationContext내 @Bean으로 등록하므로 필요하면 원하는 곳에 의존성 주입이 가능
-> 등록된 클라이언트 정보를 가져올 수 있다.
자동 설정에 의한 초기화 과정
-
OAuth2ImportSelector에서 설정 클래스에 따라 class load
-
위 이미지 와 같이 로드되고 OAuth2ClientWebMvcSecurityConfiguration에서 2개의 클래스 로드
-
DefaultOAuth2AuthorizedClientManager는 실제 권한 부여 요청에 따라 작업을 담당하는 클래스
-
HandlerMethodArgumentResolver 는 웹 mvc관련 클래스
DefaultOAuth2AuthorizedClientManager가 실제 권한 부여 작업 담당 클래스로 중요!
Bean 등록 과정
OAuth2ClientRegistrationRepositoryConfiguration은 위에서 살펴본 부분이고
OAuth2WebSecurityConfiguration을 살펴보면 InMemoryOAuth2AuthorizedClientService에서 OAuth2AuthorizedClient를 관리 한다
OAuth2AuthorizedClient는 인가서버로 부터 권한 부여가 되면 승인된 정보들이 저장되는데 Client정보, accessToken, refreshToken, 사용자 정보 같은 것들이 저장되고 이 객체를 Spring MVC에서 참조해 사용
OAuth 2.0 Client - oauth2Login()
OAuth2.0 Client Module을 통해서 클라이언트가 인가 서버로 부터 권한 부여 받고, 사용자 인증 처리, 권한 체크까지 인증 처리
oauth2.0 config 설정을 통해 http.oauth2Login()이 실행이 되면
Form방식과 동일하게 OAuth2LoginConfigurer클래스가 init, configure 함수를 초기화 한다.
init함수부터 먼저 살펴보면
-
OAuth2LoginAuthenticationFilter 인증 필터가 생성되는데 설정한 경로로 요청이 오면 Authorization Code Grant Type 방식에서 code를 가지고 AccessToken을 발급하는 단계를 담당한다.
(default : /login/oauth2/code/*) -
OAuth2LoginAuthenticationProvider 는 실제로 OAuth2.0 인가서버와 통신하는 기능을 담당한다.
-
OidcAuthorizationCodeAuthenticationProvider 는 OpenID 프로토콜을 통해 사용자 정보를 가져온다.
-
DefaultLoginPageGeneratingFilter는 실제 로그인 페이지가 필요한데 해당 페이지를 생성해 주는 기능 담당
configure함수를 살펴보면 OAuth2AuthorizationRequestRedirectFilter가 생성되는데 해당 필터는 Authorization Code Grant Type 방식에서 code를 발급해주는 기능 담당
마지막으로 Configurer가 가지고 있는 속성파일을 보면 4개의 Config를 가지고 있다
-
AuthorizationEndpointConfig : code 발급 요청 시 설정 파일
-
RedirectionEndpointConfig : 인가서버에서 클라이언트에게 코드를 발급하기 위해 redirect하는데 이 정보를 관리
-
TokenEndpointConfig : AccessToken을 요청할 때 인가 서버에 요청하는데 이 때 사용되는 속성
-
UserInfoEndpointConfig : AccessToken을 가지고 인가서버에서 User정보를 가지고 올 때 관련된 url나 설정 된 정보를 가지고 있는 config
Authorization Code - 주요 클래스
1) OAuth2AuthorizationRequestRedirectFilter
-> 권한 코드 부여 흐름 시작
-> 해당 필터의 동작 조건 : AuthorizationRequestMatcher : /oauth2/authorization/{registrationId}*
-> AuthorizationEndpointConfig. authorizationRequestBaseUri 를 통해 재정의 가능
2) DefaultOAuth2AuthorizationRequestResolver
- 웹 요청에 대하여 OAuth2AuthorizationRequest 객체 생성
- /oauth2/authorization/{registrationId} 와 일치하는지 확인해서 일치하면 registrationId를 추출하고 이를 사용해서 ClientRegistration을 가져와 OAuth2AuthorizationRequest 빌드
3) OAuth2AuthorizationRequest
- 토큰 엔드포인트 요청 파라미터를 담은 객체로서 인가 응답을 연계하고 검증할 때 사용
(임시 코드를 받기위한 서버의 엔드 포인트)
4) OAuth2AuthorizationRequestRepository
- 인가 요청을 시작한 시점부터 인가 요청을 받는 시점까지 (리다이렉트) OAuth2AuthorizationRequest 유지
Authorization Code Grant Type - get code
-
oauth2/authorization/{registrationId}로 request 요청이 오면 OAuth2AuthorizationRequestRedirectFilter가 받고 DefaultOAuth2AuthorizationRequestResolver가 요청 정보와 가지고 있는 requestMatcher값을 비교
-
값이 동일하면 /login/oauth2/code/{registrationId} 경로를 생성 하면서 OAuth2AutorizationRequest객체 생성
-
OAuth2AutorizationRequest 객체를 세션에 저장
(세션을 사용하지 않을 경우 쿠키에 저장해서 참조하도록 repository 객체 재정의 가능) -
인가서버에서 검증 후 code와 state값을 담아 반환한다.
- 1번에서 값이 동일하지 않을경우 AuthenticationEntryPoint에 설정된 Url로 이동 ()
요약하면 Authorization Code Grant Type 첫번째 code를 받는 기능을 담당하는 Filter가
OAuth2AuthorizationRequestRedirectFilter
Authorization AccessToken 주요 클래스
1) OAuth2LoginAuthenticationFilter
- 인가서버로부터 리다이렉트 되면서 전달된 code 를 인가서버의 Access Token 으로 교환
- Access Token 이 저장된 OAuth2LoginAuthenticationToken을 AuthenticationManager에 위임하여 UserInfo 정보를 요청해서 최종 사용자에 로그인
- OAuth2AuthorizedClientRepository를 사용하여 OAuth2AuthorizedClient 를 저장한다.
- 인증에 성공 시 OAuth2AuthenticationToken 이 생성, SecurityContext에 저장되며 인증 처리를 완료
- /login/oauth2/code/*로 url이 매핑된다
2) OAuth2LoginAuthenticationProvider
- Access Token 으로 교환하고 이 토큰을 사용하여 UserInfo 처리를 담당
- Scope 에 openid 유,무에 따라 OAuth처리 Provider, OpenId처리 Provider 호출
3) OAuth2AuthorizationCodeAuthenticationProvider
- 권한 코드 부여 흐름을 처리하는 AuthenticationProvider
- 인가서버에 Authorization Code 와 AccessToken 의 교환을 담당하는 클래스
4) OidcAuthorizationCodeAuthenticationProvider
- OpenID Connect Core 1.0 권한 코드 부여 흐름을 처리하는 AuthenticationProvider 이며 요청 Scope 에 openid 가 존재할 경우 실행
5) DefaultAuthorizationCodeTokenResponseClient
- 인가서버의 token 엔드 포인트로 통신을 담당하며 AccessToken 을 받은 후 OAuth2AccessTokenResponse 에 저장하고 반환한다
Authorization Code Grant Type - get AccessToken
- code를 받아오면 OAuth2LoginAuthenticationFilter 실행
- OAuth2AuthorizationRequest(세션에 저장 된), OAuth2AccessTokenResponse(인가 서버로 부터 받은)를 가진 OAuth2LoginTuthenticationToken을 저장
- OAuth2LoginAuthenticationProvider에서 AccessToken을 가져옴(/token)
- 가져온 OAuth2AuthorizationCodeAuthentication Token을 사용해 DefaultOAuth2UserService에서 인가서버로 부터 사용자 정보를 가져온다.
추가로
-
Code 가지고 있는 객체는 RequestEntity
-
AccessToken가지고 있는 객체는 ResponseEntity
OAuth2UserService
AccessToken을 사용해 리소스 소유자의 정보를 가져오며 OAuth2User타입의 객체를 리턴한다.
- 구현체는 DefaultOAuth2UserService와 OidcUserService가 있다.
DefaultOAuth2UserService
표준 OAuth2.0 Provider를 지원하는 구현체
- OAuth2User 반환
- OAuth2.0 Provider에 연결된 사용자 주체
- 사용자 인증정보를 가지고 있다 (name, email, phone number, address)
- 기본 구현체는 DefaultOAuth2User이며 Authentication principal에 저장
OidcUserService
OpenID Connect 1.0 Provider를 지원하는 구현체
- OidcUserRequest의 ID Token을 통해 인증처리, 필요 시 DefaultOAuth2UserService 사용
- OidcUser타입 반환
- 최종 사용자의 인증 정보인 Claims을 포함
- 기본 구현체는 DefaultOidcUser 이며 인증 이후 Authentication principal에 저장
- ID토큰은 개인키로 서명이 되어있기 때문에 정해진 알고리즘을 통해 검증하고 Oidc 객체를 생성해야 한다.
여기까진 Login 과정으로 SecurityConfig에
@Bean
SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests((requests) -> requests.anyRequest().authenticated());
http.oauth2Login(Customizer.withDefaults());
return http.build();
}
으로 실행된다
OpenID Logout
- 클라이언트는 로그아웃 엔드포인트를 사용하여 웹 브라우저에 대한 세션과 쿠키를 지운다.
- 클라이언트 로그아웃 성공 후 OidcClientInitiatedLogoutSuccessHandler 를 호출하여 OpenID Provider 세션 로그아웃 요청
- OpenID Provider 로그아웃이 성공하면 지정된 위치로 리다이렉트 한다
- 로그아웃 엔드 포인트는 end_session_endpoint 로 정의되어 있다
@Configuration(proxyBeanMethods = false)
public class OAuth2ClientConfig {
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
@Bean
SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests((requests) -> requests.anyRequest().authenticated());
http.oauth2Login(Customizer.withDefaults());
http.logout()
.logoutSuccessHandler(oidcLogoutSuccessHandler())
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID");
return http.build();
}
private OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() {
OidcClientInitiatedLogoutSuccessHandler successHandler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
successHandler.setPostLogoutRedirectUri("http://localhost:8081/login");
return successHandler;
}
}
MVC에서 인증 객체 참조 방법
// 1.Authentication 객체로 전달 받기
@GetMapping("/user")
public OAuth2User user(Authentication authentication){
OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
// 2. ContextHolder에서 꺼내오기
OAuth2AuthenticationToken oAuth2AuthenticationToken = (OAuth2AuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
OAuth2User oAuth2User2 = oAuth2AuthenticationToken.getPrincipal();
System.out.println("oAuth2User = " + oAuth2User);
System.out.println("oAuth2User2 = " + oAuth2User2);
return oAuth2User;
}
// 3. AuthenticationPrincipal 어노테이션 사용
@GetMapping("/oauth2User")
public OAuth2User oauth2User(@AuthenticationPrincipal OAuth2User oAuth2User){
System.out.println("oAuth2User = " + oAuth2User);
return oAuth2User;
}
@GetMapping("/oidcUser")
public OidcUser oidcUser(@AuthenticationPrincipal OidcUser oidcUser){
System.out.println("oidcUser = " + oidcUser);
return oidcUser;
}
OAuth2 Custom
oauth2LoginConfig 설정 클래스를 살펴보면 4개의 하위 config설정 클래스가 있다.
이 설정 클래스들의 값을 변경하면 requestMatcher가 요청을 처리할 url경로를 변경할 수 있다.
AuthorizationBaseUrl & RedirectionBaseUrl 변경
Authorization Code Grant Type 로그인 과정에서
code를 받아올 때 사용하는 base url과 code를 받아온 후 App으로 리다이렉트 해주는 redirection base url을 변경 할 수 있다.
- config 설정 파일에 oauth2LoginConfig 설정 변경, yml파일에 등록한 client redirect-uri 변경
// application.yml
redirect-uri: http://localhost:8081/login/v1/oauth2/code/keycloak
// Config
http
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
// .loginProcessingUrl("/login/v1/oauth2/code/*")
.authorizationEndpoint(authorizationEndpointConfig ->
authorizationEndpointConfig.baseUri("/oauth2/v1/authorization"))
.redirectionEndpoint(redirectionEndpointConfig ->
redirectionEndpointConfig.baseUri("/login/v1/oauth2/code/*"))
);
- 인가 서버의 valid post logout redirect url 설정 추가
PCKE 사용
pcke방식 grant type을 설정하려면 Code Challenge를 암호화 해서 사용해야 하는데 OAuth2AuthorizationRequestResolver를 상속받은 구현체를 만들어 resolve함수를 custom 해야한다.
resolve 로직을 간단히 요약하면 request 요청에 registrationId를 keycloakWithPKCE(yml 파일에서 해당 이름으로 매칭 시켜 놓음) 값이 있을 경우에 대한 분기처리를 한다.
// config
@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())));
http.logout().logoutSuccessUrl("/home");
return http.build();
}
private OAuth2AuthorizationRequestResolver customOAuth2AuthenticationRequestResolver() {
return new CustomOAuth2AuthorizationRequestResolver(clientRegistrationRepository, "/oauth2/authorization");
}
// CustomOAuth2AuthorizationRequestResolver
public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
...
public CustomOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository, String authorizationRequestBaseUri) {
this.clientRegistrationRepository = clientRegistrationRepository;
this.authorizationRequestMatcher = new AntPathRequestMatcher(
authorizationRequestBaseUri + "/{" + REGISTRATION_ID_URI_VARIABLE_NAME + "}");
defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
String registrationId = resolveRegistrationId(request);
if (registrationId == null) {
return null;
}
ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId);
if(registrationId.equals("keycloakWithPKCE")){
OAuth2AuthorizationRequest oAuth2AuthorizationRequest = defaultResolver.resolve(request);
return customResolve(oAuth2AuthorizationRequest, clientRegistration);
}
return defaultResolver.resolve(request);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(clientRegistrationId);
if(clientRegistrationId.equals("keycloakWithPKCE")){
OAuth2AuthorizationRequest oAuth2AuthorizationRequest = defaultResolver.resolve(request);
return customResolve(oAuth2AuthorizationRequest, clientRegistration);
}
return defaultResolver.resolve(request,clientRegistrationId);
}
private OAuth2AuthorizationRequest customResolve(OAuth2AuthorizationRequest authorizationRequest, ClientRegistration clientRegistration) {
Map<String,Object> extraParam = new HashMap<>();
extraParam.put("customName1","customValue1");
extraParam.put("customName2","customValue2");
extraParam.put("customName3","customValue3");
OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest
.from(authorizationRequest)
.additionalParameters(extraParam)
;
DEFAULT_PKCE_APPLIER.accept(builder);
return builder.build();
}
...
}
OAuth 2.0 Client - oauth2Client()
- OAuth2AuthorizedClient 는 인가받은 클라이언트를 의미하는 클래스
- OAuth2AuthorizedClient 는 AccessToken 과 RefreshToken 을 ClientRegistration (클라이언트)와 권한을 부여한 최종 사용자인 Principal과 함께 묶는다.
- OAuth2AuthorizedClient 의 AccessToken 을 사용해서 리소스 서버의 자원에 접근, 인가서버와의 통신으로 토큰을 검증
OAuth2.0 Client 주요 클래스
AuthorizationCodeGrantConfigurer
해당 클래스는 위에서 코드를 받아오기 위한 3개의 클래스를 생성하고, 추가로 accessToken을 받아오기 위한 OAuth2AuthorizationCodeGrantFilter도 생성
OAuth2AuthorizedClientRepository
다른 웹 요청이 와도 동일한 OAuth2AuthorizedClient 를 유지하는 역할
OAuth2AuthorizedClientService
OAuth2AuthorizedClientService 는 어플리케이션 레벨에서 OAuth2AuthorizedClient 를 관리(저장, 조회, 삭제 )
OAuth2AuthorizedClientManager
OAuth2AuthorizedClient 를 전반적으로 관리하는 인터페이스
- Client Credentials Flow, Resource Owner Password Flow, Refresh Token Flow 3개의 권한 부여 방식 사용
- OAuth2AuthorizedClientService 나 OAuth2AuthorizedClientRepository 에 OAuth2AuthorizedClient 저장을 위임한 후 OAuth2AuthorizedClient 최종 반환
- HttpServletRequest는 DefaultOAuth2AuthorizedClientManager 클래스가 처리
- 데몬, 백그라운드 실행 시 AuthorizedClientServiceOAuth2AuthorizedClientManager 클래스가 처리
OAuth2 client - Resource Owner Password Flow
인증 처리
Resource Owner Password 인증을 살펴보면
-
인증하는데 필요한 요청 정보를 OAuth2AuthorizeRequest에 담아 ClientManager로 보낸다.
-
OAuth2AuthorizedClient 객체가 있는지 확인
-
OAuth2AuthorizedClient가 없을 경우 OAuth2AutorizationContext객체를 생성해 PasswordOAuth2AuthorizedClientProvider 전달하고 클라이언트 인가 처리
-
AccessToken과 RefreshToken 로직 분기 처리 후 필요 시 인가 서버로 DefaultPasswordTokenResponseClient클래스를 사용해 처리
인가처리
인증 처리가 끝나면 위와 같이 AccessToken을 사용해 사용자 정보를 가져올 수 있다.
구현
- 인증 처리를 하기 위해선 username과 password를 처리하는 기능이 필요해 contextAttributesMapper를 구현해 OAuth2AutorizedClientManager에 등록시켜야 한다.
// OAuth2ClientConfig
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests(authRequest -> authRequest
.antMatchers("/","/oauth2Login","/client").permitAll()
.anyRequest().authenticated());
http
// .oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults());
return http.build();
}
// AppConfig
@Configuration
public class AppConfig {
@Bean
public DefaultOAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.clientCredentials()
.password(passwordGrantBuilder -> {
passwordGrantBuilder.clockSkew(Duration.ofSeconds(3600));
})
.refreshToken(refreshTokenGrantBuilder -> refreshTokenGrantBuilder.clockSkew(Duration.ofSeconds(3600)))
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
authorizedClientManager.setContextAttributesMapper(contextAttributesMapper());
return authorizedClientManager;
}
private Function<OAuth2AuthorizeRequest, Map<String, Object>> contextAttributesMapper() {
return authorizeRequest -> {
Map<String, Object> contextAttributes = Collections.emptyMap();
HttpServletRequest servletRequest = authorizeRequest.getAttribute(HttpServletRequest.class.getName());
String username = servletRequest.getParameter(OAuth2ParameterNames.USERNAME);
String password = servletRequest.getParameter(OAuth2ParameterNames.PASSWORD);
if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
contextAttributes = new HashMap<>();
// `PasswordOAuth2AuthorizedClientProvider` requires both attributes
contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, username);
contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password);
}
return contextAttributes;
};
}
}
코드를 간략히 설명하면
-
DefaultOAuth2AutorizedClientManager 객체 생성
-
setAuthorizedClientProvider 함수로 Provider 등록
-
parameter 처리 부분이 있는 contextAttributesMapper 함수를 등록한다.
DefaultOAuth2AuthorizedClientManager 객체를 통해 동작 확인 가능
Config 설정은 끝이고
실제 로그인 기능을 구현하기 위해서 Controller 구현해야 한다.
@Controller
public class LoginController {
@Autowired
DefaultOAuth2AuthorizedClientManager authorizedClientManager;
@Autowired
OAuth2AuthorizedClientRepository authorizedClientRepository;
private Duration clockSkew = Duration.ofSeconds(3600);
private Clock clock = Clock.systemUTC();
@GetMapping("/oauth2Login")
public String oauth2Login(Model model, HttpServletResponse servletResponse, HttpServletRequest servletRequest) throws IOException {
Authentication principal = SecurityContextHolder.getContext().getAuthentication();
if (principal == null) {
principal = new AnonymousAuthenticationToken("anonymous","anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
}
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(principal)
.attribute(HttpServletRequest.class.getName(), servletRequest)
.attribute(HttpServletResponse.class.getName(), servletResponse)
.build();
OAuth2AuthorizationSuccessHandler authorizationSuccessHandler = (authorizedClient, authentication, attributes) ->
authorizedClientRepository
.saveAuthorizedClient(authorizedClient, authentication,
(HttpServletRequest) attributes.get(HttpServletRequest.class.getName()),
(HttpServletResponse) attributes.get(HttpServletResponse.class.getName()));
authorizedClientManager.setAuthorizationSuccessHandler(authorizationSuccessHandler);
OAuth2AuthorizedClient oAuth2AuthorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (oAuth2AuthorizedClient != null && hasTokenExpired(oAuth2AuthorizedClient.getAccessToken())
&& oAuth2AuthorizedClient.getRefreshToken() != null) {
ClientRegistration.withClientRegistration(oAuth2AuthorizedClient.getClientRegistration()).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN);
oAuth2AuthorizedClient = authorizedClientManager.authorize(authorizeRequest);
}
if(oAuth2AuthorizedClient != null) {
ClientRegistration clientRegistration = oAuth2AuthorizedClient.getClientRegistration();
OAuth2AccessToken accessToken = oAuth2AuthorizedClient.getAccessToken();
OAuth2RefreshToken refreshToken = oAuth2AuthorizedClient.getRefreshToken();
OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
// 사용자 정보 가져오는 함수
OAuth2User oauth2User = oAuth2UserService.loadUser(new OAuth2UserRequest(
oAuth2AuthorizedClient.getClientRegistration(), accessToken));
// 권한 매핑
SimpleAuthorityMapper simpleAuthorityMapper = new SimpleAuthorityMapper();
simpleAuthorityMapper.setPrefix(""); // SYSTEM_ 이런식으로 prefix 줄 수 있음
Collection<? extends GrantedAuthority> authorities = simpleAuthorityMapper.mapAuthorities(oauth2User.getAuthorities());
OAuth2AuthenticationToken oAuth2AuthenticationToken = new OAuth2AuthenticationToken(oauth2User, authorities, clientRegistration.getRegistrationId());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(oAuth2AuthenticationToken);
SecurityContextHolder.setContext(context);
authorizationSuccessHandler.onAuthorizationSuccess(oAuth2AuthorizedClient, oAuth2AuthenticationToken, createAttributes(servletRequest, servletResponse));
model.addAttribute("oAuth2AuthenticationToken",oAuth2AuthenticationToken);
}
return "home";
}
private static Map<String, Object> createAttributes(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
Map<String, Object> attributes = new HashMap<>();
attributes.put(HttpServletRequest.class.getName(), servletRequest);
attributes.put(HttpServletResponse.class.getName(), servletResponse);
return attributes;
}
@GetMapping("/logout")
public String logout(HttpServletRequest servletRequest, HttpServletResponse servletResponse, Authentication authentication){
SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
logoutHandler.logout(servletRequest, servletResponse, authentication);
return "index";
}
private boolean hasTokenExpired(OAuth2Token token) {
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
}
}
Client Credentials, Refresh Token
Credentials는 Resource Owner Password Flow 로직과 대부분 유사한데
권한 부여에 따른 처리 하는 클래스들이 차이가 난다.
해당 부분의 Config를 수정하면서 구현
Refresh Token은 위에 적용이 되어있는데 App Config와 controller에 처리하는 부분을 추가하면 된다.
Custom Login Filter
이전까지 과정은 Client Manager를 이용해 Spring MVC에서 Login Controller에서 인가처리를 했는데
Spring Security에서 지원하는 Filter 기반으로 적용이 가능
인가 처리 로직은 이전과 동일하고 Config 설정에서 Filter 등록을 한다.
@Configuration(proxyBeanMethods = false)
public class OAuth2ClientConfig {
@Autowired
private DefaultOAuth2AuthorizedClientManager authorizedClientManager;
@Autowired
private OAuth2AuthorizedClientRepository authorizedClientRepository;
@Bean
SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests((requests) -> requests.antMatchers("/","/oauth2Login","/logout").permitAll().anyRequest().authenticated());
http
// .oauth2Login().and()
.oauth2Client()
.and()
.addFilterBefore(customOAuth2LoginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
;
return http.build();
}
public CustomOAuth2LoginAuthenticationFilter customOAuth2LoginAuthenticationFilter() throws Exception {
CustomOAuth2LoginAuthenticationFilter customOAuth2LoginAuthenticationFilter =
new CustomOAuth2LoginAuthenticationFilter(authorizedClientManager,authorizedClientRepository);
customOAuth2LoginAuthenticationFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.sendRedirect("/home");
});
return customOAuth2LoginAuthenticationFilter;
}
customOAuth2LoginAuthenticationFilter 설정을 살펴보면
public class CustomOAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String DEFAULT_FILTER_PROCESSES_URI = "/oauth2Login/**";
private OAuth2AuthorizedClientRepository authorizedClientRepository;
private DefaultOAuth2AuthorizedClientManager oAuth2AuthorizedClientManager;
private OAuth2AuthorizationSuccessHandler authorizationSuccessHandler;
private OAuth2AuthorizationFailureHandler authorizationFailureHandler;
private Duration clockSkew = Duration.ofSeconds(3600);
private Clock clock = Clock.systemUTC();
public CustomOAuth2LoginAuthenticationFilter(DefaultOAuth2AuthorizedClientManager oAuth2AuthorizedClientManager, OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) {
super(DEFAULT_FILTER_PROCESSES_URI);
this.oAuth2AuthorizedClientManager = oAuth2AuthorizedClientManager;
this.authorizedClientRepository = oAuth2AuthorizedClientRepository;
this.authorizationSuccessHandler = (authorizedClient, authentication, attributes) ->
authorizedClientRepository
.saveAuthorizedClient(authorizedClient, authentication,
(HttpServletRequest) attributes.get(HttpServletRequest.class.getName()),
(HttpServletResponse) attributes.get(HttpServletResponse.class.getName()));
this.oAuth2AuthorizedClientManager.setAuthorizationSuccessHandler(authorizationSuccessHandler);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
Authentication principal = SecurityContextHolder.getContext().getAuthentication();
if (principal == null) {
principal = new AnonymousAuthenticationToken("anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
}
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(principal)
.attribute(HttpServletRequest.class.getName(), request)
.attribute(HttpServletResponse.class.getName(), response)
.build();
OAuth2AuthorizedClient oAuth2AuthorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);
if (oAuth2AuthorizedClient != null && hasTokenExpired(oAuth2AuthorizedClient.getAccessToken())
&& oAuth2AuthorizedClient.getRefreshToken() != null) {
ClientRegistration.withClientRegistration(oAuth2AuthorizedClient.getClientRegistration()).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN);
oAuth2AuthorizedClient = oAuth2AuthorizedClientManager.authorize(authorizeRequest);
}
if (oAuth2AuthorizedClient != null) {
ClientRegistration clientRegistration = oAuth2AuthorizedClient.getClientRegistration();
OAuth2AccessToken accessToken = oAuth2AuthorizedClient.getAccessToken();
OAuth2RefreshToken refreshToken = oAuth2AuthorizedClient.getRefreshToken();
OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oauth2User = oAuth2UserService.loadUser(new OAuth2UserRequest(
oAuth2AuthorizedClient.getClientRegistration(), accessToken));
SimpleAuthorityMapper simpleAuthorityMapper = new SimpleAuthorityMapper();
Collection<? extends GrantedAuthority> authorities = simpleAuthorityMapper.mapAuthorities(oauth2User.getAuthorities());
OAuth2AuthenticationToken oAuth2AuthenticationToken = new OAuth2AuthenticationToken(oauth2User, authorities, clientRegistration.getRegistrationId());
// 인증 받은 사용자를 저장해줘야 한다.
authorizationSuccessHandler.onAuthorizationSuccess(oAuth2AuthorizedClient, oAuth2AuthenticationToken, createAttributes(request, response));
return oAuth2AuthenticationToken;
}
return null;
}
private static Map<String, Object> createAttributes(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
Map<String, Object> attributes = new HashMap<>();
attributes.put(HttpServletRequest.class.getName(), servletRequest);
attributes.put(HttpServletResponse.class.getName(), servletResponse);
return attributes;
}
private boolean hasTokenExpired(OAuth2Token token) {
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
}
}
코드 설명을 간략히 하면
- CustomOAuth2LoginAuthenticationFilter 생성자 내부에서 default url설정을 해 어떤 경로로 들어오면 filter처리를 할 지 등록시켜 준다.
(AbstractAuthenticationProcessingFilter 생성자에서 url 등록) - config설정을 보면 UsernamePasswordAuthenticationFilter 앞에 Filter가 동작하도록 설정해서 MVC Controller에서 처리하는 부분과 다르게 SecurityContextHolder안에 Token이 존재하지 않아 principal이 null일 경우 AnonymousAuthenticationToken을 만들어주는 로직이 필요
- 나머지는 위에 MVC 처리 부분과 동일하다