2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonとOAuth2でログイン/ログアウトするWebサーバを作る(Auth0, Azure AD対応ソース記載)

Last updated at Posted at 2021-09-18

概要

「ユーザ認証でログイン/ログアウトできる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のサービスを利用可能な状態にする

(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アドレスはプライベートな値でも問題ない
  • 設定の一番下までいって、「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つ。

上記サイトのソースをそのまま実行すれば本記事とほぼ同じログイン/ログアウトの動作を確認できるが、Auth0用のパッケージでかなりラッピングされている。今回はOAuth2の動きも実感しつつソースを作ってみたかったので、Auth0のパッケージはあえて使わず、他のIdPでも共通して使えそうなOAuthのパッケージ(OauthLib)で実装してみることにした。

OauthLibの基本的な使い方が書かれており、大変参考にさせていただいた。

24/10/14追記:GitHubでソースを公開

本記事に記載した内容をブラッシュアップして、FlaskでOIDCを行うソースを以下のGitHubに公開しました。

ソース構成

.
├── app.py            <- メイン部分
└── templates
    ├── layout.html   <- 共通に使うテンプレート
    ├── top.html      <- ルート(/)の内容
    └── welcome.html  <- ログイン後のサイト(/welcome)の内容

htmlテンプレートの中身

layout.html
<!doctype html>
<html>
<head>
<title>{{ title }}</title>
</head>
<meta charset="utf-8" />
<body>
{% block content %}
<!-- ここにメインコンテンツを書く -->
{% endblock %}
</body>
</html>

トップページ(top.html)はAuth0のログインサイトに遷移するリンクを表示

top.html
{% extends "layout.html" %}
{% block content %}
<a href="{{ authz_url }}">ログイン</a>
{% endblock %}

ログイン後のページ(welcome.html)には以下を表示

  • Auth0から取得したユーザ名。変数{{ name }}が該当。
  • ログアウトへのリンク
welcome.html
{% extends "layout.html" %}
{% block content %}
Welcome, {{ name }}!<br>
<a href="/logout">ログアウト</a>
{% endblock %}

メインのソース部分

まずはソース全体を掲載。
★がついているところは後ほど解説。

app.py
#!/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のエンドポイントとなる
  • (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に変更
app.py
#!/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)
2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?