com4dc’s blog

Javaプログラマーのはずだけど運用してます

Spring Security 5で OAuth2 Login

背景

業務で Spring Security を使っているのだが、まだ古いバージョンを利用していてこれを Migration する気配がない。

さすがに最後発で新しく作るサービスはそちらに合わせるわけには行かないので、 Java バージョン(これは11だけど)、Spring バージョンを最新にすると同時に Spring Security のバージョンも 5.X 系に変更させたい。それのために色々調べている。

そしてバージョンアップの一番のモチベーションがこちら。

github.com

今まで利用していた OAuth2RestTemplate が Deprecated にマークされており、今後使ってくれるなとのこと。メンテナンスもされないのでこれは厳しい。

そしてこの Migration ガイドがまだ WIP な模様。 途中の Examples Matrix を見ると、それぞれのGrant Flowごとにサンプルがあるように見えるのだが、リンクはすべて同じという(何のためにわけてあるんだ・・?)

OAuth 2.0 Migration Guide · spring-projects/spring-security Wiki · GitHub

最小限で使ってみる

公式 Reference を読むべきなのだがなかなか膨大。Migration ガイドがうーんという感じで困っていたところ kazuki43zoo さんの Qiita 記事が一番まとまっていてとても助かった。動かしながら確認できそうなのでこちらをガイドに使わせていただく。

qiita.com

こちらの Qiita 記事と公式 Reference を参照しながらすすめていく。

環境

  • macOS Mojave 10.14.6
  • openjdk version "11.0.6" 2020-01-14 LTS
  • IntelliJ IDEA 2019.3.3 Ultimate

IdProvider

現在所属しているクラスメソッドでは、OpenId Connect 1.0 準拠の IdProvider アプリケーションを提供している。自分も少しだけ開発に噛んでいたためこちらを利用することにした。

classmethod.jp

build.gradle

最近、Spring Initializr を使わない謎ブームが来てるのでぬくもりある手作りでやっていき。

https://start.spring.io/

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">&nbsp;</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>&nbsp;</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/ にアクセスしてみると

f:id:com4dc:20200323231520p:plain

認証されていないため、AuthorizeエンドポイントへRedirectされ、最終的に IdProvider のログイン画面が GET しているのがわかる。ログインして正常に認証されると以下のページが表示される。

f:id:com4dc:20200323232115j:plain

ひとまず単純な正常動作はOK。動作が色々気になるので Qiitaの記事をガイドに読んでいく。

エラー

scopeに openid がない

clientのscopeに openid がない場合以下のエラー画面が表示される。

f:id:com4dc:20200323232552p:plain

client の scope に定義されていても承認画面で scope を拒否した場合も同様にエラー画面が表示される。