python/flaskでgoogleにOpenIDでログインしてみた。ライブラリ無しで。

昨日公開したwebアプリ、ハッカソン期間中にはギリギリ間に合わなかったのですが、実は本当はOpenIDに対応させる予定だったのです。 私は結局認証部分には一度も手を付けずに終わってしまったので、すこし挑戦してみようかと。 実用目的ではないので、自前で全て実装してみることにしました。 情報が少なくて苦労しましたが、案外簡単だった。

とりあえず、フレームワークにflaskを使います。ルーティングで手間取りたくないので。あとはpythonの標準ライブラリだけで頑張ります。

OpenID Connectでの認証の大雑把な流れ

詳しいことは私もよく分かっていないので、本当に大雑把に。

OpenIDを利用するサイト(RPとか呼ばれるそうです)がOpenIDを提供しているサイト(こちらはOPというそうです)に自分の情報をクエリに入れてリダイレクトします。 クライアントからアクセスを受けたOPはログイン画面を出して、ログインした(もしくはキャンセルした)ら、RPのサイトに更にリダイレクトします。 リダクレクトされてきたURLにユーザに関する情報が入っていて、こいつをOPに問い合わせたりしてユーザの情報を取得する、ということらしいです。

やっぱりよく分からないので 第1回 OpenIDサービスを利用して,OpenIDの仕組みを理解する:いますぐ使えるOpenID|gihyo.jp … 技術評論社 あたりを読んでください。 とりあえず、リダイレクトの連続で事が運ぶのだと、そういうことですね、うん。

googleでクライアントIDを取得する

googleのデベロッパーコンソールに行って、適当にプロジェクトとかいうやつを作ります。なんか本当に適当で大丈夫そうです。 プロジェクトを作ったら、左の方にある「APIと認証」の「認証情報」ってところにある「認証情報を追加」から「OAuth 2.0 クライアント ID」を作ります。 多分サービス名を設定しろ的なことを言われるので、言われた通りにします。

「ウェブアプリケーション」を選んで、「承認済みのリダイレクト URI」とやらに認証に使うURLを入れます。localhostとかも大丈夫です。 この記事では、http://localhost:5000/login/checkに設定しておきます。

完了するとクライアントIDクライアントシークレットという二つの文字列が生成されるので、どこかに控えておいてください。(まあでも後からでも見れるのでどっかやっちゃっても平気です)

ログイン画面を出してみる

クライアントIDを取得できたら、実際にアプリを作り始めましょう。といっても最初にお話しした通り、最初にするのはただのリダイレクトです。

import urllib.parse

import flask


client_id = '-- デベロッパーコンソールで取得したクライアントID --'
client_secret = '-- こっちはクライアントシークレット --'
redirect_uri = 'http://localhost:5000/login/check'

state = 'this is test'  # 本当はこれはランダム

@app.route('/login')
def login():
    return flask.redirect('https://accounts.google.com/o/oauth2/auth?{}'.format(urllib.parse.urlencode({
        'client_id': client_id,
        'scope': 'email',
        'redirect_uri': redirect_uri,
        'state': state,
        'openid.realm': 'http://localhost:5000',
        'response_type': 'code'
    })))

こんな感じにしてみました。

  • https://accounts.google.com/o/oauth2/authに諸々のクエリを付けてリダイレクトしています。
  • client_id, redirect_uriなんかは設定/取得したものをそのまま渡します。
  • openid.realmにはオリジンを渡せば良いっぽい?
  • response_typeには常にcodeを設定します。
  • scopeには欲しいデータを指定すれば良いらしい。profileとかemailとか。スペース区切りで両方も行けます。
  • stateはあとで使いますが、ワンタイムパスのようなものらしいです。乱数を設定してください。でも面倒なのでここでは定数です。

このコードを動かして/loginにアクセスすると、見慣れたグーグルの画面に転送されると思います。

アカウントの情報を取得してみる

ログイン画面を出せてログインも(一応は)出来るようになったので、アカウントの情報を取得してみましょう。

ソースコードは増えた部分だけ書きます。結合したものがこの記事の下にあるので、動作確認にはそちらのほうが良いかも。

import urllib.request
import json
import base64

@app.route('/login/check')
def check():
    if flask.request.args.get('state') != state:
        return 'invalid state'

    dat = urllib.request.urlopen('https://www.googleapis.com/oauth2/v4/token', urllib.parse.urlencode({
        'code': flask.request.args.get('code'),
        'client_id': client_id,
        'client_secret': client_secret,
        'redirect_uri': redirect_uri,
        'grant_type': 'authorization_code'
    }).encode('ascii')).read()

    dat = json.loads(dat.decode('ascii'))

    id_token = dat['id_token'].split('.')[1]  # 署名はとりあえず無視する
    id_token = id_token + '=' * (4 - len(id_token)%%4)  # パディングが足りなかったりするっぽいので補う
    id_token = base64.b64decode(id_token, '-_')
    id_token = json.loads(id_token.decode('ascii'))

    return 'success!<br>hello, {}.'.format(id_token['email'])

こんな感じで。 さきほど設定したstateを冒頭でチェックしています。今回は定数なのでチェックする意味がありませんが、本来はこれで同じセッションかどうか調べたりするようです。

今回はチェックしていませんが、ログインがキャンセルされたなどの問題があった場合はerrorにエラーメッセージが入るそうです。

んで、受け取ったクエリの内、codeってやつをグーグルにPOSTで送ります。アドレスはhttps://www.googleapis.com/oauth2/v4/tokenに。なんかバージョンによって色々あるようですが、たぶんこれが(2015年8月時点では)最新です。 client_id, client_secret, redirect_uriなどはデベロッパーコンソールで設定もしくは取得した内容そのままです。 grant_typeというのはよく分からないのですが、とりあえずauthorization_codeを指定してくれとのことです。

データを送ると、json形式で色々データが返ってきます。同じcodeを使って何度も取得しようとすると400 Bad Requestが返るみたい。 返ってくるjsonには色々情報が入っているのですが、とりあえず欲しいのはid_tokenというパラメータの中身。

ただこのデータ、JSON Web Tokenとかいう形式でエンコードされています。 署名とJSONデータをbase64エンコードしてピリオドで繋いだものらしいのですが、面倒なので署名は無視します。 ピリオド区切りの二番目のデータが本体らしいのでそこだけ抜き出して、足りないパディングを補ってからbase64デコードします。

デコードしたデータはただのjsonになるので、パースすれば完了。emailアドレスも中に入っているので、そいつを表示させるようにしてみました。 ユーザ情報の取得はさらに別のAPIを叩くことになるそうです。面倒なのでここでは扱いません。

まとめ

こういうのってなんかやたらと面倒臭そうなイメージだったのですが、思いの外簡単に出来ました。 まあ、それでもやっぱりライブラリ使えよって感じはしますね。なんだか情報少ないし。APIもちょいちょい変わるみたいだし。

今回使ったコードのフルバージョンを掲載しておきます。

import urllib.request
import urllib.parse
import json
import base64

import flask


app = flask.Flask(__name__)

client_id = '-- デベロッパーコンソールで取得したクライアントID --'
client_secret = '-- こっちはクライアントシークレット --'
redirect_uri = 'http://localhost:5000/login/check'

state = 'this is test'  # 本当はこれはランダム


@app.route('/login')
def login():
    return flask.redirect('https://accounts.google.com/o/oauth2/auth?{}'.format(urllib.parse.urlencode({
        'client_id': client_id,
        'scope': 'profile email',
        'redirect_uri': redirect_uri,
        'state': state,
        'openid.realm': 'http://localhost:5000',
        'response_type': 'code'
    })))


@app.route('/login/check')
def check():
    print(flask.request.args.get('state'))
    dat = urllib.request.urlopen('https://www.googleapis.com/oauth2/v4/token', urllib.parse.urlencode({
        'code': flask.request.args.get('code'),
        'client_id': client_id,
        'client_secret': client_secret,
        'redirect_uri': redirect_uri,
        'grant_type': 'authorization_code'
    }).encode('ascii')).read()

    dat = json.loads(dat.decode('ascii'))

    id_token = dat['id_token'].split('.')[1]  # 署名はとりあえず無視する
    id_token = id_token + '=' * (4 - len(id_token)%%4)  # パディングが足りなかったりするっぽいので補う
    id_token = base64.b64decode(id_token)
    id_token = json.loads(id_token.decode('ascii'))

    return 'success!<br>hello, {}.'.format(id_token['email'])


if __name__ == '__main__':
    app.run(debug=True)

参考: