Flask ログイン機能 CRUDとセットで実装してみた

Flaskでログイン機能を使ってみたいな、と思い、
CRUDアプリに追加する形で作成しました！！
CRUDは以下の記事を参考にしています。

https://qiita.com/Yu_unI1/items/316e03d94f276695ff13

# 手順
1. [パッケージの準備](#パッケージの準備)
1. [PythonにおけるDBの準備](#pythonにおけるdbの準備)
1. [サインアップ機能](#サインアップ機能)
1. [ログイン機能](#ログイン機能)
1. [ログアウト機能](#ログアウト機能)
1. [できることリスト（宿題的ななにか）](#できることリスト)
1. [コードの全容](#コードの全容)

# パッケージの準備
今回、[CRUDアプリを作成](https://qiita.com/Yu_unI1/items/316e03d94f276695ff13)しているので、その後、新たに必要となるパッケージをAnacondaでインストールします。
```zsh
% conda install flask-login
```
CRUDの記事では、pipを使用していますが
flask関連は基本的にAnacondaでインストールできます！

# PythonにおけるDBの準備
```diff_python:app.py
from flask import Flask, redirect,render_template, request
from flask_sqlalchemy import SQLAlchemy
+ from flask_login import LoginManager, UserMixin
+ import os

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'
+ app.config['SECRET_KEY'] = os.urandom(24)
db = SQLAlchemy(app)
+ login_manager = LoginManager()
+ login_manager.init_app(app)

class Tweet(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(20), nullable=False)
    body = db.Column(db.String(140), nullable=False)

+ class User(UserMixin, db.Model):
+ 	id = db.Column(db.Integer, primary_key=True)
+ 	username = db.Column(db.String(50), nullable=False, unique=True)
+ 	password = db.Column(db.String(25))

+ @login_manager.user_loader
+ def load_user(user_id):
+     return User.query.get(int(user_id))
```
色々気になる箇所があると思いますが、順に解説します〜！
CRUDからの変更箇所はハイライトしてあります！

## 秘密鍵の登録
`app.config['SECRET_KEY'] = os.urandom(24)`
現在ユーザーがログイン中なのか、ログアウト状態なのかなどのセッション情報を管理するため、Flaskアプリのコンフィグレーションに秘密鍵(SECRET_KEY)を定義します。

## ログインマネージャー
```python
login_manager = LoginManager()
login_manager.init_app(app)
```
Flask-loginを使用する上で、最も重要（活躍する）なClass、
`LoginManager`を使っています。
>ログインマネージャには、IDからユーザを読み込む方法、ログインが必要なときにユーザをどこに送るかなど、アプリケーションとFlask-Loginを連携させるためのコードが含まれています。
[Flask-login公式](https://flask-login.readthedocs.io/en/latest/#configuring-your-application)より

色々ありますが、要は色々と便利な仕事してくれるから定義しようね！くらいでOKです！
（詳しく知りたい方はURL方覗いてみましょう）

また、`login_manager.init_app(app)`では、アプリのインスタンス変数を用いて
ログイン周りの初期化を行なってくれます。

## Userクラス
```python
class User(UserMixin, db.Model):
	id = db.Column(db.Integer, primary_key=True)
	username = db.Column(db.String(50), nullable=False, unique=True)
	password = db.Column(db.String(25))
```
若干Railsのモデル周りと似ていますが、DBに関しては
Flask-alchemyと同様に、Classで定義します。
このClassは、以下の図と同じ意味です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2017749/2ccda31d-ec5f-9e18-78fc-ffc76e4f1c2c.png)

言語化すると、以下のような状態です。
- ID         主キーの整数
- ユーザ名    50文字までの文字列型、値は必ず入る、DB内で唯一である必要あり
- パスワード  25文字までの文字列型

## load_user
```python
@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))
```
flask-loginでは、セッションに保存されているユーザIDから
ユーザオブジェクトをリロードするために使用されるので
上記のようにload_userを定義しています！

## DBをコマンドで作成
以下のコマンドを実行してDB作成しましょう！
CRUDの時と同じですね！
```zsh:ターミナル
% python
>>> from app import db
>>> db.create_all()
>>> exit()
```
面倒だなぁ、と思う人はシェルスクリプトを作ってみると良いですね！
例はこちらです🙌

```sh:create_db.sh
#!/bin/sh

python -c "from app import db; db.create_all()"
```
先ほどのコマンドを`./create_db.sh`と
ターミナルに打つだけで実行できるようにしたものです！
（もっと効率化したい場合は、Makefileという手段もあります✨）

# サインアップ機能
準備が長々とありましたが、ここからが本番です🔥
早速コードをお見せします！
```diff_python:app.py
from flask import Flask, redirect,render_template, request
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin
+ from werkzeug.security import generate_password_hash
import os

# ~~~importからdeleteの後まで変更がないので、省略~~~
@app.route('/tweets/<int:id>/delete',methods=['GET'])
def delete(id):
    tweet = Tweet.query.get(id)
    #投稿を削除
    db.session.delete(tweet)
    #削除を反映
    db.session.commit()
    return redirect('/')

+ @app.route('/signup', methods=['GET', 'POST'])
+ def signup():
+     if request.method == "POST":
+         username = request.form.get('username')
+         password = request.form.get('password')
+         # Userのインスタンスを作成
+         user = User(username=username, password=generate_password_hash(password, method='sha256'))
+         db.session.add(user)
+         db.session.commit()
+         return redirect('login')
+     else:
+         return render_template('signup.html')
```
htmlのページは`templates`配下に新規作成！
```html:signup.html
{% extends 'application.html' %}
{% block content %}
<h1>ユーザ登録</h1>
<form method="POST">
    <label for="">ユーザ名</label>
    <input type="text" name="username">
    <label for="">パスワード</label>
    <input type="password" name="password">
    <input type="submit" value="新規登録">
</form>
{% endblock %}
```

signup関数はPOSTメソッドがリクエストされた時、ユーザを新規登録する関数です。
以下に、解説を載せます！

## パスワードの扱い
今回、パスワードはそのまま保存するのではなく、
SHA256というハッシュ関数を利用してハッシュ値として保存しています。
SHA256は簡易的なブロックチェーンの実装にも利用されることがありますが、
SHA256について気になる方は、以下のサイトを見てみると良いかもしれません！

https://wa3.i-3-i.info/word15997.html

## DBの動き
DBの動きは、CRUDの時と同じですが
今回は、ユーザの情報をセッションに追加し
問題なければ、トランザクションをコミットするようになっています。


# ログイン機能
```diff_python:app.py
from flask import Flask, redirect,render_template, request
from flask_sqlalchemy import SQLAlchemy
- from flask_login import LoginManager, UserMixin
+ from flask_login import LoginManager, UserMixin, login_user
- from werkzeug.security import generate_password_hash
+ from werkzeug.security import generate_password_hash, check_password_hash

# ~~~importからsignupの後まで変更がないので、省略~~~
@app.route('/signup', methods=['GET', 'POST'])
def signup():
    if request.method == "POST":
        username = request.form.get('username')
        password = request.form.get('password')
        # Userのインスタンスを作成
        user = User(username=username, password=generate_password_hash(password, method='sha256'))
        db.session.add(user)
        db.session.commit()
        return redirect('login')
    else:
        return render_template('signup.html')

+ @app.route('/login', methods=['GET', 'POST'])
+ def login():
+     if request.method == "POST":
+         username = request.form.get('username')
+         password = request.form.get('password')
+         # Userテーブルからusernameに一致するユーザを取得
+         user = User.query.filter_by(username=username).first()
+         if check_password_hash(user.password, password):
+             login_user(user)
+             return redirect('/tweets')
+     else:
+         return render_template('login.html')
```
`signup`関数と流れはほとんど同じですが
若干注意点を以下にまとめています！

## ユーザの取得方法
今回、ユーザはDBにある中で、名前を検索(`filter_by`)してヒットしたものの中で
一番上(`first`)にあるものを取得しています。
今回のDBでは、ユーザ名は主キーかつユニークなので
名前で取得しても問題はありません🙌

## パスワードの検証
今回、パスワードの扱いとして、ハッシュ値で保存する形式を取っています。
そのため、`check_password_hash`では入力として受け取ったパスワードを一度
ハッシュ値に変換後、DBから取得したユーザのパスワード（ハッシュ値）を比較します。
ハッシュ値が一致すれば、ログインできます✨

# ログアウト機能
```diff_python:app.py
from flask import Flask, redirect,render_template, request
from flask_sqlalchemy import SQLAlchemy
- from flask_login import LoginManager, UserMixin, login_user
+ from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required
from werkzeug.security import generate_password_hash, check_password_hash
import os

# ~~~importからloginの後まで変更がないので、省略~~~
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == "POST":
        username = request.form.get('username')
        password = request.form.get('password')
        # Userテーブルからusernameに一致するユーザを取得
        user = User.query.filter_by(username=username).first()
        if check_password_hash(user.password, password):
            login_user(user)
            return redirect('/tweets')
    else:
        return render_template('login.html')

+ @app.route('/logout')
+ @login_required
+ def logout():
+     logout_user()
+     return redirect('login')
```

## login_required
`login_required`は、ログインを必要とする関数の前に、
ルーティングと同じ感覚でつけることで、制限をつけることができます。
つまり、ログインしていないと実行できない機能を作ることに役立ちます✊

また、`logout_user`で、ログインの時と同様に
flask-loginがよしなにログアウトしてくれます！

# できることリスト
ここまで色々とやってきましたが
記事が長くなってきたので
やってみると面白いかもな〜というリストを載せておきます！

実際に私が実装できたものを載せているので
トライしてみてください🔥
1. DBのセッション・コミットに関するエラーハンドリング（初級）
1. フラッシュを活用したアラート機能（初級）
1. 投稿に作成時間・更新時間を追加する（中級）
1. 投稿の編集・削除を投稿したユーザだけに限定する機能（中級〜上級）

# コードの全容
```python:app.py
from flask import Flask, redirect,render_template, request
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required
from werkzeug.security import generate_password_hash, check_password_hash
import os

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'
app.config['SECRET_KEY'] = os.urandom(24)
db = SQLAlchemy(app)
login_manager = LoginManager()
login_manager.init_app(app)

class Tweet(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(20), nullable=False)
    body = db.Column(db.String(140), nullable=False)

class User(UserMixin, db.Model):
	id = db.Column(db.Integer, primary_key=True)
	username = db.Column(db.String(50), nullable=False, unique=True)
	password = db.Column(db.String(25))

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

@app.route('/')
def top():
    return render_template('top.html')

@app.route('/tweets')
def index():
    tweets = Tweet.query.all()
    return render_template('tweets/index.html', tweets=tweets)

@app.route('/tweets/new',methods=['GET','POST'])
def create():
    if request.method == 'POST':
        # POSTメソッドの時の処理。
        title = request.form.get('title')
        body = request.form.get('body')
        tweet = Tweet(title=title,body=body)
        # DBに値を送り保存する
        db.session.add(tweet)
        db.session.commit()
        return redirect('/')
    else:
        # GETメソッドの時の処理
        return render_template('tweets/new.html')

@app.route('/tweets/<int:id>/edit',methods=['GET','POST'])
def update(id):
    tweet = Tweet.query.get(id)
    if request.method == 'GET':
        return render_template('tweets/edit.html',tweet=tweet)
    else:
        tweet.title = request.form.get('title')
        tweet.body = request.form.get('body')
        db.session.commit()
        return redirect('/')

@app.route('/tweets/<int:id>/delete',methods=['GET'])
def delete(id):
    tweet = Tweet.query.get(id)
    #投稿を削除
    db.session.delete(tweet)
    #削除を反映
    db.session.commit()
    return redirect('/')

@app.route('/signup', methods=['GET', 'POST'])
def signup():
    if request.method == "POST":
        username = request.form.get('username')
        password = request.form.get('password')
        # Userのインスタンスを作成
        user = User(username=username, password=generate_password_hash(password, method='sha256'))
        db.session.add(user)
        db.session.commit()
        return redirect('login')
    else:
        return render_template('signup.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == "POST":
        username = request.form.get('username')
        password = request.form.get('password')
        # Userテーブルからusernameに一致するユーザを取得
        user = User.query.filter_by(username=username).first()
        if check_password_hash(user.password, password):
            login_user(user)
            return redirect('/tweets')
    else:
        return render_template('login.html')

@app.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect('login')

if __name__ == '__main__':
    app.run(debug=True)
```

# 参考文献

https://qiita.com/Yu_unI1/items/316e03d94f276695ff13

https://flask-login.readthedocs.io/en/latest/

https://wa3.i-3-i.info/word15997.html