概要
「ユーザ認証でログイン/ログアウトできるWebサーバを作ってみたい」と思ったが、やったことがないのでどう取っ掛かっていいのかがわからない…。自分で全部実装するのは初心者には結構大変そうなので、ユーザ管理に関して便利な仕組みはないのだろうか。
そう思って調べていくと、ユーザIDの保存や管理などを代わりにやってくれるIdentify Provider(IdP)というサービスがあるようで、これを使えばだいぶ手間が省けそうな気がした。IdPとしては、例えばOkta、Azure AD、Auth0とかが有名みたい。
沢山あるIdPの中から今回はとりあえずAuth0を選んで、ログイン/ログアウト可能なWebサーバを実装してみた。(21/12/29:Azure AD対応版ソースも追記)
やりたいこと
すごく単純。Webブラウザ上で以下の遷移をするサーバを作りたい。
- (1) トップページ(Path :
/
)- 「ログイン」と記載されたリンクだけがある。クリックすると(2)に進む。
- (2) ログイン入力ページ(Auth0の認証サイトに遷移)
- 「ユーザID」と「パスワード」を入力するページ
- 実際はわざわざ(1)のトップページと分けなくても、(1)に埋め込んで一つのページで完結できるようだが、今回はそこまでできていない。
- (3) ログイン完了後のページ(Pathは
/welcome
)- 「Welcome (ユーザ名)!」と表示
- 「ログアウト」と記載されたリンクがある。クリックすると(1)のトップページに戻る
準備
(1) Auth0へのユーザ登録
まずは以下のサイト等を参考にして、Auth0のサービスを利用可能な状態にする
- クラウド認証サービス Auth0 の無料トライアルを試してみよう
(2) Auth0 Dashboardでの事前設定
- Auth0のトップページ(https://auth0.com/jp)からログインして、「Auth0 Dashboard」の画面に移動。
- 左側のメニューで「Applications」→「Applications」をクリックすると右側に2つのアプリケーションが出てくるので、その中の「Default App」を選択。
- Default Appの「Settings」で真ん中あたりに出てくる「Allowed Callback URLs」と「Allowed Logout URLs」にそれぞれ以下のようにURLを追加。
- Allowed Callback URLs
- http(s)://[サーバのホスト名 or IP]:3000/callback
- Allowed Logout URLs
- http(s)://[サーバのホスト名 or IP]:3000
- ※ホスト名やIPアドレスはプライベートな値でも問題ない
- Allowed Callback URLs
- 設定の一番下までいって、「Save Changes」をクリックして保存。
「Callback URL」「Logout URL」と言われてもピンとこないかもしれないので、本記事の「やりたいこと」で書いた遷移(1)〜(3)で、これらがどこで関わってくるかに主眼を置き説明する。
まずCallback URLは、「遷移(2)でAuth0の認証サイトでID/Passwordを入力後、どこに遷移するか」を表す。今回の例の場合は自分で作ったサイトの/callback
のパスに戻ってくる。
続いてLogout URLは、遷移(3)でログアウトボタンをクリック後に遷移する先を指定する。後で示すソースを見ていただくとわかるかと思うが、ログアウト時は「Auth0のログアウト用API」も呼ばれ、API実行後にルート/
のページに戻る。
(3) Webサーバの構築準備(PythonのFlaskを利用)
PythonのFlaskを使ってログイン/ログアウトするWebサーバを構築する。
Pythonの環境情報
-
動作確認したPythonのバージョン
- 3.9.7
-
pipでインストールするパッケージ
パッケージ 用途など flask Webアプリケーションフレームワーク oauthlib OAuth2に関連するリクエスト情報の作成 requests Auth0(IdP)のAPIへのリクエスト用
実装
準備が整ったらソースを作成して、Webサーバを実装する。
ソース作成のために参考にさせていただいたサイトは下記2つ。
- (1) Auth0のPythonログイン用サンプルソース(GitHub)
上記サイトのソースをそのまま実行すれば本記事とほぼ同じログイン/ログアウトの動作を確認できるが、Auth0用のパッケージでかなりラッピングされている。今回はOAuth2の動きも実感しつつソースを作ってみたかったので、Auth0のパッケージはあえて使わず、他のIdPでも共通して使えそうなOAuthのパッケージ(OauthLib)で実装してみることにした。
- (2) PythonでシンプルにOAuth2する
OauthLibの基本的な使い方が書かれており、大変参考にさせていただいた。
24/10/14追記:GitHubでソースを公開
本記事に記載した内容をブラッシュアップして、FlaskでOIDCを行うソースを以下のGitHubに公開しました。
ソース構成
.
├── app.py <- メイン部分
└── templates
├── layout.html <- 共通に使うテンプレート
├── top.html <- ルート(/)の内容
└── welcome.html <- ログイン後のサイト(/welcome)の内容
htmlテンプレートの中身
<!doctype html>
<html>
<head>
<title>{{ title }}</title>
</head>
<meta charset="utf-8" />
<body>
{% block content %}
<!-- ここにメインコンテンツを書く -->
{% endblock %}
</body>
</html>
トップページ(top.html)はAuth0のログインサイトに遷移するリンクを表示
{% extends "layout.html" %}
{% block content %}
<a href="{{ authz_url }}">ログイン</a>
{% endblock %}
ログイン後のページ(welcome.html)には以下を表示
- Auth0から取得したユーザ名。変数
{{ name }}
が該当。 - ログアウトへのリンク
{% extends "layout.html" %}
{% block content %}
Welcome, {{ name }}!<br>
<a href="/logout">ログアウト</a>
{% endblock %}
メインのソース部分
まずはソース全体を掲載。
★がついているところは後ほど解説。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, requests, json, base64
from urllib.parse import urlencode, urlparse, parse_qs
from functools import wraps
from flask import Flask, render_template, request, redirect, session
from oauthlib.oauth2 import WebApplicationClient
# httpでtoken取得可能にするために設定
# (httpsサーバの場合は不要)
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
# FlaskのWebサーバ設定
app = Flask(__name__)
app.secret_key = "SecretKey"
app.debug = True
# ログイン判定に使用
PROFILE_KEY = 'profile'
# ★ 解説(1)
# Auth0(OAuth2)関連の情報を定義
AUTH0_DOMAIN = "dev-xxxxxxxx.us.auth0.com"
AUTH0_CLIENT_ID = "yyyyyyyyyyyyyyyyyyyyy"
AUTH0_CLIENT_SECRET = "zzzzzzzzzzzzzzzzzzzzz"
AUTH0_CALLBACK_URL = r"http://[サーバのホスト名 or IP]:3000/callback"
# WebApplicationClientの作成
oauth = WebApplicationClient(AUTH0_CLIENT_ID)
# Auth0での認証が完了していない場合はルート(/)にリダイレクトさせる関数
# 関数welcome(URLで/welcomeを指定した時の処理)にデコレータとして適用
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
if PROFILE_KEY not in session:
return redirect('/')
return f(*args, **kwargs)
return decorated
# トップページ
@app.route('/')
def index():
# ★ 解説(2)
# 認可用のリクエストを作成
authz_url, authz_headers, authz_body = \
oauth.prepare_authorization_request(
'https://{}/authorize'.format(AUTH0_DOMAIN),
redirect_url=AUTH0_CALLBACK_URL,
scope='openid profile email',
)
# トップページに、Auth0の認可用URLへのリンク(authz_urlの値)を貼る
return render_template('top.html', authz_url=authz_url)
# ★ 解説(3)
# Auth0での認証後に戻ってくるパス
@app.route('/callback')
def callback():
# callbackURLのクエリ文字列から、stateの値を取得
url_parsed = urlparse(request.url)
qs_d = parse_qs(url_parsed.query)
state = qs_d['state'][0]
# ★ 解説(3-1)
# トークン取得用のリクエスト作成
token_url, token_headers, token_body = \
oauth.prepare_token_request(
'https://{}/oauth/token'.format(AUTH0_DOMAIN),
authorization_response=request.url,
client_secret=AUTH0_CLIENT_SECRET,
state=state,
)
# ★ 解説(3-2)
# アクセストークンを取得
token_res = requests.post(
token_url, data=token_body, headers=token_headers
)
# ★ 解説(3-3)
# レスポンスからJWTの各種情報を取得
token_res_json = json.loads(token_res.content.decode('utf-8'))
# JWTからペイロード情報を取得
jwts = token_res_json['id_token'].split(".")
# 文字数が4の倍数でないとエラーになるので"="でパディング
jwts[1] = jwts[1] + '='*(-len(jwts[1])%4)
# ペイロードをsessionの配列に格納
session[PROFILE_KEY] = json.loads(base64.urlsafe_b64decode(jwts[1]).decode('utf-8'))
return redirect('/welcome')
# ★ 解説(4)
# ログイン後のWelcomeページ(Auth0で認証完了後でないとアクセスできない)
@app.route('/welcome')
@requires_auth
def welcome():
return render_template(
'welcome.html',
name = session[PROFILE_KEY]['nickname'],
)
# ★ 解説(5)
# ログアウト時に通るパス。最終的にはトップページ(/)に戻る
@app.route('/logout')
def logout():
session.clear()
params = {'returnTo': "http://[サーバのホスト名 or IP]:3000/", 'client_id': AUTH0_CLIENT_ID }
return redirect('https://' + AUTH0_DOMAIN + '/v2/logout?' + urlencode(params))
if __name__ == "__main__":
app.run(host='0.0.0.0', port=3000)
ソース解説
(1) Auth0(OAuth2)にアクセスするための情報定義
AUTH0_DOMAIN = "dev-xxxxxxxx.us.auth0.com"
AUTH0_CLIENT_ID = "yyyyyyyyyyyyyyyyyyyyy"
AUTH0_CLIENT_SECRET = "zzzzzzzzzzzzzzzzzzzzz"
AUTH0_CALLBACK_URL = r"http://[サーバのホスト名 or IP]:3000/callback"
- Auth0の場合、
AUTH0_DOMAIN
,AUTH0_CLIENT_ID
,AUTH0_CLIENT_SECRET
の3つは「Auth0 Dashboard」のApplicationの設定(Settings)で確認可能。おそらくAuth0以外のIdPであってもこれらのパラメータは用意されており共通に使えるはず。 -
AUTH0_CALLBACK_URL
は、Auth0でのユーザログイン後に戻ってくるURL
(2) 認可用リクエストの作成
authz_url, authz_headers, authz_body = \
oauth.prepare_authorization_request(
'https://{}/authorize'.format(AUTH0_DOMAIN),
redirect_url=AUTH0_CALLBACK_URL,
scope='openid profile email',
)
Auth0で認可してもらうためのリクエスト情報を作成する。authz_url
には以下のようなURLがクエリ文字列つきで返ってきた。(実際の値は改行されていない。以後のURLも同様)
https://dev-xxxxxxxx.us.auth0.com/authorize?
response_type=code&client_id=[AUTH0_CLIENT_ID]&
redirect_uri=[AUTH0_CALLBACK_URL]&scope=openid+profile+email&
state=[ランダムな文字列]
(3) Callback後の処理
(3-1) トークン取得用リクエストの作成
CallbackされたURLには、以下のようにcodeとstateのパラメータ値がクエリ文字列でくっついてくる。
http://[AUTH0_CALLBACK_URL]?
code=[ランダムな文字列]
state=[authz_urlのstateと同じ値]
ここからstateの値を取得して、以下のように「トークン取得用リクエスト情報」を作成する。
token_url, token_headers, token_body = \
oauth.prepare_token_request(
'https://{}/oauth/token'.format(AUTH0_DOMAIN),
authorization_response=request.url,
client_secret=AUTH0_CLIENT_SECRET,
state=state,
)
各々、以下のような結果が返ってくる。
token_url : https:/[AUTH0_DOMAIN]/oauth/token
token_headers : {'Content-Type': 'application/x-www-form-urlencoded'}
token_body : grant_type=authorization_code&
client_id=[AUTH0_CLIENT_ID]&
client_secret=[AUTH0_CLIENT_SECRET]&
code=[CallbackURLのcodeと同じ値]&
redirect_uri=[AUTH0_CALLBACK_URL]
(3-2) アクセストークンを取得
token_res = requests.post(
token_url, data=token_body, headers=token_headers
)
(3-1)で作った「アクセストークン取得用リクエスト」を投げて、アクセストークンを取得する。
アクセストークンの中身は以下のようになっている。
{
"access_token":"…",
"id_token":"aaaa.bbbb.cccc",
"scope":"openid profile email",
"expires_in":86400,
"token_type":"Bearer"
}
上記の詳しい説明は省略するが、id_tokenのaaaa.bbbb.cccc
の部分はJSON Web Token(JWT)と呼ばれており、「OpenID Connect(OIDC)の仕様で定めれられた、2者間で情報を安全に伝送するためのトークン」である。
id_tokenのドット(.)で区切られたうちのbbbb
部分に認証ユーザに関する様々な情報が入っており(「ペイロード」と呼ぶ)次の処理で抽出する。
(3-3) Welcomeページに表示するユーザ情報の取得
# レスポンスからJWTの各種情報を取得
token_res_json = json.loads(token_res.content.decode('utf-8'))
# JWTからペイロード情報を取得
jwts = token_res_json['id_token'].split(".")
# 文字数が4の倍数でないとエラーになるので"="でパディング
jwts[1] = jwts[1] + '='*(-len(jwts[1])%4)
# ペイロードをsessionの配列に格納
session[PROFILE_KEY] = json.loads(base64.urlsafe_b64decode(jwts[1]).decode('utf-8'))
ソース内でtoken_res_json['id_token']
がJWT、jwts[1]
がペイロードに相当する。ペイロードはBase64URL形式でエンコードされているため、デコードして中身を取得する。
今回は/callback
からリダイレクトする/welcome
ページに、ペイロードから「ユーザのニックネーム」を抽出して表示させたい。そこで、Flaskのsession部分(Cookieと同等?)にデコードしたペイロード情報を保存することにする。
ペイロードの中身は以下のようなJSON形式になっている。
{
'nickname': 'username',
'name': 'username@hogehoge.com',
'picture': 'https://s.gravatar.com/avatar/...',
'updated_at': '2021-09-18T12:21:57.535Z',
'email': 'username@hogehoge.com',
'email_verified': False,
'iss': 'https://[AUTH0_DOMAIN]/',
'sub': 'auth0|xxxxxxxxxxxxxxxx',
'aud': '[AUTH0_CLIENT_ID]',
'iat': 1631974418,
'exp': 1632010418,
}
(4) Welcomeページの表示
# ログイン後のWelcomeページ(Auth0で認証完了後でないとアクセスできない)
@app.route('/welcome')
@requires_auth
def welcome():
return render_template(
'welcome.html',
name = session[PROFILE_KEY]['nickname'],
)
(3-3)で取得したペイロード情報のnicknameをWebページに表示する。
(5) ログアウト
@app.route('/logout')
def logout():
session.clear()
params = {'returnTo': "http://[サーバのホスト名 or IP]:3000/", 'client_id': AUTH0_CLIENT_ID }
return redirect('https://' + AUTH0_DOMAIN + '/v2/logout?' + urlencode(params))
Auth0のページにリダイレクトしてログアウトする。ログアウト後は自分のWebサーバのルート(/)にリダイレクトされる。
また、session.clear()
自分のWebサーバのセッションもここで終了する。
[参考]Auth0のAPIドキュメント
今回、以下3つのAuth0関連のAPIを利用したが、Auth0の認証APIドキュメントにこれら含めた一連のAPI利用方法が書いてある模様。
機能 | URL |
---|---|
認可 | https://[AUTH0_DOMAIN]/authorize |
アクセストークン取得 | https://[AUTH0_DOMAIN]/oauth/token |
ログアウト | https://[AUTH0_DOMAIN]/v2/logout |
他のIdPでは試せていないが、各IdPのAPI仕様にしたがって今回ご紹介したソースをカスタマイズすれば、たぶん使えるのかなあと思っている次第。
Azure AD対応版(追記)
Azure ADと連携する場合はapp.py
を以下のように書き換えるとOK。
Auth0版ソースからの主な変更点は下記3点。
- (1) OAuth2のドメイン
- Azure ADは
https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/
配下がOAuth2のエンドポイントとなる
- Azure ADは
- (2) 各種APIのエンドポイントパスの設定
機能 | URL | 補足 |
---|---|---|
認可 | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize | Auth0と形式は同じ |
アクセストークン取得 | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token | |
ログアウト | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/logout | Auth0と形式は同じだが設定パラメータが異なる。MSのドキュメント |
- (3) Auth0にあったid_tokenのパラメータ
nickname
は、Azure ADにはデフォルトでは存在しないので、存在するname
に変更
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, requests, json, base64
from urllib.parse import urlencode, urlparse, parse_qs
from functools import wraps
from flask import Flask, render_template, request, redirect, session
from oauthlib.oauth2 import WebApplicationClient
# httpでtoken取得可能にするために設定
# (httpsサーバの場合は不要)
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
SESSION_COOKIE_NAME = "oauth-test-flask"
# FlaskのWebサーバ設定
app = Flask(__name__)
app.secret_key = "SecretKey"
app.config["SESSION_COOKIE_NAME"] = SESSION_COOKIE_NAME
app.debug = True
# ログイン判定に使用
PROFILE_KEY = 'profile'
PROTOCOL = "https"
SERVER_NAME_PORT = "[サーバのIP]:[ポート]"
# Azure ADテスト用
OAUTH2_DOMAIN = "login.microsoftonline.com/{tenant_id}/oauth2/v2.0"
OAUTH2_CLIENT_ID = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
OAUTH2_CLIENT_SECRET = "yyyyyyyyyyyyyyyyyyyyyyyyyyyy"
OAUTH2_CALLBACK_URL = "{}://{}/callback".format(PROTOCOL, SERVER_NAME_PORT)
# WebApplicationClientの作成
oauth = WebApplicationClient(OAUTH2_CLIENT_ID)
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
if PROFILE_KEY not in session:
return redirect("{}://{}/".format(PROTOCOL, SERVER_NAME_PORT))
return f(*args, **kwargs)
return decorated
# トップページ
@app.route('/')
def index():
# 認可用のリクエストを作成
authz_url, authz_headers, authz_body = \
oauth.prepare_authorization_request(
'https://{}/authorize'.format(OAUTH2_DOMAIN),
redirect_url=OAUTH2_CALLBACK_URL,
scope='openid profile email',
)
# トップページに、Auth0の認可用URLへのリンク(authz_urlの値)を貼る
return render_template('top.html', authz_url=authz_url)
# Auth0での認証後に戻ってくるパス
@app.route('/callback')
def callback():
# callbackURLのクエリ文字列から、stateの値を取得
url_parsed = urlparse(request.url)
qs_d = parse_qs(url_parsed.query)
state = qs_d['state'][0]
# トークン取得用のリクエスト作成
token_url, token_headers, token_body = \
oauth.prepare_token_request(
'https://{}/token'.format(OAUTH2_DOMAIN),
authorization_response=request.url,
client_secret=OAUTH2_CLIENT_SECRET,
state=state,
)
# アクセストークンを取得
token_res = requests.post(
token_url, data=token_body, headers=token_headers
)
# レスポンスからJWTの各種情報を取得
token_res_json = json.loads(token_res.content.decode('utf-8'))
# JWTからペイロード情報を取得
jwts = token_res_json['id_token'].split(".")
# 文字数が4の倍数でないとエラーになるので"="でパディング
jwts[1] = jwts[1] + '='*(-len(jwts[1])%4)
# JWTからペイロード情報を取得
session[PROFILE_KEY] = json.loads(base64.urlsafe_b64decode(jwts[1]).decode('utf-8'))
# return redirect('/welcome')
return redirect("{}://{}/welcome".format(PROTOCOL, SERVER_NAME_PORT))
# ログイン後のパス(Auth0で認証完了後でないとアクセスできない)
@app.route('/welcome')
@requires_auth
def welcome():
return render_template(
'welcome.html',
name = session[PROFILE_KEY]['name'],
)
# ログアウト時の通るパス。最終的にはトップページ(/)に戻る
@app.route('/logout')
def logout():
session.clear()
params = {'post_logout_redirect_uri': "{}://{}/".format(PROTOCOL, SERVER_NAME_PORT), 'client_id': OAUTH2_CLIENT_ID }
return redirect('https://' + OAUTH2_DOMAIN + '/logout?' + urlencode(params))
if __name__ == "__main__":
app.run(host='0.0.0.0', port=3000)