クライアントライブラリーを使わずOAuthを実装 (Java)

もう何番煎じなのかわからないくらいですが、OAuthの使い方記事です。
JavaにはGoogle自身のものを含め星の数ほどOAuthクライアントライブラリーが存在しますが、ここでは勉強のためクライアントライブラリーを使わずにOAuthを触ってみます。
なお、OAuth自体の仕組みについては良い記事がたくさんあると思いますので、ここでは解説を行いません。


# この記事でやること

* GoogleのOAuth APIを使用して、ユーザーの情報を取得、表示する
* Javaを使ったWebアプリケーションを想定
* OAuth用のライブラリーは使わない(ただしHTTPリクエストなどはライブラリーを使う)
* [ソース](https://bitbucket.org/minebreaker_tf/teachyourselfoauth)


# 参考文献

[Using OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/OAuth2WebServer)
[OpenID Connect](https://developers.google.com/identity/protocols/OpenIDConnect)


# OAuth


## 準備

Googleに、OAuthの認証情報を登録する必要があります。

[Developers Console](https://console.developers.google.com/apis/credentials)

認証情報を作成 -> OAuthクライアントID -> ウェブアプリケーション

名前は適当に。承認済みのリダイレクトURIに、`http://localhost:8080/auth`を追加。ここで追加した値が、リクエストのリダイレクト先として使用可能な値になります。
今回は開発に使うだけなので、localhostを指定しておきます。プロダクションでは使うべきではありません。


## 依存関係

```gradle
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.0.3.RELEASE")
    }
}

apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

dependencies {
    compile(
            'org.springframework.boot:spring-boot-devtools',
            'org.springframework.boot:spring-boot-starter-web',
            'org.springframework.boot:spring-boot-starter-thymeleaf',

            'com.google.guava:guava:25.1-jre',
            'com.google.http-client:google-http-client:1.23.0',
            'com.google.http-client:google-http-client-gson:1.23.0',
            'com.google.code.gson:gson:2.8.5',
    )
}
```

別になんでもいいのですが今回はSpringを使います。~~GuavaはBase64のために、~~HttpClientはHTTPリクエスト簡素化のために使用します。GsonをJSONのパースに使用します。

追記: [Java 8からBase64はサポートされてます](https://docs.oracle.com/javase/jp/8/docs/api/java/util/Base64.html)よね 完全にボケてた


## ログイン画面

ログイン画面を作ります。

```html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
</head>
<body>

<a th:href="${endpoint}">login</a>

</body>
</html>
```

```java
private static final String clientId = checkNotNull( System.getenv( "CLIENT_ID" ) );

@GetMapping( "/login" )
public String login( Model model ) {

    var endpoint = "https://accounts.google.com/o/oauth2/v2/auth?"
                   + "client_id=" + clientId + "&"
                   + "redirect_uri="
                   + UrlEscapers.urlFormParameterEscaper().escape( "http://localhost:8080/auth" )
                   + "&"
                   + "access_type=" + "online" + "&"
                   + "state=" + "test_state" + "&"
                   + "response_type=" + "code" + "&"
                   + "scope="
                   + UrlEscapers.urlFormParameterEscaper().escape( "email profile" );
    model.addAttribute( "endpoint", endpoint );
    return "login";
}
```

`https://accounts.google.com/o/oauth2/v2/auth`がGoogle OAuth APIのエンドポイントです。URLクエリーパラメーターに、必要な情報を設定していきます。

| パラメーター ||
|:-:|:--|
| `client_id` | クライアントを識別するIDです。先ほどデベロッパーコンソールで取得した値を使います。ここでは環境変数から取得します。 |
| `redirect_uri` | リダイレクト先です。デベロッパーコンソールに設定した値を使います。 |
| `access_type` | 今回はブラウザーを使うので、`online`を指定します。 |
| `state` | `state`自体は自由に値を設定できます。大抵は[nonce](https://ja.wikipedia.org/wiki/%E3%83%8E%E3%83%B3%E3%82%B9)などに使います。今回は単純に固定値です。 |
| `response_type` | レスポンスを受け取る方法です。サーバーサイドでは`code`固定です。 |
| `scope` | このリクエストで、どんな情報を取得できるのかを指定します。ここでは`email`および`profile`を設定しています。使用可能な値については[ドキュメント](https://developers.google.com/identity/protocols/googlescopes)を参照。 |


ユーザーがここで生成されたリンクを踏むと、Googleのログイン画面に移動します。初回はアプリケーションがユーザー情報へアクセスするのを認めるか、確認ダイアログが表示されます。
ユーザーがログイン処理を完了すると、`redirect_url`の先へリダイレクトされます。URLのクエリーパラメーターに含まれる`code`を使用して、アクセストークンをリクエストできます。


## トークンの取得

```java
@GetMapping( "/auth" )
public String auth( HttpServletRequest request, Model model ) throws IOException {

    var params = parseQueryParams( request );
    checkState( Objects.equals( params.get( "state" ), "test_state" ) );
    var code = URLDecoder.decode( checkNotNull( params.get( "code" ) ), StandardCharsets.UTF_8 );

    // ...
```

クエリパラメーターから、`code`と`state`を取得します。`state`は、先のリクエストで使用した値がそのまま返されていることを検証します。URLデコードしなければならない場合があるので注意。

```java
var response = httpPost(
        "https://www.googleapis.com/oauth2/v4/token",
        Map.of(
                "code", code,
                "client_id", clientId,
                "client_secret", clientSecret,
                "redirect_uri", "http://localhost:8080/auth",
                "grant_type", "authorization_code"
        )
).parseAsString();
var responseAsMap = parseJson( response );
```

トークン取得用のURLに、POSTリクエストを送信します。さっきはv2だったのにこっちはなぜかv4。公式のドキュメント通りなので特に気にしない。

| パラメーター ||
|:-:|:--|
| `code` | 上記で取得した`code`を、そのまま送信。 |
| `client_id` | クライアントID。 |
| `client_secret` | クライアントシークレット。ID同様、デベロッパーコンソールで指定されたものを設定。 |
| `redirect_uri` | こちらはリダイレクト先ではなく、検証のためのもの。コンソールで設定し、先のリクエストでも使用したリダイレクト先を再び設定する。 |
| `grant_type` | 固定値`authorization_code`。 |

リクエストが正常なものだと認識されると、JWT形式のトークンが返されます。


### IDトークンのパース

JSON Web Tokenは`.`で区切られたヘッダー、ボディー、サインの3つのパートに分かれていますので、それぞれを取得します。

```java
var idTokenList = Splitter.on( "." ).splitToList( idToken );

var header = parseJson( base64ToStr( idTokenList.get( 0 ) ) );
var body = parseJson( base64ToStr( idTokenList.get( 1 ) ) );
var sign = idTokenList.get( 2 );
```

ヘッダー部およびボディー部はBase64エンコードされているJSONです。結果を検証をしないのであれば、これだけで値が取得できるのですが、せっかくですのでトークンの検証も行いましょう。


#### キーの取得

検証に使用するGoogleの公開鍵を取得します。鍵の場所は、`https://accounts.google.com/.well-known/openid-configuration`から取得します。

```java
var response = httpGet( "https://accounts.google.com/.well-known/openid-configuration" ).parseAsString();
var responseAsMap = parseNestedJson( response );
var jwks = responseAsMap.get( "jwks_uri" ).toString();
```

このURIに対してGETリクエストを送信し、JSONを取得します。

```java
var keyResponse = httpGet( jwks ).parseAsString();
var keysResponseObj = parseJsonAs( keyResponse, Keys.class );
return keysResponseObj.keys.stream()
                           .filter( k -> k.kid.equals( kid ) )
                           .findAny()
                           .orElseThrow();
```

`keys`の配列に、複数の暗号鍵が格納されています。IDトークンのヘッダー部の`kid`と一致する`kid`を探します。

```java
var signature = Signature.getInstance( "SHA256withRSA" );
signature.initVerify( KeyFactory.getInstance( "RSA" ).generatePublic( new RSAPublicKeySpec(
        new BigInteger( 1, base64ToByte( n ) ),
        new BigInteger( 1, base64ToByte( e ) )
) ) );
signature.update( contentToVerify.getBytes( StandardCharsets.UTF_8 ) );
return signature.verify( base64ToByte( sign ) );
```

鍵JSONを、Java形式に変換して、正しく著名されていることを確認します。本当は鍵形式もJSONから取得すべきですが、ここでは固定値で`RSA`にしています。

署名を確認したら、次はパラメーターを検証していきます。

```java
checkState( Set.of( "https://accounts.google.com", "accounts.google.com" ).contains( body.get( "iss" ) ) );
```

イシュアー、`iss`が正当であることを確認します。Googleの場合は、`https://accounts.google.com`, `accounts.google.com`のいずれかです。

```java
checkState( Objects.equals( body.get( "aud" ), clientId ) );
```
オーディエンス、`aud`がクライアントIDと一致することを確認します。

```java
var now = System.currentTimeMillis();
checkState( now <= Long.parseLong( body.get( "exp" ) ) * 1000 + 1000 );
checkState( now >= Long.parseLong( body.get( "iat" ) ) * 1000 - 1000 );
```

最後に鍵が有効期限内にあり、署名時刻が正当であることを確認します。値は秒単位です。1000を足したり引いたりしているのは、サーバーとの時間のぶれを吸収するためです。


### パラメーターを取得

IDトークンが正当なものと検証できたので、値を取得します。ここではメールアドレス、名前、顔画像のURLを取得し、モデルに設定しています。

```java
model.addAttribute( "email", id.get( "email" ) )
     .addAttribute( "name", id.get( "name" ) )
     .addAttribute( "picture", id.get( "picture" ) );
```

```html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Result</title>
</head>
<body>

<img th:src="${picture}">
<p th:text="'Email: ' + ${email}"></p>
<p th:text="'Name:  ' + ${name}"></p>

<a th:href="${back}">back</a>

</body>
</html>
```

Googleの様々なAPIを使用するためには、JWTに含まれていた`access_token`および`refresh_token`を使い、各リクエストを送信します。


## 動作確認

`localhost:8080/login`にアクセスします。

![1.PNG](https://qiita-image-store.s3.amazonaws.com/0/231669/057b5ddb-b559-ea87-924f-89ca4b0f88c8.png)

![2.PNG](https://qiita-image-store.s3.amazonaws.com/0/231669/17ef1d89-b3b0-65ef-695e-66db9bb5e26e.png)

Googleに移動し、認証を行います。

![3.PNG](https://qiita-image-store.s3.amazonaws.com/0/231669/353e2fe1-4cbd-b694-85ac-5a7517c3db6f.png)

顔画像、メールアドレス、名前が取得できました。