Last Updated: 2/6/2024, 5:44:57 AM

# OAuth を使いたい。

WARNING

書きかけです。

以下のような具合で GitHub と Twitter の OAuth を実装していきます。

完成のイメージ「ログインフォーム」

考え方は以下のような具合です。 この辺の当たり前のことが、最初にイメージできなくて、 あれ?どうすればいいんだ?ってなってました。

  1. 「ログインフォーム」には上記のように、複数のサイトへの OAuth ログインのリンクを貼っておきます。
  2. もしユーザがログインしていなければ「ログインフォーム」にリダイレクトさせます。

# 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 のようなエラーメッセージを投げ返されます。 デバッグにあたっては 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. ローカルで動作確認

これでローカルで動くはずです。

ここからHerokuのアップロードを想定しています

# 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

これにはだいぶハマりました。 以下の記事で知ることができました。 まだ自分も詳細を把握していません。

# そのほか調べたこと

Rails の記事で force-ssl=true にするみたいなのを見かけるけど。 以下の記事は気づくきっかけにはなりました、解決には至らなかった。

ProxyFix をというのも見たけど、解決に導けなかった... orz:w

ProxyFixwerkzeug.middleware.proxy_fix.ProxyFix が最新。

# セキュリティ

flask_security, flask_login など使わずに素の Flask のままで実装した。 この辺りの温度感がわからない。 ここに鬼のようによくまとまっている...

# フック

フックらしいものを見かけた。 Flask にはたくさんフックがあるらしい。

template に対するフックもある、 こういうのとかまじどうやって調べるんだろう...

# おわりに

ここまで以下のように見てきました。

以上になります。ありがとうございました。