flask-login

# flask-loginの使い方
flask-loginを取り扱うサイトでは意外と重要なことが省略されており非常にわかりにくい
[良さげなページ](https://mycodingjp.blogspot.com/2019/03/flask3-flask-login.html)を見つけたが、おそらく外国語サイトからの引用で、日本語と英語が混ざっていたり、説明がわかりにくかったりしたので、補足情報も込みでわかりやすくまとめた
本ページも結局は引用がメインになるが、圧倒的にわかりやすいはず

説明文中(ファイル名)としているものは、このリポジトリ内のファイル名を指す

# できること
 - ログインを要求するページにアクセスすると、ログインページにリダイレクトする
 - ログインページで入力されたユーザー名、ハッシュ化されたパスワードをDBに登録されているものと比較し、マッチしなければエラーメッセージ
 - ユーザー登録画面での各フィールドのバリデーションとCSRF対策

# 開発手順
#### ユーザーパスワードのチェック
ハッシュ化されたパスワードを管理するために、`werkzeug`(flaskのDependency)を利用する
usersテーブルを定義しているモデルファイル(models.py)内で以下のように記述

```python
# generate_password_hashとcheck_password__hashをimport
from werkzeug.security import generate_password_hash, check_password_hash

# パスワードをハッシュ化
def set_password(self, password):
        self.password_hash = generate_password_hash(password)

# 入力されたパスワードが登録されているパスワードハッシュと一致するかを確認
def check_password(self, password):
        return check_password_hash(self.password_hash, password)
```

#### flask-login(extention)の使用
インストール

```bash
(venv)$ pip install flask-login
```

アプリケーションの初期化ファイル(\_\_init__.py)に以下の記述を追加

```py
# flask-loginからLoginManagerをimport
from flask_login import LoginManager

# appを初期化
app = Flask(__name__)
# ...

# LoginManagerの起動
login = LoginManager(app) #extensionを起動させる際の標準的な記述
# ...
```

#### Userモデルの定義
定義するUserモデルには以下の4つの関数が求められる
 - is_authenticated
  -  ユーザーが有効な権限を持っている場合にTrueを返す
 - is_active
  -  ユーザーアカウントがアクティブである場合にTrueを返す
 - is_anonymous
  -  匿名のユーザーの(ログインしていない)場合にTrueを返す
 - get_id()
  -  アカウント情報を一意に決定する情報を文字列型で返す

以上の4つの関数を定義せずとも、flask-loginに用意されているUserMixinクラスを継承してUSerモデルを定義すればよい
usersテーブルを定義しているモデルファイル(models.py)で以下のように記述

```py
# UserMixinクラスをimport
from flask_logins import UserMixin

# UserMixinクラスを継承したUserクラスを定義
class User(UserMixin, db.Model):
  #...
```
#### flaskのsessionを利用する
ユーザーが新しいページに遷移した時、flask-loginはセッションからidを取り出し、メモリーに格納する<br>
flask-loginはユーザーのDB構造を関知しないので、Userクラスを定義しているモデルファイル(models.py)以下の設定をする

```py
# __init__.pyからloginをimport
from app import login

# デコレータを付与したload_user関数を定義
@login.user_loader
def load_user(id):
  # usersテーブルから指定のidを持つレコードを取り出す
  # flask-loginがこの関数に引数として渡すidの値は文字列であるため、数値に変換する
  return User.query.get(int(id))

# Decoratorの命名規則（convention)
# 上記の例ではlogin_managerではなくloginにLoginManagerをセットしている
# @login_manager.user_loader
# def load_user(user_id):
#     return User.query.get(int(user_id))
```

#### ログイン機能の実装
ログインページのルーティングをルーティングファイル(routes.py)に記述する

```py
# current_userとlogin_userをimport
from flask_login import current_user, login_user
# モデルアフィルからUserクラスをimport
from app.models import User
# ...

# loginページのルーティング
@app.route('/login', methods=['GET', 'POST'])
def login():
  # 現在のユーザーが有効な権限を保持している(ログインしている)場合
  if current_user.is_authenticated:
    # トップページにリダイレクト
    return redirect(url_for('index'))
  # forms.pyで定義するloginフォームを読み込む
  form = LoginForm()
  # wtf? validate_on_submitとは
  if form.validate_on_submit():
    # ログインしようとしているユーザーのレコードを取得
    user = User.query.filter_by(username=form.username.data).one_or_none()
    # ユーザーが存在しない、もしくはパスワードが一致しない場合
    # (check_passwordはUserクラスで定義した関数)
    if user is None or not user.check_password(form.password.data):
      # usernameまたはpasswordが誤っている旨のflashを表示
      flash('Invalid username or password')
      # loginページへリダイレクト
      return redirect(url_for('login'))
    # loginユーザーとして取得したユーザーの情報を登録
    login_user(user, remember=form.remember_me.data)
    # トップページへリダイレクト
    return redirect(url_for('index'))

  # loginページのテンプレートを返す
  return render_template('login.html', title='Sign In', form=form)
```
 - current_user
  -  ログイン状態のステータスチェックをする
  -  `current_user.is_authenticated`でユーザーがログイン済みかどうかを確認している

htmlのformから送信されるユーザー名(form.username.data)をfilter_byを使ってDBから検索し、取得する<br>
[参照ページ](https://mycodingjp.blogspot.com/2019/03/flask3-flask-login.html)ではfirst()を使ってレコードが存在しない場合にNoneを受け取れるようにしているが、usernameはunique制約が付与されているのでone_or_none()を使う方が良いと個人的には思う<br>
[one_or_none()とfirst()の違いと使い分け](https://www.sukerou.com/2018/12/sqlalchemy-onefirst.html)

 - login_user関数
  -  ユーザーをログイン中ユーザーとして登録する
  -  loginユーザーがどのページに遷移してもユーザーにあてがわれたcurrent_user変数を保持する

#### ログアウト機能の実装
flask-loginのlogout_user関数を使用する<br>
ルーティングファイル(routes.py)に以下のように記述

```py
# logout_userをimport
from flask_login import logout_user

# logoutページのルーティング
@app.route('/logout')
def logout():
  # logout_user関数を呼び出し
  logout_user()
  # トップページにリダイレクト
  return redirect(url_for('index'))
```

#### HTML側のログイン/ログアウトのリンクを切り替える
ログイン/ログアウトのボタンやリンクを持つtemplateファイル(base.html)で以下のように記述

```html
<div>
  <!-- 表題 -->
  Microblog:
  <!-- トップページへのリンク -->
  <a href="{{ url_for('index') }}">Home</a>

  <!-- 現在のユーザーが匿名ユーザーの場合 -->
  {% if current_user.is_anonymous %}
  <!-- loginページへのリンク -->
  <a href="{{ url_for('login') }}">Login</a>

  <!-- その他の(現在のユーザーが有効な権限を保持している)場合 -->
  {% else %}
  <!-- logoutページへのリンク -->
  <a href="{{ url_for('logout') }}">Logout</a>

  <!-- jinja2 templateではendが必要 -->
  {% endif %}

</div>
```

#### ログインを要求する
flask-loginには、ログ位ユーザーにのみ閲覧を許可するページ(protected page)に、未ログインユーザーがアクセスした際に自動的にloginページにリダイレクトさ、ログイン後にのみリダイレクトバックを許可する機能がある<br>
appの初期化をしているファイル(\_\_init__.py)に以下のように記述する

```py
login = LoginManager(app)
login.login_view = 'login'
```

実際にログインを要求するページではルーティングに@login_requiredデコレータを付与する
例) indexページのアクセスにログインを要求する

```py
# login_requiredをimport
from flask_login import login_required

# indexページのルーティング
@app.route('index')
# login_requiredデコレータを付与する
@login_required
def index():
  # 処理
```

#### ログイン後、アクセスしようとしていたページにリダイレクトバックする
現状、未ログインユーザーが@login_requiredデコレータを付与されたページにアクセスするとloginページにリダイレクトされるが、その際、@login_requiredデコレータはクエリ文字列を付加的に保持する<br>
nextクエリ文字列はディクショナリ形式で保持される<br>
例) 未ログインユーザーが、ログインを要求するindexページにアクセスし、loginページにリダイレクトされた時のURL<br>
\~\~/login?next=index

これを利用してリダイレクトバック機能を実装する<br>
ログインページのルーティングを記述しているファイル(routes.py)を以下のように編集

```py
# requestをimport
from flask import request
# url_parseをimport
from werkzeug.urls import url_parse

# loginページのルーティング
@app.route('/login', methods=['GET', 'POST'])
def login():
    # ...
    # wtf? validate_on_submitとは
    if form.validate_on_submit():
      # クライアントから送信された情報に合致するuserレコードをDBから検索
      user = User.query.filter_by(username=form.username.data).one_or_none()
      # 合致するユーザーが存在しないもしくはパスワードが誤っている場合
      if user is None or not user.check_password(form.password.data):
        # flashを表示
        flash('Invalid username or password')
        # loginページにリダイレクト
        return redirect(url_for('login'))
      # 取得したユーザーをloginユーザーとして登録
      login_user(user, remember=form.remember_me.data)
      # 本機能実装前に記述していたトップページへのリダイレクトは不要なので削除する
      # return redirect(url_for('index'))
      # ログイン後の遷移先(アクセスしようとしていたページ)のurlを取得
      next_page = request.args.get('next')
      # 遷移先が存在しない場合もしくはそのurlのnetloc(ファーストレベルのドメイン)がある場合
      if not next_page or url_parse(next_page).netloc != '':
        # トップページにリダイレクト
        next_page = url_for('index')
      # アクセスしようとしていたページにリダイレクトバック
      return redirect(next_page)
    # ...
```

 - netloc
  -  URLにおけるTLD(トップレベルドメイン)を指す([参照ページ](https://wepicks.net/itglossary-tld/))

nextクエリ文字列にnetlocがある場合に、そのURLにアクセスできるようになっていると、悪意のある別のサイトにリダイレクトさせようとする攻撃を受けてしまう可能性がある
そのため、nextクエリ文字列にnetlocがある場合にはリダイレクト先をトップページに指定する

#### ログインユーザーの情報をhtml上で表示する
ログイン状態でアクセスされたページにおいて、ログインユーザーの情報を表示する
ここではトップページ(index.html)で表示するコードを記述する

```html
<!-- 共通テンプレート(base.html)を引き継ぐ -->
{% extends "base.html" %}
<!-- 共通テンプレート(base.html)のcontentブロックに挿入 -->
{% block content %}
  <!-- current_user変数にはログインユーザーのDB上のレコードが入っているので、
      通常DBからデータを取得するのと同じやり方でデータを取得可能 -->
  <!--ログインユーザーのusernameカラムの値を取得 -->
  <h1>Hi, {{ current_user.username }}!</h1>
  <!-- usersテーブルに関連づけられているPostテーブルからデータを取得するのも
      通常の方法で可能-->
  <!-- テーブル同士のリレーション等については本題ではないので割愛する -->
  {% for post in posts %}
  <!-- 各投稿の投稿内容(bodyカラムの値)と投稿ユーザーのusernameカラムの値を取得 -->
  <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
  {% endfor %}
{% endblock %}
```

現時点でDBのマイグレーション等を行っていない場合、pythonシェルを用いてログイン、ログアウト機能のテストを行うことができる

```shell
>>> u = User(username='user', email='user@example.com')
>>> u.set_password('hoge')
>>> db.session.add(u)
>>> db.session.commit()
```

アプリケーションを起動してlocalhost:5000/indexにアクセスすると、ログインページにリダイレクトされ、上記で設定したユーザー情報を使ってログインすると、indexページにてユーザーの情報が取得された状態が確認できる

#### ユーザー登録機能の実装
ユーザーの登録フォームのバリデーション等を設定するファイル(forms.py)を作成する<br>
多くのサイトで、この部分が不透明なまま解説が進み、わかりにくくなっていると感じるので、かなり丁寧に説明していこうと思う

まず、本解説および多くのflask-login解説のページでは、ユーザー登録フォームの実装に`flask-wtf`という拡張機能を使用している<br>
この`flask-wtf`に関する記述がほとんどのflask-loginの解説において欠けている

flask-wtfは、Pythonライブラリの一つであるwtformとflaskの連携(統合)を助ける拡張機能である<br>
wtformは、pyhtonでフォームを作る際の、バリデーションの設定や、CSRF対策をしてくれる<br>
wtformの詳細については割愛するが、興味のある方は[公式ドキュメント](https://wtforms.readthedocs.io/en/2.3.x/)を読んでみてほしい<br>
ここでは、flask-wtfを使った登録フォームの実装について解説する<br>
この部分の解説には[こちらのページ](https://agohack.com/create-form-with-flask-wtf/)に大いに助けられた

__インストール__
```bash
pip install Flask-WTF
```

__フォームの定義__
flask-wtfのFlaskFormクラスを継承するRegistrationFormクラスを定義する

```py
from flask_wtf import FlaskForm

class RegistrationForm(FlaskForm):
```

__各フィールドの定義__
ユーザー登録に必要な情報のフィールドを定義する

```py
フィールド名 = Field名('ラベル', validators=[バリデーションの詳細])

# 例)
username = StringField('Username', validators=[DataRequired()])
```

フィールドの種類は以下の通り

 - BooleanField
 - DecimalField
 - DateField
 - DateTimeField
 - FieldList
 - FloatField
 - FormField
 - IntegerField
 - RadioField
 - SelectField
 - SelectMultipleField
 - StringField
 - TimeField

詳細については[wtformのFieldクラス定義のソースコード](https://wtforms.readthedocs.io/en/stable/_modules/wtforms/fields/core/#Field.__init__)を参照

各フィールドの引数は基本的に2つ<br>
第一引数がフォームのラベル、第二引数がバリデーションである<br>
Fieldクラス自体はもっと多くの引数を持つが、それらをこちら側が設定することは非推奨なので余計な引数が入らないよう注意する

第二引数のバリデーションには配列形式で指定したいバリデーションを格納する

__バリデーションの種類__
バリデーションには以下の種類のものがある
また、カスタムバリデーションの作成も可能([参照ページ](https://wtforms.readthedocs.io/en/stable/validators/#custom-validators))

 - DataRequired()
  -  必須項目
 - Email()
  -  メールアドレスに使える文字のみ入力可
 - Length(min, max)
  -  文字数制限
 - NumberRange(min, max)
  -  数値の範囲制限
  - EqualTo(fieldname)
   -  そのフィールドの入力値が引数にとったフィールドの入力値と一致する

バリデーションエラーが発生した際は、エラーメッセージが表示されるが、それを、カスタマイズすることもできる

```py
usernamme = StringField('Username', validators=[DataRequired(message="この項目は入力が必須です")])
```

Remember me機能も実装できる

```py
remember = BooleanField('Remember me')
```

今回のアプリケーションでは以下のようにRegistrationFormクラスを定義

```py
# flask_wtfからFlaskFormをimport
from flask_wtf import FlaskForm
# wtformからフォームに必要なフィールドをimport
from wtforms import StringField, PasswordField, BooleanField, SubmitField
# wtformからフォームのバリデーションに必要な機能をimport
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
# モデルファイルからUserクラスをimport
from app.models import User
# ...
# FlaskFormクラスを継承するRegistrationFormを定義
class RegistrationForm(FlaskForm):
  username = StringField('Username', validators=[DataRequired()])
  email = StringField('Email', validators=[DataRequired(), Email()])
  password = PasswordField('Password', validators=[DataRequired()])
  password2 = PasswordField(
      'Repeat Password', validators=[DataRequired(), EqualTo('password')])
  submit = SubmitField('Register')

  # このアプリケーションではusernameがuniqueの設定なので、
  # そのusenameが他のユーザーに使われていないか確認する
  def validate_username(self, username):
    # usersテーブルから入力されたusenameを持つレコードを検索
    user = User.query.filter_by(username=username.data).one_or_none()
    # usersテーブルに入力されたものと同じusernameをもつレコードが存在する場合
    if user is not None:
      # バリデーションエラーを返す
      raise ValidationError('Please use a different username.')

  # emailがuniqueの設定なので、
  # そのemailが他のユーザーに使われていないか確認する
  def validate_email(self, email):
    # usersテーブルから入力されたemailを持つレコードを検索
    user = User.query.filter_by(email=email.data).one_or_none()
    # usersテーブルに入力されたものと同じemailをもつレコードが存在する場合
    if user is not None:
      # バリデーションエラーを返す
      raise ValidationError('Please use a different email address.')
```

__ユーザー登録ページのルーティングを設定__
ルーティングを記述しているファイル(routes.py)にユーザー登録ページのルーティングを記述

```py
# ユーザー登録ページのルーティング

@app.route('/register', methods=['GET', 'POST'])
def register():
  # 現在のユーザーが有効な権限を保持している(ログインしている)場合
  if current_user.is_authenticated:
    # トップページにリダイレクト
    return redirect(url_for('index'))
  # forms.pyで定義したRegistrationFormを読み込み
  form = RegistrationForm()
  # 入力値のバリデーションチェックを通過した場合
  if form.validate_on_submit():
    # 入力値を元に新しくUserクラスのインスタンスを作成する
    user = User(username=form.username.data, email=form.email.data)
    # 作成したUserクラスのインスタンスのパスワードをハッシュ化する
    user.set_password(form.password.data)
    # DBのusersテーブルに作成したインスタンスの情報をレコードとして追加
    db.session.add(user)
    # レコードの登録を確定
    db.session.commit()
    # 登録に成功した旨のflashを表示
    flash('Congratulations, you are now a registered user!')
    # loginページにリダイレクト
    return redirect(url_for('login'))
  # ユーザーが未ログインまたはバリデーションエラーの場合、ユーザー登録ページにリダイレクト
  return render_template('register.html', title='Register', form=form)
```

__ユーザー登録ページを作成__
forms.pyで定義したフォームのクラスを使用してユーザー登録ページを作成

```html
<!-- 共通テンプレート(base.html)を引き継ぐ -->
{% extends "base.html" %}
<!-- base.htmlのcontent部分に挿入 -->
{% block content %}
<h1>Register</h1>
<!-- フォームを作成 -->
<form action="" method="post">
  {{ form.hidden_tag() }}
  <!-- 後述するCSRF対策に必要 -->
  <!-- RegistrationFormにHiddenFieldを持たせていない場合は -->
  <!-- {{ form.csrf_token }} -->
  <!-- と記述してもよい -->
  <!-- https://teratail.com/questions/167276 がわかりやすい -->
  <p>
    <!-- usernameのフィールド -->
    <!-- usernameフィールドのラベルを表示 -->
    {{ form.username.label }}<br>
    <!-- usernameの入力エリアを表示 -->
    {{ form.username(size=32) }}<br>
    <!-- usernameフィールドでエラーが発生した場合の処理 -->
    {% for error in form.username.errors %}
    <!-- 赤文字でエラーメッセージを表示 -->
    <span style="color: red;">[{{ error }}]</span>
    {% endfor %}
  </p>
  <p>
    <!-- emailのフィールド -->
    <!-- emailフィールドのラベルを表示 -->
    {{ form.email.label }}<br>
    <!-- emailの入力エリアを表示 -->
    {{ form.email(size=64) }}<br>
    <!-- emailフィールドでエラーが発生した場合の処理 -->
    {% for error in form.email.errors %}
    <!-- 赤文字でエラーメッセージを表示 -->
    <span style="color: red;">[{{ error }}]</span>
    {% endfor %}
  </p>
  <p>
    <!-- passwordのフィールド -->
    <!-- passwordフィールドのラベルを表示 -->
    {{ form.password.label }}<br>
    <!-- passwordの入力エリアを表示 -->
    {{ form.password(size=32) }}<br>
    <!-- passwordフィールドでエラーが発生した場合の処理 -->
    {% for error in form.password.errors %}
    <!-- 赤文字でエラーメッセージを表示 -->
    <span style="color: red;">[{{ error }}]</span>
    {% endfor %}
  </p>
  <p>
    <!-- password2のフィールド -->
    <!-- password2フィールドのラベルを表示 -->
    {{ form.password2.label }}<br>
    <!-- password2の入力エリアを表示 -->
    {{ form.password2(size=32) }}<br>
    <!-- password2フィールドでエラーが発生した場合の処理 -->
    {% for error in form.password2.errors %}
    <!-- 赤文字でエラーメッセージを表示 -->
    <span style="color: red;">[{{ error }}]</span>
    {% endfor %}
  </p>
  <!-- 送信ボタン -->
  <p>{{ form.submit() }}</p>
</form>
{% endblock %}
```

ログインフォームについても同様にコーディングすればよい

__CSRF対策__
[CSRF](https://www.shadan-kun.com/blog/measure/2640/#:~:text=CSRF%E3%81%A8%E3%81%AF%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88,%E3%82%B5%E3%82%A4%E3%83%90%E3%83%BC%E6%94%BB%E6%92%83%E3%81%AE%E4%B8%80%E7%A8%AE%E3%81%A7%E3%81%99%E3%80%82)という攻撃手法がある<br>
これは、悪意のあるユーザーが他のユーザーに成り済ましてデータを送信する手法であり、これを防ぐ機能がwtformには備わっている

この対策を有効にするには、シークレットキーが必要になるので、ここでは、[参照ページ](https://agohack.com/create-form-with-flask-wtf/)で使われている方法を紹介する

デフォルトではFlaskアプリケーション自体のシークレットキーが使用される

```py
WTF_CSRF_ENABLED = True
SECRET_KEY = 'secret key'
# flask-wtfようにシークレットキーを使用するには以下のように記述
# WTF_CSRF_SECRET_KEY= 'wtf secret key'
```

# 参照ページ
[Flask(3) Flask-Loginを使ってログイン管理](https://mycodingjp.blogspot.com/2019/03/flask3-flask-login.html)

[Flask-WTF を使ったフォームの作り方](https://agohack.com/create-form-with-flask-wtf/)

[[flask] form.csrf_token と form.hidden_tag() の違いが分かりません](https://teratail.com/questions/167276)