Spring Security и OpenID Connect

Обърнете внимание, че тази статия е актуализирана до новия стек Spring OAuth 2.0. Урокът, използващ наследения стек, все още е налице.

1. Общ преглед

В този бърз урок ще се съсредоточим върху настройването на OpenID Connect (OIDC) с Spring Security.

Ще представим различни аспекти на тази спецификация и след това ще видим подкрепата, която Spring Security предлага, за да я внедри в клиент на OAuth 2.0.

2. Въведение за бързо OpenID Connect

OpenID Connect е слой за идентичност, изграден върху протокола OAuth 2.0.

По този начин е много важно да знаете OAuth 2.0, преди да се потопите в OIDC, особено потока на Кода за разрешаване.

Спецификацията на OIDC е обширна; тя включва основни функции и няколко други опционални възможности, представени в различни групи. Основните са:

  • Ядро: удостоверяване и използване на претенции за комуникация на информация за крайния потребител
  • Откриване: определя как клиентът може динамично да определя информация за доставчиците на OpenID
  • Динамична регистрация: диктува как клиентът може да се регистрира при доставчик
  • Управление на сесии: определя как да се управляват OIDC сесиите

Освен това документите разграничават OAuth 2.0 удостоверителните сървъри, които предлагат поддръжка за тази спецификация, като ги наричат ​​„доставчици на OpenID“ (OP) и клиенти на OAuth 2.0, които използват OIDC като разчитащи страни (RP). Ще се придържаме към тази терминология в тази статия.

Струва си да се знае също така, че клиент може да поиска използването на това разширение, като добави обхвата на openid в своята заявка за разрешение.

И накрая, още един аспект, който е полезен за разбиране за този урок, е фактът, че операционните системи излъчват информация за крайния потребител като JWT, наречена „ID Token“.

Сега да, готови сме да се потопим по-дълбоко в света на OIDC.

3. Настройка на проекта

Преди да се съсредоточим върху действителното развитие, ще трябва да регистрираме клиент на OAuth 2.o при нашия доставчик на OpenID.

В този случай ще използваме Google като доставчик на OpenID. Можем да следваме тези инструкции, за да регистрираме нашето клиентско приложение на тяхната платформа. Забележете, че обхватът на openid присъства по подразбиране.

URI за пренасочване, който създадохме в този процес, е крайна точка в нашата услуга: // localhost: 8081 / login / oauth2 / code / google.

Трябва да получим Client Id и Client Secret от този процес.

3.1. Maven конфигурация

Ще започнем с добавяне на тези зависимости към нашия проект pom файл:

 org.springframework.boot spring-boot-starter-oauth2-client 2.2.6.RELEASE 

Стартовият артефакт обединява всички зависимости, свързани с Spring Security Client, включително:

  • на пролетно-сигурността OAuth2 клиент зависимостта за OAuth 2.0 Вход за клиенти и функционалност
  • библиотеката JOSE за поддръжка на JWT

Както обикновено, можем да намерим най-новата версия на този артефакт с помощта на търсачката Maven Central.

4. Основна конфигурация с използване на Spring Boot

Първо, ще започнем с конфигуриране на нашето приложение да използва клиентската регистрация, която току-що създадохме с Google.

Използването на Spring Boot прави това много лесно, тъй като всичко, което трябва да направим, е да дефинираме две свойства на приложението:

spring: security: oauth2: client: registration: google: client-id:  client-secret: 

Нека стартираме нашето приложение и се опитаме да влезем в крайна точка сега. Ще видим, че ще бъдем пренасочени към страница за вход в Google за нашия клиент OAuth 2.0.

Изглежда наистина просто, но тук има доста неща, които се случват под капака. След това ще проучим как Spring Security постига това.

По-рано, в нашия пост за поддръжка на WebClient и OAuth 2, ние анализирахме вътрешните данни за това как Spring Security се справя с OAuth 2.0 оторизационни сървъри и клиенти.

Там видяхме, че трябва да предоставим допълнителни данни, освен Client Id и Client Secret, за да конфигурираме успешно екземпляр ClientRegistration . И така, как работи това?

Отговорът е, че Google е добре познат доставчик и затова рамката предлага някои предварително дефинирани свойства, за да улесни нещата.

Можем да разгледаме тези конфигурации в изброяването CommonOAuth2Provider .

За Google изброеният тип определя свойства като:

  • обхватите по подразбиране, които ще се използват
  • крайната точка на разрешението
  • крайната точка на Token
  • крайната точка UserInfo, която също е част от спецификацията на OIDC Core

4.1. Достъп до потребителска информация

Spring Security предлага полезно представяне на главен потребител, регистриран в доставчик на OIDC, обектът OidcUser .

Освен основните OAuth2AuthenticatedPrincipal методи, този обект предлага някои полезни функционалности:

  • извличане на стойността на ID токена и претенциите, които съдържа
  • да получите исканията, предоставени от крайната точка UserInfo
  • генерира съвкупност от двата набора

Можем лесно да осъществим достъп до този обект в контролер:

@GetMapping("/oidc-principal") public OidcUser getOidcUserPrincipal( @AuthenticationPrincipal OidcUser principal) { return principal; }

Или като използвате SecurityContextHolder в боб:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication.getPrincipal() instanceof OidcUser) { OidcUser principal = ((OidcUser) authentication.getPrincipal()); // ... }

Ако проверим принципала, тук ще видим много полезна информация, като име на потребител, имейл, снимка на профила и локал.

Освен това е важно да се отбележи, че Spring добавя правомощия към принципала въз основа на обхвата, който е получил от доставчика, с префикс „ SCOPE_ “. Например обхватът на openid става SCOPE_openid предоставен орган.

Тези органи могат да се използват за ограничаване на достъпа до определени ресурси, например :

@EnableWebSecurity public class MappedAuthorities extends WebSecurityConfigurerAdapter { protected void configure(HttpSecurity http) { http .authorizeRequests(authorizeRequests -> authorizeRequests .mvcMatchers("/my-endpoint") .hasAuthority("SCOPE_openid") .anyRequest().authenticated() ); } }

5. OIDC в ​​действие

Досега научихме как можем лесно да внедрим решение за влизане в OIDC, използвайки Spring Security

Видяхме предимствата, които носи, като делегира процеса на идентификация на потребителя на доставчик на OpenID, който от своя страна предоставя подробна полезна информация, дори по мащабируем начин.

Но истината е, че досега не ни се налагаше да се занимаваме с някакъв аспект, специфичен за OIDC. Това означава, че Пролетта върши по-голямата част от работата вместо нас.

Следователно ще видим какво се случва зад кулисите, за да разберем по-добре как тази спецификация се прилага и да можем да извлечем максимума от нея.

5.1. Процесът на влизане

За да видим това ясно, нека активираме регистрационните файлове RestTemplate , за да видим заявките, които услугата изпълнява:

logging: level: org.springframework.web.client.RestTemplate: DEBUG

If we call a secured endpoint now, we'll see the service is carrying out the regular OAuth 2.0 Authorization Code Flow. That's because, as we said, this specification is built on top of OAuth 2.0. There are, anyway, some differences.

Firstly, depending on the provider we're using and the scopes we've configured, we might see that the service is making a call to the UserInfo endpoint we mentioned at the beginning.

Namely, if the Authorization Response retrieves at least one of profile, email, address or phone scope, the framework will call the UserInfo endpoint to obtain additional information.

Even though everything would indicate that Google should retrieve the profile and the email scope – since we're using them in the Authorization Request – the OP retrieves their custom counterparts instead, //www.googleapis.com/auth/userinfo.email and //www.googleapis.com/auth/userinfo.profile, thus Spring doesn't call the endpoint.

This means that all the information we're obtaining is part of the ID Token.

We can adapt to this behavior by creating and providing our own OidcUserService instance:

@Configuration public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { Set googleScopes = new HashSet(); googleScopes.add( "//www.googleapis.com/auth/userinfo.email"); googleScopes.add( "//www.googleapis.com/auth/userinfo.profile"); OidcUserService googleUserService = new OidcUserService(); googleUserService.setAccessibleScopes(googleScopes); http .authorizeRequests(authorizeRequests -> authorizeRequests .anyRequest().authenticated()) .oauth2Login(oauthLogin -> oauthLogin .userInfoEndpoint() .oidcUserService(googleUserService)); } }

The second difference we'll observe is a call to the JWK Set URI. As we explained in our JWS and JWK post, this is used to verify the JWT-formatted ID Token signature.

Next, we'll analyze the ID Token in detail.

5.2. The ID Token

Naturally, the OIDC spec covers and adapts to a lot of different scenarios. In this case, we're using the Authorization Code flow, and the protocol indicates that both the Access Token and the ID Token will be retrieved as part of the Token Endpoint response.

As we said before, the OidcUser entity contains the Claims contained in the ID Token, and the actual JWT-formatted token, which can be inspected using jwt.io.

On top of this, Spring offers many handy getters to obtain the standard Claims defined by the specification in a clean manner.

We can see the ID Token includes some mandatory Claims:

  • the issuer identifier formatted as a URL (e.g. “//accounts.google.com“)
  • a subject id, which is a reference of the End-User contained by the issuer
  • the expiration time for the token
  • time at which the token was issued
  • the audience, which will contain the OAuth 2.0 Client id we've configured

And also many OIDC Standard Claims like the ones we mentioned before (name, locale, picture, email).

As these are standard, we can expect many providers to retrieve at least some of these fields, and therefore facilitating the development of simpler solutions.

5.3. Claims and Scopes

As we can imagine, the Claims that are retrieved by the OP correspond with the scopes we (or Spring Security) configured.

OIDC defines some scopes that can be used to request the Claims defined by OIDC:

  • profile, which can be used to request default profile Claims (e.g. name, preferred_username,picture, etcetera)
  • email, to access to the email and email_verified Claims
  • address
  • phone, to requests the phone_number and phone_number_verified Claims

Even though Spring doesn't support it yet, the spec allows requesting single Claims by specifying them in the Authorization Request.

6. Spring Support for OIDC Discovery

As we explained in the introduction, OIDC includes many different features apart from its core purpose.

The capabilities we're going to analyze in this section and the following are optional in OIDC. Hence, it's important to understand that there might be OPs that don't support them.

The specification defines a Discovery mechanism for an RP to discover the OP and obtain information needed to interact with it.

In a nutshell, OPs provide a JSON document of standard metadata. The information must be served by a well-known endpoint of the issuer location, /.well-known/openid-configuration.

Spring benefits from this by allowing us to configure a ClientRegistration with just one simple property, the issuer location.

But let's jump right into an example to see this clearly.

We'll define a custom ClientRegistration instance:

spring: security: oauth2: client: registration: custom-google: client-id:  client-secret:  provider: custom-google: issuer-uri: //accounts.google.com

Now we can restart our application and check the logs to confirm the application is calling the openid-configuration endpoint in the startup process.

We can even browse this endpoint to have a look at the information provided by Google:

//accounts.google.com/.well-known/openid-configuration

We can see, for example, the Authorization, the Token and the UserInfo endpoints that the service has to use, and the supported scopes.

An especially relevant note here is the fact that if the Discovery endpoint is not available at the time the service launches, then our app won't be able to complete the startup process successfully.

7. OpenID Connect Session Management

This specification complements the Core functionality by defining:

  • different ways to monitor the End-User's login status at the OP on an ongoing basis so that the RP can log out an End-User who has logged out of the OpenID Provider
  • the possibility of registering RP logout URIs with the OP as part of the Client registration, so as to be notified when the End-User logs out of the OP
  • a mechanism to notify the OP that the End-User has logged out of the site and might want to log out of the OP as well

Naturally, not all OPs support all of these items, and some of these solutions can be implemented only in a front-end implementation via the User-Agent.

In this tutorial, we'll focus on the capabilities offered by Spring for the last item of the list, RP-initiated Logout.

At this point, if we log in to our application, we can normally access every endpoint.

If we logout (calling the /logout endpoint) and we make a request to a secured resource afterward, we'll see that we can get the response without having to log in again.

However, this is actually not true; if we inspect the Network tab in the browser debug console, we'll see that when we hit the secured endpoint the second time we get redirected to the OP Authorization Endpoint, and since we're still logged in there, the flow is completed transparently, ending up in the secured endpoint almost instantly.

Of course, this might not be the desired behavior in some cases. Let's see how we can implement this OIDC mechanism to deal with this.

7.1. The OpenID Provider Configuration

In this case, we'll be configuring and using an Okta instance as our OpenID Provider. We won't go into details on how to create the instance, but we can follow the steps of this guide, and keeping in mind that Spring Security's default callback endpoint will be /login/oauth2/code/okta.

In our application, we can define the client registration data with properties:

spring: security: oauth2: client: registration: okta: client-id:  client-secret:  provider: okta: issuer-uri: //dev-123.okta.com

OIDC indicates that the OP logout endpoint can be specified in the Discovery document, as the end_session_endpoint element.

7.2. The LogoutSuccessHandler Configuration

Next, we'll have to configure the HttpSecurity logout logic by providing a customized LogoutSuccessHandler instance:

@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests(authorizeRequests -> authorizeRequests .mvcMatchers("/home").permitAll() .anyRequest().authenticated()) .oauth2Login(oauthLogin -> oauthLogin.permitAll()) .logout(logout -> logout .logoutSuccessHandler(oidcLogoutSuccessHandler())); }

Now let's see how we can create a LogoutSuccessHandler for this purpose using a special class provided by Spring Security, the OidcClientInitiatedLogoutSuccessHandler:

@Autowired private ClientRegistrationRepository clientRegistrationRepository; private LogoutSuccessHandler oidcLogoutSuccessHandler() { OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler( this.clientRegistrationRepository); oidcLogoutSuccessHandler.setPostLogoutRedirectUri( URI.create("//localhost:8081/home")); return oidcLogoutSuccessHandler; }

Consequently, we'll need to set up this URI as a valid logout Redirect URI in the OP Client configuration panel.

Clearly, the OP logout configuration is contained in the client registration setup, since all we're using to configure the handler is the ClientRegistrationRepository bean present in the context.

So, what will happen now?

After we login to our application, we can send a request to the /logout endpoint provided by Spring Security.

Ако проверим мрежовите регистрационни файлове в конзолата за отстраняване на грешки в браузъра, ще видим, че сме пренасочени към крайна точка за излизане от OP, преди най-накрая да осъществим достъп до конфигурирания от нас URI за пренасочване.

Следващият път, когато влезем в крайна точка в нашето приложение, която изисква удостоверяване, задължително ще трябва да влезем отново в нашата OP платформа, за да получим разрешения.

8. Заключение

За да обобщим, в този урок научихме много за решенията, предлагани от OpenID Connect, и как можем да внедрим някои от тях с помощта на Spring Security.

Както винаги, всички пълни примери могат да бъдат намерени в нашето репо GitHub.