【Python】flask + cloud run で Auth0 でログイン機能実装

# 概要

pythonのflaskを使って、Auth0を用いたログイン機能を実装します。
インフラはなるべくシンプルにしたいと思って、いくつか試したのですが、
cloud run で動かすのが個人的にはうまくいったので、今回記しておこうと思います。

# 実装開始！

## 動作環境の用意

以下のようなDockerfileを作成します。


```Dockerfile:Dockerfile
FROM python:3.8
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 app:app
```

必要なライブラリはこちら。

```text:requirements.txt
Flask==1.1.4
gunicorn==19.10.0
python-dotenv
requests
authlib
six
```

## flaskのコード作成

ドメインやクライアント情報は、適宜、Auth0の管理画面から確認して、
自分の登録情報で書き換えてください。

また、Auth0公式のサンプルコードには記載されていませんが、
`app.secret_key`の設定が漏れているとエラーが発生するので、忘れず記載しましょう。

```python:app.py
from functools import wraps
import json
from os import environ as env
from werkzeug.exceptions import HTTPException

from dotenv import load_dotenv, find_dotenv
from flask import Flask
from flask import jsonify
from flask import redirect
from flask import render_template
from flask import session
from flask import url_for
from authlib.integrations.flask_client import OAuth
from six.moves.urllib.parse import urlencode

app = Flask(__name__)
app.secret_key = 'xxxxxxx'

oauth = OAuth(app)

auth0 = oauth.register(
    'auth0',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    api_base_url='https://sample.us.auth0.com',
    access_token_url='https://sample.us.auth0.com/oauth/token',
    authorize_url='https://sample.us.auth0.com/authorize',
    client_kwargs={
        'scope': 'openid profile email',
    },
)
```

続いて、コールバックとログインです。
YOUR_CALLBACK_URLは、Auth0の管理画面で設定したものと一致しないと動作しません。
CloudRunにデプロイするとURLが取得できるので、その値を入れるようにしましょう。
ローカルで実行する際は、YOUR_CALLBACK_URLをlocalhostなどに切り替えてください。

```python:app.py
@app.route('/callback')
def callback_handling():
    # Handles response from token endpoint
    auth0.authorize_access_token()
    resp = auth0.get('userinfo')
    userinfo = resp.json()

    # Store the user information in flask session.
    session['jwt_payload'] = userinfo
    session['profile'] = {
        'user_id': userinfo['sub'],
        'name': userinfo['name'],
        'picture': userinfo['picture']
    }
    return redirect('/dashboard')


@app.route('/login')
def login():
    return auth0.authorize_redirect(redirect_uri='YOUR_CALLBACK_URL')
```

ログイン画面に行くためのページを作成します。

```python:app.py
@app.route('/')
def home():
    return render_template('home.html')
```

![スクリーンショット 2021-11-27 8.25.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8222/6e57ff97-8e99-03ef-f5b2-10ddcdcd8554.png)

```html:templates/home.html
<div class="login-box auth0-box before">
    <h3>Auth0 Example</h3>
    <p>Zero friction identity infrastructure, built for developers</p>
    <a class="btn btn-primary btn-lg btn-login btn-block" href="/login">Log In</a>
</div>
```

Log Inをクリックすると、以下のようなAuth0側で用意しているログイン画面に遷移します。

![2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f383232322f38303765373337302d353732362d656232322d643030382d6162356133323062393631342e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8222/32ae9d7d-e3d8-0a17-3125-1e013cd3b8bb.png)



## ログイン状態の確認

次は、ユーザーがログインしているかどうかチェックする関数を作成します。
この関数は、例えば、以下のようにパス指定のあとに入れることで、
ログイン確認をおこない、ログインしていないユーザーをトップにリダイレクトしてくれます。

```python
@app.route('/dashboard')
@requires_auth
```


```python:app.py
def requires_auth(f):
  @wraps(f)
  def decorated(*args, **kwargs):
    if 'profile' not in session:
      # Redirect to Login page here
      return redirect('/')
    return f(*args, **kwargs)

  return decorated
```

## ログインしているユーザーの情報表示

ログインしているかどうか確認しやすいように、
ログイン中のユーザーの情報を表示してみます。

```python:app.py
@app.route('/dashboard')
@requires_auth
def dashboard():
    return render_template('dashboard.html',
                           userinfo=session['profile'],
                           userinfo_pretty=json.dumps(session['jwt_payload'], indent=4))
```

情報を表示するページのHTML。
次の項目で実装するログアウトボタンも設置してあります。

```html:templates/dashboard.html
<div class="logged-in-box auth0-box logged-in">
    <img class="avatar" src="{{userinfo['picture']}}"/>
    <h2>Welcome {{userinfo['name']}}</h2>
    <pre>{{userinfo_pretty}}</pre>
    <a class="btn btn-primary btn-lg btn-logout btn-block" href="/logout">Logout</a>
</div>
```

## ログアウトをする

ログアウトは、Auth0側のサイトに遷移して、
URLパラメータで指定したURLで戻ってくるようになっています。

ここで要注意なのが、
ログアウト後に戻ってくるサイトのURLです。
url_for()で生成されるアドレスは、基本は **http** になっているようで、
redirect()で生成されるパラメータのURLも **http** から始まるものになっています。
CloudRunで生成された **https** から始まるアドレスを、そのまま Auth0 の Allowed Logout URLs に設定する場合、戻り先も **https** から始まるURLになっている必要があります。
`url_for()` で、 `_scheme='https'` を指定すると**https**のURLを生成することができます。

ちなみに、私はパラメータのURLがhttpから始まっていることになかなか気づけず、
ローカルでは動くのに、CloudRunにデプロイすると動かない、という状態になり、頭を抱えました…

```python:app.py
@app.route('/logout')
def logout():
    # Clear session stored data
    session.clear()
    # Redirect user to logout endpoint
    params = {'returnTo': url_for('home', _external=True, _scheme='https',), 'client_id': 'YOUR_CLIENT_ID'}
    return redirect(auth0.api_base_url + '/v2/logout?' + urlencode(params))
```

## CloudRun向けの動作設定

動作ポートを8080に指定します。

```python:app.py
if __name__ == "__main__":
    app.run(debug=False, threaded=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))

```

## CloudRunにデプロイする

### ビルドする

デプロイしたいGCPのプロジェクトIDを確認して、
ビルドします。

```zsh
% gcloud projects list
PROJECT_ID                     NAME               PROJECT_NUMBER
....

% gcloud builds submit --tag gcr.io/PROJECT_ID/flask-auth0-sample
Creating temporary tarball archive of xx file(s) totalling xx.0 KiB before compression.
Some files were not included in the source upload.
(中略)
DONE
------------------------------------------------------------------------------------------------------------------------------
ID                       CREATE_TIME                DURATION  SOURCE                                                                  IMAGES                                                 STATUS
xxx-xxx-xxxx-xxxx-xxxxx  2021-11-26T22:46:42+00:00  45S       gs://PROJECT_ID_cloudbuild/source/xxx.tgz  gcr.io/PROJECT_ID/flask-auth0-sample (+1 more)  SUCCESS
```

ビルドに成功したら、次はデプロイをおこないます。
Service nameは、カッコ内に表示されている文字列を同じでOKです。
リージョンはどこでも構いませんが、今回は**1**にしました。

```zsh
% gcloud run deploy --image gcr.io/PROJECT_ID/flask-auth0-sample
Service name (flask-auth0-sample): flask-auth0-sample
Please specify a region:
 [1] asia-east1
 [2] asia-east2
 [3] asia-northeast1
 (中略)
 [28] us-west3
 [29] us-west4
 [30] cancel
Please enter your numeric choice:  1
To make this the default region, run `gcloud config set run/region asia-east1`.

Allow unauthenticated invocations to [flask-auth0-sample] (y/N)? y

Deploying container to Cloud Run service [flask-auth0-sample] in project [PROJECT_ID] region [asia-east1]
✓ Deploying new service... Done.                                                                                             
  ✓ Creating Revision...                                                                                                     
  ✓ Routing traffic...                                                                                                       
  ✓ Setting IAM Policy...                                                                                                    
Done.                                                                                                                        
Service [flask-auth0-sample] revision [flask-auth0-sample-xxx] has been deployed and is serving 100 percent of traffic.
Service URL: https://flask-auth0-sample-xxxxxx.a.run.app
```

あとは、Service URLに記載されたURLにアクセスして、ログインできれば、成功です！