もう何番煎じなのかわからないくらいですが、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の認証情報を登録する必要があります。
認証情報を作成 -> 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 );
// ...
クエリパラメーターから、code
とstate
を取得します。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
にアクセスします。
Googleに移動し、認証を行います。
顔画像、メールアドレス、名前が取得できました。