タグ別アーカイブ: OAuth2.0

JavaでOpenID ConnectのBasic Clientを実装してみた

| コメントをどうぞ

5月も中頃となり、気候は穏やかな毎日ですね。
あーあ、ずっと春ならいいのに。
どうもDiceK Mikamiです。

今回は、あのGoogleも対応を発表したOpenID Connectのサンプル実装をご紹介します。
これであなたもOpenID Connecterだ!

1. OpenID Connectとは

まずは、OpenID Connectについて軽くおさらいをしておきましょう。
OpenID Connectについて調べてみますと、、、

OpenID Connectは、ユーザーがパーソナルデータのコントロールを行うことを目的とした認証プロトコルです。
Webサービスごとにアイデンティティを使い分けて、利用者のプライバシー強化を実現する技術として標準化が進められてきました。

ごめん。なに言ってるかさっぱりわからねぇ。
複数のサービスでIDを同じにしておけば、ユーザーとしてはログインの手間が省けますし、ID情報をセキュアなOpenID Providerが管理しているためパスワードなどの秘匿情報の流出を防ぐことができます。
さらにユーザー属性がサービスによって異なるからそれを標準化して、様々なサービスで属性情報を共通化しようと。
でも、これはOpenID自体の利点であって、OpenID Connectになったから出てきたメリットではありません。

では、なにがいいの?
OpenID ConnectはOAuth2.0ベースで仕様が策定されています。
これによってWebアプリケーションだけでなく、さまざまなアプリケーションと連携できるようになりました。

既存アプリケーションでは、OAuth2.0本来の仕様を利用して認証を行っているものもありましたが、OAuth2.0は認可プロトコルのため、実際のところはあまり好ましい実装ではありませんでした。
なので、OpenIDという認証プロトコルとOAuth2.0という認可プロトコルを結合させて、本来あるべき認証と認可の仕組みを提供しようという考えなんですね。
また、それによってプロバイダから提供されるID Tokenなるユーザー属性によってID連携を行うことができます。

実際のところ、すでにこうした実装がされているアプリにとってはなんも嬉しくないんですけど。。。
何でもありません。

OpenID Connectは、OAuth2.0の拡張にて実装が可能となっています。
今回のサンプルでは、Basic Client Profile(OAuth 2.0 Authorization Code Grantの拡張)を実装しますので、そのフローを以下にてご紹介します。

openidconnect00

うん。よく見るOAuth2.0のフローですね。
さてさて、次項からOpenID Connectのサンプル実装の解説に入っていきます。

2. 検証環境

  • JDK: 1.7.0_51

3. 下準備

OpenID Connectの実装、、、の前に、クライアントIDとクライアントシークレットを取得しておきましょう。
今回はGoogleをOPとしますので、Google Developers Consoleからアプリを作成しておきます。

Google Developers Consoleにアクセスしたら、CREATE PROJECTをクリックします。

openidconnect01

左ペインよりAPIs & authをクリックします。

openidconnect02

APIs & authからCredentialsを選択し、右ペインのOAuthカテゴリ内にあるCREATE NEW CLIENT IDをクリックします。

openidconnect03

Create Client IDモーダルApplication typeWeb applicationを選択します。
Authorized JavaScript originsには、認可するサイトのオリジンを記述します。(後述するサンプルを利用する場合、http://localhost:4567を指定してください)
Authorized redirect URIには、コールバックURLを記述します。(後述するサンプルを利用する場合、http://localhost:4567/oauth2callbackを指定してください)
設定したらCreate Client IDボタンをクリックしましょう。

openidconnect04

最後に作成されたアプリのClient IDClient secretを覚えておきましょう。

openidconnect05

4. 実装

それではようやく実装に入っていきましょう。
今回は、Spark Framework(like a Sinatra framework for Ruby)とGoogle謹製のgoogle-api-java-clientを使います。
(サンプルコードはGitHubにアップロードしてあります。GitHub corestrike/OpenIDConnectSample
仕組みとしてはOAuth2.0と変わりませんので、その順序で実装していきます。
最初に、Authorization Codeを取得します。

App.java

// OpenID Connectのエントリーポイント
post(new Route("/oauth") {
   @Override
   public Object handle(Request req, Response res){
       // クライアントID
       String clientId = req.queryMap().get("client_id").value();
       // クライアントシークレット
       String clientSecret = req.queryMap().get("client_secret").value();

       if(clientId.isEmpty() || clientSecret.isEmpty()){
           res.status(400);
           return "Bad Request!";
       }else{
           setCLIENT_ID(clientId);
           setCLIENT_SECRET(clientSecret);
           String authUrl = getCodeUrl();
           res.redirect(authUrl);
       }

       return null;
   }
});

// Authorization Codeを取得するURLを生成
private static String getCodeUrl() {
    AuthorizationCodeRequestUrl codeUrl
        = new AuthorizationCodeRequestUrl(AUTHORIZATION_SERVER_URL, CLIENT_ID);
    // スコープにopenidを入れる必要がある
    codeUrl.setScopes(Arrays.asList("openid email profile"));
    codeUrl.setResponseTypes(Arrays.asList("code"));
    codeUrl.setRedirectUri(REDIRECT_URL);
    codeUrl.setState(STATE);

    /* RefreshTokenを返却する場合
    codeUrl.set("access_type", "offline");
    */

    return codeUrl.build();
}

Authorization Codeを取得する際のスコープにopenidを含めるのがポイントです。
これを含めていると最終的にユーザー属性を含んでいるクレームがAccessTokenと同時に返却されてくるようになります。
Authorization Codeを取得するURLは、Googleの場合、https://accounts.google.com/o/oauth2/authになります。

App.java

// コールバックメソッド
// 取得したトークンを分解してブラウザに返却する
get(new FreeMarkerRoute("/oauth2callback") {
    @Override
    public Object handle(Request req, Response res){
        // 本来はここでstateチェックをする

        // Tokenを取得
        String code = req.queryMap("code").value();
        TokenResponse tr = getTokenUrl(code);

        // ID TokenはJWTで返却されるのでそれを分解する
        String[] jwt = ((String)tr.get("id_token")).split(".");
        byte[] jwtHeader = Base64.decodeBase64(jwt[0]);
        byte[] jwtClaim = Base64.decodeBase64(jwt[1]);
        byte[] jwtSigniture = Base64.decodeBase64(jwt[2]);

        // 本来はここでSignを検証する

        Map<String, Object> attributes = new HashMap<>();
        attributes.put("accesstoken", tr.getAccessToken());
        attributes.put("refreshtoken",
                 tr.getRefreshToken() == null ? "null" : tr.getRefreshToken());
        attributes.put("tokentype", tr.getTokenType());
        attributes.put("expire", tr.getExpiresInSeconds());
        attributes.put("jwtheader", new String(jwtHeader));
        attributes.put("jwtclaim", new String(jwtClaim));
        attributes.put("jwtsign", new String(jwtSigniture));

        try{
            JsonParser jsonParser
                          = JSON_FACTORY.createJsonParser(new String(jwtClaim));
            while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
                String name = jsonParser.getCurrentName();
                if(name != null) {
                    jsonParser.nextToken();

                    switch (name){
                        case "iss":
                            attributes.put("iss", jsonParser.getText());
                            break;
                        case "sub":
                            attributes.put("sub", jsonParser.getText());
                            break;
                        case "azp":
                            attributes.put("azp", jsonParser.getText());
                            break;
                        case "email":
                            attributes.put("email", jsonParser.getText());
                            break;
                        case "at_hash":
                            attributes.put("at_hash", jsonParser.getText());
                            break;
                        case "email_verified":
                            attributes.put("email_verified", jsonParser.getText());
                            break;
                        case "aud":
                            attributes.put("aud", jsonParser.getText());
                            break;
                        case "iat":
                            attributes.put("iat", jsonParser.getText());
                            break;
                        case "exp":
                            attributes.put("exp", jsonParser.getText());
                            break;
                    }
                }
            }
        }catch (IOException e){
            e.printStackTrace();
        }

        return modelAndView(attributes, "callback.ftl");
    }
});

// Authorization Codeを利用して、Tokenを取得
private static TokenResponse getTokenUrl(String code) {
    AuthorizationCodeTokenRequest tokenUrl = new AuthorizationCodeTokenRequest(
            HTTP_TRANSPORT,
            JSON_FACTORY,
            new GenericUrl(TOKEN_SERVER_URL),
            code
    );
    tokenUrl.setGrantType("authorization_code");
    tokenUrl.setRedirectUri(REDIRECT_URL);
    tokenUrl.set("client_id", CLIENT_ID);
    tokenUrl.set("client_secret", CLIENT_SECRET);

    TokenResponse tr = null;
    try {
        tr = tokenUrl.execute();
    } catch (IOException e) {
        e.printStackTrace();
    }

    return tr;
}

Tokenの取得は、Authorization URLにPost送信します。
Token取得のためのURLは、https://accounts.google.com/o/oauth2/tokenになります。
サンプルでは実装していませんが、実運用するためにはStateのチェックや返却されたID Tokenの正当性検証などを実装する必要があります。
ID TokenはJWTにて返却されるため、JWT解析を行う必要があります。
そのあたりに関しては、本稿では割愛します。(参考文献にあるJWT関連のページは大変分かりやすいと思います)

これだけで実装は完了です。
簡単ですよね?
すでにOAuth2.0クライアントを実現しているアプリケーションなどでは、スコープを少し変更するだけでOKですよね。
(まぁ、JWT解析とか正当性検証とか必要ですけど。。。

OpenID Connectは様々なプロバイダで実装が進んでいます。
これからのID連携の一つとして実装パターンを覚えておいて損はないかと思います。
それでは、また次回。

参考文献

Using OAuth 2.0 for Login (OpenID Connect)
Google Developers Console
OpenID Connect Basic Client Implementer’s Guide 1.0 – draft 33
Spark – A small web framework for Java
google-api-java-client
hiyosi’s blog – JWTについて簡単にまとめてみた
GitHub corestrike/OpenIDConnectSample

OAuth2.0 Web Application Flowの実装

| コメントをどうぞ

はじめに

はじめまして。infoScoop OpenSource開発のお手伝いをさせていただいておりますDiceK Mikamiと申します。
開発者ブログでは、ウェブ/ローカル問わず簡単な技術Tipsなどに関して投稿させていただこうかと考えております。
とは言いましても、私自身まだまだ若輩ゆえ誤り等々があるかと思いますので、
その際には皆様からの生暖かいご指摘/アドバイスなどいただければ幸いです。
今後ともよろしくお願いいたします。

OAuth2.0認証

OAuth2.0は、OAuth1.0Aにて不便と思われていたいくつかの仕様(例えば、署名など)を改善し、
より簡単にサービス間の連携を行うための認証機構です。
OAuth2.0による認証はGoogleやFacebookをはじめ、いくつかのサービスですでに提供されるようになりましたが、
仕様自体はまだドラフトの段階であり、未だ策定作業が続いていると言うのが現状です。

The OAuth 2.0 Authorization Protocol draft-ietf-oauth-v2-23

ですので、現状でサーバーサイドにてOAuth2.0実装を行う場合、
策定されているドラフトを元にするか、あるいは他のサービスとの連携をふまえて実装する必要性があります。
今回は実際にinfoScoop OpenSource V3.0にて実装したコードをパクって経験を活かして、
サーバーサイドでOAuth2.0認証をしてみることにします。

OAuth2.0 Web Application Flow

Web Application Flowでは、下図のような流れで認証し、データを取得することになります。

図では上方向から下方向にかけて、状態が遷移していきます。
Aはアプリケーション側のスタートポイントクラスになります。
ここで認証サーバーに対して認可コードを要求します。
認可コードとはOAuth1.0Aにおけるリクエストトークンに類似したものですが、
基本的にワンタイムの使用となります。
認可コードを得るためには、ユーザーは認証サーバーに対してログインする必要があります。
また、この際にアクセスするAPIに対しての権限を許可するかどうかの応答も行う必要があります。
Bは認証サーバー側のAuthorizationクラスになります。

認可コードはアプリケーションのコールバッククラスに返却されます。(C)
コールバックURLは認証サーバー側に登録しておく必要があります。
Google、Facebookで確認したところ適当なURLではダメでした。
コールバッククラスにて、認可コードを使ってアクセストークンを要求します。
取得先は認証サーバーごとに提示されているAccessTokenクラスになります。(D)
アクセストークンを返却してくる際に、認証サーバーはアクセストークンの持続時間(expires)とリフレッシュトークンも同時に返却してきます。
OAuth2.0ではアクセストークンに持続時間が付与されており、
時間が過ぎてしまったトークンは無効化されてしまいます。
この際、無効化されたトークンを再発行するために利用するのがリフレッシュトークンとなります。
注釈:現状、Facebookではリフレッシュトークンを返却しません。サービスによって実装はまちまちです。

サンプルコード

サンプルでは、Facebookからフレンド一覧を取得するコードをもとに実際の挙動を解説します。
今回のサンプルでは、スタートポイントとなるOAuth2Sample.javaと
コールバッククラスとなるOAuth2Callback.javaの2つのサーブレットを使用します。
(サンプルは要点だけを抜粋して記述してあります。また、実際にOAuth2.0を試すためには各サービス毎にアプリケーションの登録を行う必要があります)

// OAuth2Sample.java
 public void doGet(HttpServletRequest req, HttpServletResponse res)
       throws IOException, ServletException {
  // 認可コード取得URLを返却------------------(a)
  res.setContentType("text/html; charset=UTF-8");
  PrintWriter out = res.getWriter();
  out.println(createHTML());
  out.close();
 }
 
 private String createHTML(){
  StringBuffer sb = new StringBuffer();
  sb.append("<html><head><title>OAuth2サンプル</title></head><body>");
  sb.append("<a href='"+AUTH_URL);
  sb.append("?client_id="+APP_ID);
  sb.append("&redirect_uri="+REDIRECT_URL);
  sb.append("&response_type=code");
  sb.append("&state=xxx");
  sb.append("&scope="+"user_about_me,offline_access");
  sb.append("'>認可コードを取得します。");
  sb.append("</a></body></html>");
  return (new String(sb));
 }

(a)より認可コードを取得するためのアドレスをクライアントに返却する処理を記述しています。
認可コード取得先にはGETリクエストする必要がありますので、そのままアドレス返却します。
認可コード取得先にリクエストするためのパラメータは以下になります。

GET https://www.facebook.com/dialog/oauth
client_id: <サービスから発行されるAPP ID>
redirect_uri: <サービスに登録したコールバックURL>
response_type: "code"
state: <コールバックURLに渡したい値を記述>
scope: "user_about_me,offline_access"

Facebookではリフレッシュトークンが実装されていません。
そのためアクセストークンを永続化させるためにscopeのパラメータとして「offline_access」を付加しています。
扱えるスコープの情報に関しては、各サービスのドキュメントを参考にしてください。

facebook Developer(英語)

リクエストを行うと、以下のような形で値が返却されます。

// OAuth2Callback.java
 public void doGet(HttpServletRequest req, HttpServletResponse res)
       throws IOException, ServletException {
  // 認可コード解析------------------(b)
  HashMap<String,String> authCodeMap = parseRequest(req);
  HttpClient client = new DefaultHttpClient();
  
  // アクセストークン取得------------------(c)
  HttpPost httpPost = new HttpPost(TOKEN_URL);
  List<NameValuePair> postParams = new ArrayList<NameValuePair>();
  postParams.add(new BasicNameValuePair("code",authCodeMap.get("code")));
  postParams.add(new BasicNameValuePair("client_id",APP_ID));
  postParams.add(new BasicNameValuePair("client_secret",APP_SECRET));
  postParams.add(new BasicNameValuePair("redirect_uri",REDIRECT_URL));
  postParams.add(new BasicNameValuePair("grant_type","authorization_code"));
  httpPost.setEntity(new UrlEncodedFormEntity(postParams, HTTP.UTF_8));
  HttpResponse authTokenResponse = client.execute(httpPost);
  
  // アクセストークン解析------------------(d)
  HttpEntity httpEntity = authTokenResponse.getEntity();
  HashMap<String,String> accessTokenMap = parseQuery(EntityUtils.toString(httpEntity));
  
  // 実データ取得------------------(e)
  HttpGet httpGet
       = new HttpGet(ACCESS_URL+"?access_token="+accessTokenMap.get("access_token"));)
  HttpResponse dataResponse = client.execute(httpGet);
  HttpEntity dataEntity = dataResponse.getEntity();
  
  res.setContentType("text/html; charset=UTF-8");
  PrintWriter out = res.getWriter();
  out.println(EntityUtils.toString(dataEntity));
  out.close();
 }

認可コードは登録したコールバックURLに返却されてきます。
(b)では返却された認可コードを解析し、ハッシュマップに登録しています。

認可コードが取得できたら、認可コードを利用してアクセストークンを取得します。
アクセストークン取得先には、POSTリクエストする必要があります。
(サーバーサイドからHTTPアクセスできるようにApache commons HTTP Clientを利用しています)
リクエストの際のパラメータは以下になります。

POST https://graph.facebook.com/oauth/access_token
code: <返却された認可コード>
client_id: <サービスから発行されるAPP ID>
client_secret: <サービスから発行されるAPP SECRET>
redirect_uri: <サービスに登録したコールバックURL>
grant_type: "authorization_code"

アクセストークンリクエスト時の返却値は以下のようになります。
今回は「offline_access」スコープを付与しているので、expiresは返却されていません。


最後に返却されてきたアクセストークンを利用して、データを取得しています。
アクセストークンをサービスに渡す方法は複数提案されておりますが、
今回はGETアクセスのパラメータとしてトークンを渡しています。

GET https://graph.facebook.com/me/friends
access_token: <返却されたアクセストークン>

結果として以下のデータが取得できました。
個人情報が含まれるためモザイクを入れておりますが、
data属性にフレンド情報が入っていることが確認できると思います。