Spring Security 5で OAuth2 Login
背景
業務で Spring Security を使っているのだが、まだ古いバージョンを利用していてこれを Migration する気配がない。
さすがに最後発で新しく作るサービスはそちらに合わせるわけには行かないので、 Java バージョン(これは11だけど)、Spring バージョンを最新にすると同時に Spring Security のバージョンも 5.X 系に変更させたい。それのために色々調べている。
そしてバージョンアップの一番のモチベーションがこちら。
今まで利用していた OAuth2RestTemplate
が Deprecated にマークされており、今後使ってくれるなとのこと。メンテナンスもされないのでこれは厳しい。
そしてこの Migration ガイドがまだ WIP な模様。 途中の Examples Matrix を見ると、それぞれのGrant Flowごとにサンプルがあるように見えるのだが、リンクはすべて同じという(何のためにわけてあるんだ・・?)
OAuth 2.0 Migration Guide · spring-projects/spring-security Wiki · GitHub
最小限で使ってみる
公式 Reference を読むべきなのだがなかなか膨大。Migration ガイドがうーんという感じで困っていたところ kazuki43zoo さんの Qiita 記事が一番まとまっていてとても助かった。動かしながら確認できそうなのでこちらをガイドに使わせていただく。
こちらの Qiita 記事と公式 Reference を参照しながらすすめていく。
環境
IdProvider
現在所属しているクラスメソッドでは、OpenId Connect 1.0 準拠の IdProvider アプリケーションを提供している。自分も少しだけ開発に噛んでいたためこちらを利用することにした。
build.gradle
最近、Spring Initializr を使わない謎ブームが来てるのでぬくもりある手作りでやっていき。
plugins { id 'org.springframework.boot' version '2.2.5.RELEASE' id 'io.spring.dependency-management' version '1.0.9.RELEASE' id 'java' } group = 'com.github.com4dc.sample' version = '0.0.1-SNAPSHOT' sourceCompatibility = '11' configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } 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' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' } testImplementation 'org.springframework.security:spring-security-test' } test { useJUnitPlatform() }
Gradleプロジェクトの初期記述から最低限必要なのだけ追加した。
DemoController
Qiita の記事のコードをほぼ丸パクリ。Java11だから var
をちょっと使ってみたかった程度しか手を入れていない。
@Controller @RequiredArgsConstructor public class DemoController { private RestOperations restOperations = new RestTemplate(); // 認可済みのクライアント情報は OAuth2AuthorizedClientService経由で取得できる private final OAuth2AuthorizedClientService authorizedClientService; @GetMapping("/") public String index(OAuth2AuthenticationToken authentication, Model model) { model.addAttribute("authorizedClient", getAuthorizedClient(authentication)); return "index"; } @GetMapping("/attributes") public String userAttributeAtLogin(@AuthenticationPrincipal OAuth2User oAuth2User, Model model) { // 認証時点のユーザー情報。あくまでスナップショット。時間が経てば更新されている可能性 model.addAttribute("attributes", oAuth2User.getAttributes()); return "userinfo"; } @GetMapping("/attributes/latest") public String userLatestAttribute(OAuth2AuthenticationToken authenticationToken, Model model) { // 最新のユーザー情報を取って表示する // 認可済みClientを返す var client = this.getAuthorizedClient(authenticationToken); // Client情報からUserinfo Endpointを探す var userInfoUri = client.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri(); // AccessTokenをAuthorizationヘッダにBearerで指定してUserInfoエンドポイントから最新の情報を引っ張ってくる var requestEntity = RequestEntity.get(URI.create(userInfoUri)) .header(HttpHeaders.AUTHORIZATION, "Bearer " + client.getAccessToken().getTokenValue()) .build(); model.addAttribute("attributes", restOperations.exchange(requestEntity, Map.class).getBody()); return "userinfo"; } private OAuth2AuthorizedClient getAuthorizedClient(OAuth2AuthenticationToken authentication) { // 認証済みのClient情報を取得 return this.authorizedClientService.loadAuthorizedClient( authentication.getAuthorizedClientRegistrationId(), authentication.getName() ); } }
Application Class
SpringBoot Applicationのエントリポイント
@SpringBootApplication public class SampleOAuth2Application { public static void main(String... args) { SpringApplication.run(SampleOAuth2Application.class, args); } }
application.yaml
src/main/resources
配下に application.yaml
を作成。
spring: security: oauth2: client: registration: barista: client-id: CLIENT_SAMPLE client-secret: 'CLIENT_SECRET' redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}' authorization-grant-type: authorization_code scope: - openid client-name: Barista provider: barista: authorization-uri: https://example.com/oauth/authorize token-uri: https://example.com/oauth/token jwk-set-uri: https://example.com/jwk user-info-uri: https://example.com/userinfo user-name-attribute: sub logging: level: org: springframework: security: debug
Google, Facebook などの一般的なIdProviderはCommonとして定義されており、エンドポイント情報等が予め記述されているため、provider情報を自分で設定する必要がない。
spring-security/CommonOAuth2Provider.java at master · spring-projects/spring-security · GitHub
仕様に準拠していれば独自の IdProvider でも定義さえすれば問題なく動作するようだ。ログレベルに関しては何がどう動いてるかを確認するためにつけてる。
HTML Template
index.html
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4"> <head> <title>Demo Application OAuth 2.0 Login with Spring Security 5</title> <meta charset="utf-8"/> </head> <body> <div style="float: right" th:fragment="logout" sec:authorize="isAuthenticated()"> <div style="float:left"> <span style="font-weight:bold">User Id:</span> <span sec:authentication="name">1000000</span> <span style="font-weight:bold">User Name:</span> <span sec:authentication="principal.attributes['name']">Taro Yamada</span> </div> <div style="float:none"> </div> <div style="float:right"> <form action="#" th:action="@{/logout}" method="post"> <input type="submit" value="Logout"/> </form> </div> </div> <h1>Welcome Demo Application OAuth 2.0 Login with Spring Security 5 !!</h1> <div> You are successfully logged in via the OAuth 2.0 Client -<span style="font-weight:bold" th:text="${authorizedClient.clientRegistration.clientName}">Demo Client</span>- </div> <div> </div> <div> <ul> <li> <a href="/templates/userinfo.html" th:href="@{/attributes}">Display User Attributes at login</a> </li> <li> <a href="/templates/userinfo.html" th:href="@{/attributes/latest}">Display latest User Attributes</a> </li> </ul> </div> </body> </html>
userinfo.html
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <head> <title>Demo Application OAuth 2.0 Login with Spring Security 5 - User Attributes -</title> <meta charset="utf-8"/> </head> <body> <div th:replace="index::logout"></div> <!-- 認証ユーザの名前、ログアウトボタン --> <h1>Demo Application OAuth 2.0 Login with Spring Security 5 - User Attributes -</h1> <div> <span style="font-weight:bold">User Attributes:</span> <!-- ユーザ属性の一覧 --> <ul> <li th:each="attribute : ${attributes}"> <span style="font-weight:bold" th:text="${attribute.key}"></span>: <span th:text="${attribute.value}"></span> </li> </ul> </div> </body> </html>
ひとまずこれで起動する。本当に何も書いてないのだが、これでつながるのだろうか・・・。 今までのいろいろな面倒な設定を思い出すと何も書いてないに等しい。
.... 2020-03-23 23:12:34.515 INFO 50010 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Creating filter chain: any request, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@794366a5, org.springframework.security.web.context.SecurityContextPersistenceFilter@6e3ecf5c, org.springframework.security.web.header.HeaderWriterFilter@73809e7, org.springframework.security.web.csrf.CsrfFilter@67f77f6e, org.springframework.security.web.authentication.logout.LogoutFilter@49d30c6f, org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter@5d1e0fbb, org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter@127705e4, org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter@3fb9a67f, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@35c9a231, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@480b57e2, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@341b13a8, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@462abec3, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@40f35e52, org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter@3f36e8d1, org.springframework.security.web.session.SessionManagementFilter@48df4071, org.springframework.security.web.access.ExceptionTranslationFilter@30c4e352, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@6bccd036] 2020-03-23 23:12:34.585 INFO 50010 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' 2020-03-23 23:12:34.590 INFO 50010 --- [ main] j.c.k.sample.SampleOAuth2Application : Started SampleOAuth2Application in 2.085 seconds (JVM running for 8.001)
なんかめちゃくちゃFilterが定義されてるのが見える。 ブラウザで http://localhost:8080/
にアクセスしてみると
認証されていないため、AuthorizeエンドポイントへRedirectされ、最終的に IdProvider のログイン画面が GET しているのがわかる。ログインして正常に認証されると以下のページが表示される。
ひとまず単純な正常動作はOK。動作が色々気になるので Qiitaの記事をガイドに読んでいく。
エラー
scopeに openid がない
clientのscopeに openid
がない場合以下のエラー画面が表示される。
client の scope に定義されていても承認画面で scope を拒否した場合も同様にエラー画面が表示される。