Keycloak×Spring SecurityでOIDC入門

# はじめに
こんにちは。GxPの[富岡](https://qiita.com/wtomioka-gxp)です。
本記事は[グロースエクスパートナーズアドベントカレンダー2022](https://qiita.com/advent-calendar/2022/gxp)の9日目の記事です。
今年のお仕事でKeycloak×Spring securityでの認証認可を構築する機会があり、そこで学んだことを記事にしました。

# 認証認可
認証とはユーザーIDやパスワードなどのユーザーから提示された情報と、あらかじめシステムに登録された情報を照合してユーザー本人であるかを確認することです。
認可とはアクセス権を与える行為のことで、認可が行われた後はアクセス権に基づきアクセス可否を判断する「認可判断」が行われます。
身近な例では、あるアプリへSNS連携でサインインする際に、SNSへログインして（認証）、アプリがSNSアカウントへアクセスすることを許可する（認可）というのがイメージしやすいかと思います。

# OIDCとは
OIDCとは __OpenID Connect__ の略で、認可が目的だったOAuth2.0を拡張して認証も行えるようにした標準プロトコルです。([公式ドキュメント](https://www.openid.or.jp/document/))
この仕様では、認証認可に使用するトークンの生成や受け渡しなどのフローを定められており、これに沿って認証認可を実現します。
ユーザーがアプリにログインすると、そのアプリは認証クライアントとして認証サーバーに認証を要求します。認証サーバーはユーザー認証を行い、IDトークンを発行してクライアントに返します。このIDトークンは、アクセスするための認証情報のことです。認証クライアントとなるアプリをRP（Relying Party）、認証を行うサービスをOP（OpenID Provider）といいます。

# Keycloakについて
[Keycloak](https://www.keycloak.org)はオープンソースのソフトウェア（Identity Provider：idp）で、OIDCなどの標準プロトコルを実装しています。これによってシングルサインオンやAPIアクセスの認証・認可制御を実現できます。

ここからはKeycloakで認証サーバーを立てて、Spring Securityで保護されたアプリケーションの認証処理を実装していきます。

# Keycloakの構築

dockerで簡単に動かすことができます。
```
docker run -p 18080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=pass quay.io/keycloak/keycloak:20.0.1 start-dev
```

:::note info
__環境変数__
KEYCLOAK_ADMIN : Keycloakの管理ユーザー名
KEYCLOAK_ADMIN_PASSWORD : Keycloakの管理ユーザーパスワード

__コマンド__
start-dev : Keycloakを開発モードで起動します。本番環境では本番動作用に最適化されるstart（下）を利用する必要があります。
start : Keycloakを本番モードで起動します。
:::


[http://localhost:18080/admin/](http://localhost:18080/admin/)　で管理コンソールにアクセスします。
管理ユーザー（admin / pass）でログインします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/5ae34963-d52c-2bfd-14cc-0a8ae5bbc99f.png)

管理コンソールで、クライアントとなるアプリ（＝認証したいアプリ）やユーザーの設定を行います。

:::note info
__レルム（Realm）__
レルムは同一の認証情報やセキュリティ設定などが適用される範囲、同じ認証情報でシングルサインオンできる範囲です。クライアントとなるアプリやユーザーはレルム毎に設定することができます。
デフォルトで「master」という全てのレルムを包含して管理することができる特殊なレルムがあります。
「master」レルムはレルム内で最も高いレベルで、管理者(初回起動時に作成した管理者アカウント、ここでは環境変数で指定したアカウント)で管理できます。
:::


まずはレルムを作成します。
管理コンソール左上のレルム名のプルダウン中の「Create Realm」ボタンから作成します。
任意のレルム名を設定します。（例：demo）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/d4bf52e1-efe1-7a3a-f580-6a86d0b3c5ff.png)


次に、クライアントとなるアプリを設定します。
左メニューの「client」でクライアントの一覧を開き、「Create client」ボタンから設定します。
クライアントIDに任意の名前を設定（例：demo-app）、OIDCをするので、「Client type」が「OpenID Connect」になっていることを確認します。また、クライアントの身元証明を行うためにclient authenticationにチェックを入れます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/bbcadac9-303d-5e3f-4485-c534b7d68cf7.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/5f78da8c-41c9-d559-f5e8-a38769a05732.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/7b9e5489-61fc-585d-0fe4-752541963774.png)

クライアントの一覧から作成したクライアント（demo-app）を開き、Keycloakで認証した後にリダイレクト可能なURIを設定します。（例：http://localhost:8080/）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/0861aabc-83f4-d55e-041d-ab472042933f.png)


ユーザーを作成します。
左メニューの「user」でユーザーの一覧を開き、「Create new user」ボタンから作成します。
任意の「ユーザー名」を入力（例：demoUser）して作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/a7922cfd-f69f-3f93-1a89-81f19340771c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/4bce8613-452c-4733-50f3-71ad57ec2878.png)
パスワードを設定するため、ユーザー一覧から作成したユーザーを開き、「Credentials」タブから設定します。
ここでTemporaryにチェックを入れると初回ログイン時にパスワード変更を要求されるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/a4bdc412-57ee-da4c-728b-6e09a8fd6753.png)

# Spring Security組み込み
Spring SecurityでOIDCの設定をします。
本記事ではSpring Security 5.7.3を使用しています。（5.4辺りで設定が大幅に変わっています。）
application.ymlにKeycloakのエンドポイントを指定します。
client-secretはKeycloakのclientの詳細の「Credentials」から確認できます。

各エンドポイントのベースには以下のように、レルム名が入ります。
http://localhost:18080/realms/{レルム名}/protocol/openid-connect/～～

```yaml:application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: demo-app
            client-secret: vE1T55t77pAA7oNu8fkaDo5AG5Y5kdmH
            provider: keycloak
            scope: openid
            authorization-grant-type: authorization_code
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
        provider:
          keycloak:
            authorization-uri: http://localhost:18080/realms/demo/protocol/openid-connect/auth
            token-uri: http://localhost:18080/realms/demo/protocol/openid-connect/token
            user-info-uri: http://localhost:18080/realms/demo/protocol/openid-connect/userinfo
            jwk-set-uri: http://localhost:18080/realms/demo/protocol/openid-connect/certs
            user-name-attribute: preferred_username

```

後はSecurityFilterChainを設定します。

```java:SecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final WebOidcUserService userService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.
            authorizeHttpRequests(authorize -> authorize
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .anyRequest().authenticated()
            ).oauth2Login().userInfoEndpoint().oidcUserService(userService);
        return http.build();
    }

}
```

ユーザー情報をカスタマイズするには、OidcUserServiceとDefaultOidcUserを継承することで実現できます

```java:WebOidcUserService.java

@RequiredArgsConstructor
@Service
public class WebOidcUserService extends OidcUserService {

    private final MyUserRepository myUserRepository;

    // ROLEプレフィックス
    private final String ROLE_PREFIX = "ROLE_";

    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        var oidcUser = super.loadUser(userRequest);
        var username = oidcUser.getPreferredUsername();
        
        // DBからユーザー情報・ロールを取得する
        MyUser myUser = myUserRepository.findById(username);
        // ロールの付与
        List<GrantedAuthority> authList = myUser.getRoleList()
            .stream()
            .map(role -> new OAuth2UserAuthority(ROLE_PREFIX + role.getUserRoleId().getRoleName(), oidcUser.getAttributes()))
            .toList();

        return new MyOidcUser(authList, userRequest.getIdToken(), oidcUser.getUserInfo(), myUser);
    }
}

```

```java:MyOidcUser.java
@Getter
public class MyOidcUser extends DefaultOidcUser {

    // アプリ固有のプロパティ
    private String orgParam1;
    private String orgParam2;
    private String orgParam3;

    public MyOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, OidcUserInfo userInfo, MyUser myUser) {
        super(authorities, idToken, userInfo);
        this.orgParam1 = myUser.getOrgParam1();
        this.orgParam2 = myUser.getOrgParam2();
        this.orgParam3 = myUser.getOrgParam3();
    }
}

```

ユーザー情報はSecurityContextHolderから取得できます。
```java
    public static MyOidcUser getLoginUser() {
        var principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (principal instanceof MyOidcUser) {
            return (MyOidcUser) principal;
        }
        throw new RuntimeException("認証情報の取得に失敗しました。");
    }
```

サンプルとして、ログインしたらログインユーザー名を表示するページを実装。

```java:MyController.java
@RequiredArgsConstructor
@Controller
public class MyController {

    private final WebContextProvider webContextProvider;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("loginUserName", webContextProvider.getLoginUser().getUserName());
        return "home";
    }
}
```
```html:home.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <title>home</title>
  </head>
  <body>
    <h1>Hello! [[${loginUserName}]]</h1>
  </body>
</html>

```

アプリケーションを起動し、作成したページにアクセスします。(http://localhost:8080)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/f688b08d-b9b2-a32f-3de6-9e6245acf4ef.png)

すると、アプリケーションは認証されていなければKeycloakのログイン画面へリダイレクトさせます。
URLを見ると、先ほど起動したKeycloakへと飛ばされていることが分かります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/2baf20db-0d37-5f67-0ee0-d426e1d3a105.png)

先ほど設定したユーザー（demouser）でログインします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/e82f3af3-2ff8-5c8e-1cb2-4d9f2010873e.png)

ログインが完了すると、アプリケーションへと戻り、ページが表示されます。
URLを見ると、アプリケーションへと戻っていることが分かります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2161631/5adfa0f7-631d-5775-e060-e1f20bcd1881.png)

<br/>
このようにKeycloakとSpring Securityで認証処理が実装できます。

# その他
Keycloakはバージョン17.0.0から、WildFlyベースからQuarkusベースに変更となり、起動方法や設定方法がガラッと変わっているため、情報収集する際は注意が必要です。
Keycloakの詳細な設定については触れませんでしたが、設定ポイントは多いのでドキュメントやガイドを読んで下さい。
設定するべきものだと、Keycloakがレルムやユーザーのデータを格納するためのDBをデフォルトの内部的に作成するDBから任意のDBに変更する辺りかと思います。


# 締め
認証認可について全く知らない状態から入ったのですが、ここまで構築できるようになりました。
同じように、認証認可についての知識がない方の学びになれば幸いです。


#### 参考図書
[認証と認可 Keycloak入門 OAuth/OpenID Connectに準拠したAPI認可とシングルサインオンの実現](
https://www.amazon.co.jp/%E8%AA%8D%E8%A8%BC%E3%81%A8%E8%AA%8D%E5%8F%AF-Keycloak%E5%85%A5%E9%96%80-OAuth-OpenID-Connect%E3%81%AB%E6%BA%96%E6%8B%A0%E3%81%97%E3%81%9FAPI%E8%AA%8D%E5%8F%AF%E3%81%A8%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%82%B5%E3%82%A4%E3%83%B3%E3%82%AA%E3%83%B3%E3%81%AE%E5%AE%9F%E7%8F%BE/dp/4865943226)
[実践 Keycloak ―OpenID Connect、OAuth 2.0を利用したモダンアプリケーションのセキュリティー保護](
https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5-Keycloak-%E2%80%95OpenID-Connect%E3%80%81OAuth-2-0%E3%82%92%E5%88%A9%E7%94%A8%E3%81%97%E3%81%9F%E3%83%A2%E3%83%80%E3%83%B3%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E3%83%BC%E4%BF%9D%E8%AD%B7/dp/4814400098/ref=d_pd_vtp_sccl_2_3/355-9925223-8590836?pd_rd_w=Lhdne&content-id=amzn1.sym.cbb45385-7b99-44b7-a528-bff5ddaa153d&pf_rd_p=cbb45385-7b99-44b7-a528-bff5ddaa153d&pf_rd_r=25Z3AX1W8D0GGVDA8VZC&pd_rd_wg=jT3cr&pd_rd_r=390ec932-03cc-4347-83d0-a185567ec6e1&pd_rd_i=4814400098&psc=1)