Flask + SQLAlchemyことはじめ

# はじめに
自分用のWebアプリでFlaskとSQLAlchemyを使う機会があったので、基本部分だけアウトプットします。
今回はUserテーブルでユーザー作成、認証、削除のみ実装します。
割とムリヤリ設計が多いです。

## SQLAlchemyとは
pythonでよく使われているORマッパー。
オブジェクト指向が理解できてれば使えると思います。

## 環境
```
$ python -v
Python 3.11.0
```
``` requirements.txt
Flask==3.0.0
SQLAlchemy==2.0.20

```

## ディレクトリ構造
```
project_folder/
       ├ models/    # SQLAlchemyのモデルを入れる
       ├ static/    # jsやらCSSやらを入れる
       |       └ js/
       |       └ css/
       ├ templates/ # htmlを入れる
       |       └ index.html
       |       └ register.html
       |       └ login.html
       |       └ mypage.html
       └ app.py     # flaskとか書く
       
```
## models.py
まずは、SQLAlchemy用のモデルを作ります。
```python: models.py
from sqlalchemy import create_engine, Column, CHAR, DateTime, INTEGER, VARCHAR
from datetime import datetime
from sqlalchemy.orm import scoped_session, sessionmaker, declarative_base

engine = create_engine("mysql://USERNAME:PASSWORD@HOSTNAME/DB_name") # SQLiteなら"sqlite://ファイルパス"
db_session = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine)) # セッションを作成してトランザクション管理出来るようにします。

Base = declarative_base() # ベースとなるクラスを継承します

class User(Base):
    __tablename__ = "users" # テーブル名
    __table_args__ = {
        "mysql_default_charset": "utf8mb4",
        "mysql_collate": "utf8mb4_general_ci",
    }

    id = Column(INTEGER, primary_key=True, autoincrement=True) # idを主キーに設定、オートインクリメントをオンにします。
    username = Column(VARCHAR(256), nullable=False)
    email = Column(VARCHAR(256), nullable=False)
    hashed_password = Column(CHAR(64), nullable=False) # パスワードをSALTでハッシングするので、SALTとハッシュ済みパスワードを両方保存します。
    salt = Column(CHAR(64), nullable=False)
    createdAt = Column(DateTime, nullable=False, default=datetime.now) # 作成された日付を保存
    updatedAt = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) # updateされた日付を保存

    # 各カラムの初期化設定
    def __init__(self, username=None, email=None, hashed_password=None, salt=None):
    self.username = username
    self.email = email
    self.hashed_password = hashed_password
    self.salt = salt

Base.metadata.create_all(engine)
Base.query = db_session.query_property()
```

## app.py

### SQLAlchemyを保存する際のポイント
Userクラスに`id(オートインクリメント), username, email, hashed_password, new_salt, createdAt(default設定済み), updatedAt(default設定済み)`カラムがあり、ここにデータを保存する場合、
```
User(username, email, hashed_password, new_salt)
```
の形式で記述します。
オートインクリメントや、default値の設定がされているカラムは特段入れたいデータが無い限り設定する必要はありません。
また、クラス記述時に決めたカラムと`User(data, data)`に記述するデータの順番は対応しているので、`User(email, username)`のように書くと`username`カラムに`email`が、`email`カラムに`username`が入ります。

### 認証設計
`メールアドレス`・`ユーザー名`・`パスワード`でユーザーを登録し、`メールアドレス`と`パスワード`を入力してログインする仕様になっています。
また、ユーザー登録時にランダム生成されたSALTキーを含めてハッシュ化する仕様にしています。
ログイン認証時はデータベースに保存された`stored_salt`を用いて復号化します。

`ユーザー名`で認証するシステムでも出来ると思います。

### 実コード

```python:app.py
from models.models import User, db_session
from flask import Flask, render_template, request, session
from hashlib import sha256
import secrets

app = Flask(__name__, static_url_path="/static")
app.secret_key = secrets.token_hex(24)

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = user             # ここと
        email = example@example.com # ここはフォームから取得するなりして置き換えてください
        password = "examplepassword"
        new_salt = secrets.token_hex(32)
        hashed_password = sha256((username + password + new_salt).encode("UTF-8")).hexdigest()

        user = User(username, email, hashed_password, new_salt)
        db_session.add(user)
        db_session.commit()

        session["username"] = username
        session["is_logged_in"] = True

        return redirect("mypage")
            
    return render_template("register.html")

@app.route("/delete", methods=["POST"]) # /deleteにPOSTするとユーザーが削除される
def delete():
    username = session["username"] # ここではセッションに保存されたユーザーを削除する
                                   # GETメソッドを有効にし、フォームから取得したユーザーを削除することも出来る

    if username is not None:
        user = User.query.filter_by(username=username).first()
        db_session.delete(user)
        db_session.commit()

        session.pop("username", None)
        session.pop("is_logged_in", False)

    return redirect("/")

@app.route("/login", methods=["POST", "GET"]) # ログイン
def login():
    if "is_logged_in" in session and session["is_logged_in"] is True:
        return redirect("mypage") # ログイン済みならmypageへ

        if request.method == "POST":
        
            email = "example@example.com"
            user = User.query.filter_by(email=email).first() # Emailでユーザー検索クエリを飛ばす
            if user:
                username = user.username
                password = form.password.data
                stored_salt = user.salt
                hashed_password = sha256((username + password + stored_salt).encode("UTF-8")).hexdigest() # type: ignore
                if user.hashed_password == hashed_password:
                    session["username"] = username
                    session["is_logged_in"] = True
    
                    return redirect("mypage")
                else:
                    return redirect("login") # ログインが失敗したら戻す
                                             # else以下にflashを設定すればエラーメッセージで表示もできる
            else:
                return redirect("login")

    return render_template("login.html")

@app.route("/logout")
def logout():
    session.pop("username", None) # session.popで指定したsessionを削除できる
    session.pop("is_logged_in", False)
    
    return redirect("/")

@app.route("/mypage")
def mypage():
    if "is_logged_in" in session and session["is_logged_in"] is True:
        username = session["username"]
        return render_template("mypage.html", username=username, is_logged_in=True) # jinjaにusernameを渡しているので、html側でユーザー名に応じた条件分岐などができる
    else:
        return redirect("login")

if __name__ == "__main__":
    app.run(debug=True, host="localhost", port=8080)
```

# その他
今回sessionでムリヤリ作りましたが、ユーザー認証やログイン処理などは[Flask_Login](https://flask-login.readthedocs.io/en/latest/)を使ったほうがいいと思います。

https://qiita.com/yuuki-h/items/47135ea7a07d0c994275


HTMLやらCSS、JavaScriptはお好みで書いてください。
Flaskの基本はこちらの記事がおすすめです。

https://qiita.com/usaitoen/items/0184973e9de0ea9011ed

Flaskを利用したWebアプリでのCSRFの実装はこちらをどうぞ。

https://qiita.com/selfnavi/items/edccd17e7aa980d43c0e