LoginSignup
9
18

More than 3 years have passed since last update.

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

Last updated at Posted at 2018-06-27

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

この記事でやること

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

参考文献

Using OAuth 2.0 for Web Server Applications
OpenID Connect

OAuth

準備

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

Developers Console

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

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

依存関係

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はサポートされてますよね 完全にボケてた

ログイン画面

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

<!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>
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などに使います。今回は単純に固定値です。
response_type レスポンスを受け取る方法です。サーバーサイドではcode固定です。
scope このリクエストで、どんな情報を取得できるのかを指定します。ここではemailおよびprofileを設定しています。使用可能な値についてはドキュメントを参照。

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

トークンの取得

@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 );

    // ...

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

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つのパートに分かれていますので、それぞれを取得します。

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から取得します。

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を取得します。

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を探します。

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にしています。

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

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

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

checkState( Objects.equals( body.get( "aud" ), clientId ) );

オーディエンス、audがクライアントIDと一致することを確認します。

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を取得し、モデルに設定しています。

model.addAttribute( "email", id.get( "email" ) )
     .addAttribute( "name", id.get( "name" ) )
     .addAttribute( "picture", id.get( "picture" ) );
<!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

2.PNG

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

3.PNG

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

9
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
18