# OAuth を使いたい。
WARNING
書きかけです。
以下のような具合で GitHub と Twitter の OAuth を実装していきます。
考え方は以下のような具合です。 この辺の当たり前のことが、最初にイメージできなくて、 あれ?どうすればいいんだ?ってなってました。
- 「ログインフォーム」には上記のように、複数のサイトへの OAuth ログインのリンクを貼っておきます。
- もしユーザがログインしていなければ「ログインフォーム」にリダイレクトさせます。
# Flask Dance
Flask Dance という OAuth に対応した Flask のライブラリを発見する。 質問も見てくれてて優しそうな人やな。
# 方針
Flask の公式チュートリアル + Flask Dance の公式チュートリアルという方針でいきます。
# 1. Flask の公式チュートリアル
Flask の公式チュートリアルに沿ってアプリケーションが書かれたものとして、 お話を進めさせていただきます。 日本語に翻訳されたものは見当たりませんでした...
コードを書くのが面倒くさいです。 ちなみに公式チュートリアルで作る Flaskr のコードは GitHub 上にあります。 README にダウンロードからセットアップの仕方まで丁寧に書かれています。
# 2. Flask Dance の公式チュートリアル
上記の Flask の公式チュートリアルに Flask Dance の公式チュートリアルにあった Multi-User Setups を組み合わせていきます。
ただし、Flask Dance の公式チュートリアルにある Multi-User Setups は SQLAlchemy を使っているのですが、 Flask の公式チュートリアルでは SQLAlchemy を使っていません。 そのため Flask の公式チュートリアルに合わせる形で、 SQLAlchemy は使わないことにしました。
SQLAlchemy のような ORM を使う使わないは、いろいろ議論があるのを見かけたのですが、 自分自身、そもそもあまり SQL に慣れてないからという個人的な理由で SQLAlchemy を外しました。
# OAuth
これがすごいありがたかった。 完全に誤解していた。 これを見る前に漁ってていろいろちんぷんかんぷんでした。
OAuth2 の解説サイトを漁る前 - Qiita (opens new window)
OAuth2は「ユーザ/パスワードで本人確認」する仕組みではありません。 正しくは「特定のデータへ特定の操作を許可」する仕組みです。
例えばGithubアカウントを使用したOAuth2であれば、 「リポジトリ一覧を読み取り専用でアクセスしてOKです。 リポジトリの追加はできません。」を達成することが目的です。 この達成目標のために、結果的に認証も行うため、 認証の仕組みとしても広く利用されているというだけです。
しばらく触ったあとに、 認可サーバとリソースサーバが別々というのを見て驚きました。
この辺は書籍を買って軽く通しでやった方が、理解は早いのかもしれません。
# Step 1. Flask 公式チュートリアル
README に従い、ダウンロード、セットアップを行ってください。
# Step 2. Flask Dance をインストール
$ pip install Flask-Dance
# Step 3. HTML を追記
flaskr/template/auth/login.html
に追記する。
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Log In{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required>
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
<input type="submit" value="Log In">
</form>
<!-- ここから追記 -->
<div class="oauth">
<h2>Use OAuth</h2>
<a
class="oauth__button oauth__button_twitter"
href="{{ url_for('twitter.login') }}"
>
<span class="oauth__link-text">
Login with Twitter
</span>
</a>
<a
class="oauth__button oauth__button_github"
href="{{ url_for('github.login') }}"
>
<span class="oauth__link-text">
Login with GitHub
</span>
</a>
</div>
<!-- ここまで追記 -->
{% endblock %}
# Step 4. CSS を追記
flaskr/static/style.css
に追記する。
/* 追記 */
.oauth__button {
font-size: 13px;
font-weight: bold;
color: white;
height: 45px;
text-align: center;
display: block;
}
.oauth__button_twitter {
background-color: #00acee;
}
.oauth__button_github {
background-color: #23282D;
}
.oauth__link-text {
line-height: 45px;
}
# Step 5. OAuth アプリを作る
基本的に指示に従っていけば作れます。 コールバック URL さえ間違えなければ、大丈夫なはずです。
# 1. Tiwtter
コールバック URL には以下を指定してください。
https://127.0.0.1:5000/login/twitter/authorized
もしご自身で買ったドメイン名があれば、 もう1つ OAuth アプリを作成してみてください。 書式は以下のようなものです。
https://自分のURL/login/twitter/authorized
公式のサンプルは、こちら。
# 2. GitHub
コールバック URL には以下を指定してください。
https://127.0.0.1:5000/login/github/authorized
もしご自身で買ったドメイン名があれば、 もう1つ OAuth アプリを作成してみてください。 書式は以下のようなものです。
https://自分のURL/login/github/authorized
公式のサンプルは、こちら。
# 3. デバッグ
唯一気をつけないといけないのはコールバック URL の指定の時です。
- Redirect URI mismatch (opens new window)
- error : redirect_uri_mismatch :: The redirect_uri MUST match the registered callback URL - Stackoverflow (opens new window)
これを間違えると Redirect URI mismatch のようなエラーメッセージを投げ返されます。 デバッグにあたっては Chrome のデベロッパツールとにらめっこすることになります。
自分は最初 Wireshark (opens new window) とか引っ張り出してきて、 「暗号化されていてわかないンゴ(´;ω;`)ブワッ」 みたいなドリフみないことやってハマってました。
Wireshark は NIC を出入りするパケットをキャプチャするツールなので、 すでに暗号化されてしまっているんですよね笑 時代は変わるものです。
# Step 6. 環境変数の設定
# 1. ローカル環境
venv/bin/activate
の末尾に以下を追記する。
export FLASK_APP=flaskr
export FLASK_ENV=development
export TWITTER_OAUTH_CLIENT_KEY=ツイッターのクライアントキー
export TWITTER_OAUTH_CLIENT_SECRET=ツイッターのシークレットキー
export GITHUB_OAUTH_CLIENT_ID=ギフハブのクライアントアイディー
export GITHUB_OAUTH_CLIENT_SECRET=ギフハブのクライアントシークレット
# 2. Heroku
Heroku にアプリをアップロードする場合は、 Heroku の環境変数に書き込む必要があります。
heroku config:set GITHUB_OAUTH_CLIENT_ID=ツイッターのクライアントキー
heroku config:set GITHUB_OAUTH_CLIENT_SECRET=ツイッターのシークレットキー
heroku config:set TWITTER_OAUTH_CLIENT_KEY=ギフハブのクライアントアイディー
heroku config:set TWITTER_OAUTH_CLIENT_SECRET=ギフハブのクライアントシークレット
このようにして「環境変数に設定を書き込むこと」は、 「開発環境ごとに config を読み換える方法」の1つです。
# Step 7. blueprint の登録
flaskr/__init__.py
に追記する。blueprint の登録と設定の読み込みます。
def create_app(test_config=None):
...
# register the database commands
from flaskr import db
db.init_app(app)
# apply the blueprints to the app
from flaskr import auth
from flaskr import blog
app.register_blueprint(auth.bp)
app.register_blueprint(blog.bp)
#
# ここから追記
#
def load_config(key):
app.config[key] = os.environ[key]
load_config('GITHUB_OAUTH_CLIENT_ID') # ID
load_config('GITHUB_OAUTH_CLIENT_SECRET')
load_config('TWITTER_OAUTH_CLIENT_KEY') # KEY
load_config('TWITTER_OAUTH_CLIENT_SECRET')
app.register_blueprint(auth.github_bp, url_prefix="/login")
app.register_blueprint(auth.twitter_bp, url_prefix="/login")
#
# ここまで追記
#
# make url_for('index') == url_for('blog.index')
# in another app, you might define a separate main index here with
# app.route, while giving the blog blueprint a url_prefix, but for
# the tutorial the blog will be the main index
app.add_url_rule('/', endpoint='index')
return app
# 短い解説
コールバック URL を思い出してください。 さて、これはどういうルールで決められているのでしょうか?
https://自分のURL/login/twitter/authorized
以下のような具合になっています。
https://自分のURL/ブループリントのURLプリフィックス/OAuthプロバイダの名前/authorized
ブループリントのURLプリフィックス login
は、上記のコードの以下部分で指定しています。
app.register_blueprint(auth.github_bp, url_prefix="/login")
app.register_blueprint(auth.twitter_bp, url_prefix="/login")
かなりわかりにくいのですが、雰囲気だけ伝わればと思いました。
# Step 8. イベントの登録
flaskr/auth.py
に追記してください。
import flask_dance.consumer # noqa: E402
import flask_dance.contrib.github # noqa: E402
import flask_dance.contrib.twitter # noqa: E402
github_bp = flask_dance.contrib.github.make_github_blueprint() # <-- /github
twitter_bp = flask_dance.contrib.twitter.make_twitter_blueprint()
@flask_dance.consumer.oauth_authorized.connect_via(github_bp)
@flask_dance.consumer.oauth_authorized.connect_via(twitter_bp)
def github_logged_in(blueprint, token): # noqa: D400, D403
# 1)
if not token:
msg = "Failed to log in with GitHub."
flash(msg, category="error")
return False
if blueprint is github_bp:
token_string = token['access_token']
elif blueprint is twitter_bp:
token_string = token['oauth_token']
# 2)
if blueprint is github_bp:
resp = flask_dance.contrib.github.github.get("/user")
elif blueprint is twitter_bp:
resp = flask_dance.contrib.twitter.twitter.get(
"account/verify_credentials.json")
provider_name = blueprint.name
if not resp.ok:
msg = "Failed to fetch user info from " + provider_name,
flash(msg, category="error")
return False
if blueprint is github_bp:
provider_user_id = resp.json()["id"]
provider_user_name = resp.json()["login"]
elif blueprint is twitter_bp:
provider_user_id = resp.json()["id"]
provider_user_name = resp.json()["screen_name"]
# 3)
select_oauth = (
f"SELECT * FROM oauth WHERE "
f"provider='{provider_name}' and "
f"provider_user_id='{provider_user_id}' "
)
db = get_db()
cursor = db.cursor()
oauth = cursor.execute(select_oauth).fetchone()
if not oauth:
# 3-0) null literal
null = type(str(), tuple(), dict(__repr__=lambda self: 'null'))()
# 3-1) Create a record for user.
user = (null, provider_user_name, null)
insert_user = f'INSERT INTO user VALUES {user}'
cursor.execute(insert_user)
user_id = cursor.lastrowid
# 3-2) Create a record for oauth user.
oauth = (null, user_id, provider_name, provider_user_id, token_string)
insert_oauth = f'INSERT INTO oauth VALUES {oauth}'
cursor.execute(insert_oauth)
db.commit()
# oauth = (
# f"(null, {user_id}, '{provider_name}', "
# f"'{provider_user_id}', '{token}')"
# )
session.clear()
session['user_id'] = oauth[1]
msg = "Successfully signed in with " + provider_name + "."
flash(msg)
return False
# 1. 短い解説
最初にこのデコレータを見たときに何をしているのか、 さっぱりわかりませんでした。
@flask_dance.consumer.oauth_authorized.connect_via(github_bp)
OAuth の認証が成功した oauth_authorized
というイベントと、
github_bp
を、結びつける connect_via
するという意味合いらしいです。
他言語でよくイベントと呼ばれているものは、 Flask ではシグナル signal (opens new window) と呼ばれている様子です。
このシグナルとなんらかの処理を結びつけるときは connect_via という 関数名で登録されているのをよく見かけます。
つまり、シグナルを使えば、送信者は、何かが起こったことを、事前に登録した受信者に通知することができます。
In short, signals allow certain senders to notify subscribers that something happened.
Signals - Flask (opens new window)
# 2. 対応したエラー
作業途中に場合によっては、以下のようなエラーが返されます。
AttributeError: '_FakeSignal' object has no attribute 'connect_via'
その場合 blinker をインストールしてください。
$ pip install blinker
blinker はシグナル signal で使われています。 フォールバックが何を指しているのかはわかりませんでした。
signal は blinker を元に開発されました、 blinker を利用できない場合、適切にフォールバックされます。
This support is provided by the excellent blinker library and will gracefully fall back if it is not available. Signals - Flask (opens new window)
# Step 9. SQL テーブルの追加する。
flaskr/schema.sql
に以下を追加する。
CREATE TABLE oauth (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
token TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user (id)
);
# Step 10. ローカルで動作確認
これでローカルで動くはずです。
# Step 11. gitignore を修正する。
gitignore を修正して何をするのかというと sqlite を使えるようにします。 ただし
WARNING
本当は sqlite を Heroku で使ってはいけません。 なぜならデータベースを更新しても、時間が経つと元に戻ってしまうからです。 ここでは練習として sqlite を使います。 詳細は下記リンク先を参照してください。
要するに24時間後にはファイルは元に戻っちゃうから、 ファイルに保存する形式のSQLite3はうまく動作しないよってことらしい。
SQLite runs in memory, and backs up its data store in files on disk. While this strategy works well for development, Heroku’s Cedar stack has an ephemeral filesystem. You can write to it, and you can read from it, but the contents will be cleared periodically. If you were to use SQLite on Heroku, you would lose your entire database at least once every 24 hours.
Disk backed storage - SQLite on Heroku (opens new window)
You must not use sqlite3 on Heroku. “no such table” error on Heroku after django syncdb passed - Stackoverflow (opens new window)
# 1. 作業
.gitignore
から instance/
をコメントアウトしてください。
venv/
*.pyc
__pycache__/
# instance/ <--- これをコメントアウト # する
.cache/
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
.idea/
*.swp
*~
# 2. これをしないとどうなるか
これをしないと post
というテーブルがないよと怒られます。
instance/flaskr.sqlite
というファイルに sqlite のデータがはいっているからです。
$ heroku logs
...
app[web.1]: File "/app/flaskr/blog.py", line 17, in index
app[web.1]: 'SELECT p.id, title, body, created, author_id, username'
app[web.1]: sqlite3.OperationalError: no such table: post
...
# 3. 気づいたきっかけ
最初はローカルで動くのに Heroku で動かずハマりました。 これを見て気づくとっかかりになりました。
そこから heroku run bash
して ls
したら、
instance ディレクトリがないことを確認し、原因を特定しました。
(venv) tutorial$ heroku run bash
~ $
~ $ ls
flaskr
LICENSE
MANIFEST.in
Procfile
README.rst
requirements.txt
runtime.txt
setup.cfg
setup.py
tests
~ $
# Step 12. TLS termination に対応する。
もし Cloudflare 経由で SSL 化している場合、すこし話が面倒になります。 うまく説明できないのですが、どういうことかというと redirect url mismuch が発生します。
以下の構成を見てください。
client <- https -> cloudflare <- http -> heroku <- http -> gunicron <- WSGI -> flask
Flask は自分の手前のウェブサーバ、上記の構成なら gunicorn が通信をしている HTTP で通信をしていると思い込んでしまいます。
そのため client にリダイレクト先の URL として https ではなくて http の URL を示してしまうのです。 これを回避するために以下のように書き込んでください。
def create_app(test_config=None):
...
class CloudflareProxy(object):
# This middleware sets the proto scheme
# based on the Cf-Visitor header.
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
import json
cf_visitor = environ.get("HTTP_CF_VISITOR")
if cf_visitor:
try:
cf_visitor = json.loads(cf_visitor)
except ValueError:
pass
else:
proto = cf_visitor.get("scheme")
if proto is not None:
environ['wsgi.url_scheme'] = proto
return self.app(environ, start_response)
app.wsgi_app = CloudflareProxy(app.wsgi_app)
# make url_for('index') == url_for('blog.index')
# in another app, you might define a separate main index here with
# app.route, while giving the blog blueprint a url_prefix, but for
# the tutorial the blog will be the main index
app.add_url_rule('/', endpoint='index')
return app
これにはだいぶハマりました。 以下の記事で知ることができました。 まだ自分も詳細を把握していません。
- Correct HTTP scheme in WSGI with Cloudflare (opens new window)
- X-Forwarded-Proto and Flask - stackoverflow (opens new window)
# そのほか調べたこと
Rails の記事で force-ssl=true
にするみたいなのを見かけるけど。
以下の記事は気づくきっかけにはなりました、解決には至らなかった。
- Can Heroku force an application to use SSL/TLS? - Heroku Help (opens new window)
- Heroku headers - Heroku Dev Center (opens new window)
- flask-sslify - GitHub (opens new window)
- flask-talisman - GitHub (opens new window)
- How does Cloudflare handle HTTP Request headers? - Cloudflare (opens new window)
ProxyFix
をというのも見たけど、解決に導けなかった... orz:w
- authorized_url is http, not https: #188 - GitHub (opens new window)
- Proxies and HTTPS - Flask Dance (opens new window)
ProxyFix
は werkzeug.middleware.proxy_fix.ProxyFix
が最新。
- Make Flask's url_for use the 'https' scheme in an AWS load balancer without messing with SSLify - stackoverflow (opens new window)
- ndalone WSGI Containers - Flask (opens new window)
# セキュリティ
flask_security, flask_login など使わずに素の Flask のままで実装した。 この辺りの温度感がわからない。 ここに鬼のようによくまとまっている...
# フック
フックらしいものを見かけた。 Flask にはたくさんフックがあるらしい。
template に対するフックもある、 こういうのとかまじどうやって調べるんだろう...
# おわりに
ここまで以下のように見てきました。
以上になります。ありがとうございました。