Flaskでユーザ認証をさせてみよう
ここまでで、ログイン画面とユーザ情報を保持するデータベースができました。
次は、Flaskでユーザ認証をさせてみましょう。
Flask-Loginモジュール
実際のユーザ認証では次のような処理が必要になります。
パスワードを暗号化するか、URLに非表示にしてPOSTで送信
パスワード認証を行い合否を判定
認証に合格したユーザは、同じセッションの間だけアクセスを有効にする
ログアウトの処理
管理者の視点では次の処理も必要になります。
ユーザの追加
ユーザの削除
Flaskはマイクロ・フレームワークなので自由度は高いのですが、
なかなか結構な処理をプログラムしていくことになります。
ユーザ認証処理では、簡単に実装することができるFlask-Login 拡張モジュールが使えます。
インストール
インストールは pip で行います。
code: bash
$ pip install flask-login
準備
code: bash
$ mkdir authdemo
$ cd authdemo
$ mkdir app templates
flask-login の初期化
flask-login を初期化するためには、次のようなコードになります。
code: python
import flask
import flask_login
app = flask.Flask(__name__)
app.secret_key = 'K24rMLtDxlbW_PLoQQrAwg'
login_manager = flask_login.LoginManager()
login_manager.init_app(app)
secret_key はクライアント側のセッションを安全に保つために必要になります。
次のコードでランダムなキーを生成できます。
code: bash
$ python3 -c "import uuid; print(uuid.uuid4().hex)"
309fedfcc2774c0889d9b4708b47f3d9
code: bash
$ python3 -c "import secrets; print(secrets.token_urlsafe(24))"
gDtvrPZsqmHp1XAbUJFV0kfvE4OGE5Ae
このシークレットキーは、設定ファイルとして記述するようにします。
code:apps/config.py
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'K24rMLtDxlbW_PLoQQrAwg'
#
DATABASE_NAME = os.environ.get('DATABASE_NAME') or 'app.db'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, DATABASE_NAME)
SQLALCHEMY_TRACK_MODIFICATIONS = False
Flaskの初期化処理
ここまでの設定を apps/__init__.py に保存します。
code: apps/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
import flask_login
from .config import Config
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login_manager = flask_login.LoginManager()
login_manager.init_app(app)
from apps import views, models
パスワードの処理
データベースに保存しているといえ、ユーザのパスワードをそのまま保存することは避けるべきです。こうした目的で、データベースシステム側でパスワード文字列を暗号化するSQLコマンドもありますが、データベースシステムが変更されることを考えるとアプリケーション側で実装する方が柔軟性が高くなります。Python でも使いやすいライブラリがいくつかあります。
Flaskの依存モジュールとして一緒にインストールされる Werkzeug に、パスワード暗号化に使える関数があります。
code: Python
In 2: # %load password_hash.py ...: from werkzeug.security import generate_password_hash, check_password_hash
...: hash = generate_password_hash('Python')
...: print(hash)
...:
...: check = check_password_hash(hash, 'python')
...: print(check)
...: check = check_password_hash(hash, 'Python')
...: print(check)
...:
pbkdf2:sha256:150000$7E4hyQLD$70a2fc8b46bd357a240e25b3ccbd868928995728efbf10c22ba90f765c912539
False
True
generate_password_hash() でハッシュ化した文字列をデータベースに保存しておき、
ユーザから入力された文字列をgenerate_password_hash()で処理して比較することでパスワード認証ができます。
code: apps/modules.py
from app import db
from werkzeug.security import generate_password_hash, check_password_hash
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(16), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True)
password = db.Column(db.String(128))
def __repr__(self):
return f'<User {self.username}>'
def set_password(self, password):
self.password_hash = generate_password_hash(password)
return self.password_hash
def check_password(self, password):
return check_password_hash(self.password_hash, password)
code: IPython
In 1: from apps.models import User In 2: user = User(username='python') In 3: user.set_password('think_big!') In 4: user.check_password('think_big!') Out4: True In 5: user.check_password('think_big') Out5: False Userモデル
Flask-Login拡張機能は、アプリケーションのクラスUserが定義され、特定のプロパティとメソッドがアプリケーションに実装されることを期待しています。
is_authenticated:
ユーザーが有効な資格情報を持っている場合はTrue、そうでない場合はFalse
is_active:
ユーザーのアカウントがアクティブな場合はTrue、それ以外の場合はFalse
is_anonymous:
通常のユーザーの場合はFalse、ゲストユーザーの場合はTrue
get_id():
ユーザーで重複しない識別子を文字列として返す
これらは簡単に実装できるのですが、あまりに一般的なため flask_login.UserMixinクラスを継承するだけで済みます。
Flask-Loginには必要な項目はこの4つだけです。これにより、任意のデータベースシステムと連携したユーザーモデルも簡単に実装することができます。
code: apps/models.py
from app import db
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
class User(UsdrMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(16), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True)
password = db.Column(db.String(128))
def __repr__(self):
return f'<User {self.username}>'
def set_password(self, password):
self.password = generate_password_hash(password)
def check_password(self, password):
return check_password(self.password, password)
コールバック関数の定義
Flask-Login が呼び出すコールバック関数 loader_user() を定義しておく必要があります。
(未定義だとエラーになります)
このコールバック関数は、セッションに保存されているユーザーIDからユーザーオブジェクトを読み込みするために呼び出されます。
この関数がNoneを返すとユーザIDに該当するユーザが存在しないものとFlaskは判断します。
Flask-Loginは、アプリケーションに接続するユーザーごとに確保される領域ユーザーセッションに、ユーザIDを保存することでログインしたユーザーを追跡します。 ログインしたユーザーが新しいページに移動するたびに、Flask-LoginはセッションからユーザーのIDを取得し、そのUserモデルをメモリに読み込みます。
Flask-Loginはloader_user() を呼ぶだけなので、情報がどこにあって、どう読み取るかについてはプログラムする必要があります。
この関数は、引数にIDを受け取り、指定したユーザーを返すものです。
apps/models.pyモジュールに追加しておきます。
code: apps/models.py
from app import db, login
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(16), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True)
password = db.Column(db.String(128))
def __repr__(self):
return f'<User {self.username}>'
def set_password(self, password):
self.password = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password, password)
@login.user_loader
def load_user(id):
return User.query.get(int(id))
Flask-Loginがload_user()関数に引数として渡すIDは文字列なので、データベースでIDを数値データとして保存されているような場合は、変換することを忘れないでください。
ログイン処理
code: apps/views.py
from flask import flash, redirect, url_for
from flask_login import current_user, login_user
from app import app
from app.models import User
from app.forms import LoginForm
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user)
return redirect(url_for('index'))
return render_template('login.html', title='Sign In', form=form)
current_user.is_authenticated はログインしたユーザが再度ログインをするリンクをクリックしたときに、ログイン画面を表示させないために使用しています。
UserモデルはSQLAlchemyのデータベースモデル(db.Model)を継承しているため、
データベースアクセスできるqueryオブジェクトを持っているので、filter_by()メソッドを使用することができます。(User.query.filter_by())
username は重複しないので、filter_by()の結果は1つ、もしくはゼロ個のユーザオブジェクトとなるので、first() メソッドで取り出しています。
取得できたユーザオブジェクトのcheck_password()メソッドで、ユーザが入力したパスワードと比較して合否を判定します。
有効なユーザであるとき、login_user(user) で登録をして、/index にリダイレクトしています。
ログアウト処理
ユーザがアプリケーションからログアウトするときには、logout_user() を呼び出して、ログアウトしたことをFlaskに登録します。
code: apps/views.py
from flask import flash, redirect, url_for
from flask_login import current_user, login_user, logout_user
from app import app
from app.models import User
from app.forms import LoginForm
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user)
return redirect(url_for('index'))
return render_template('login.html', title='Sign In', form=form)
@app.route('/logut')
def logout():
logout_user()
return redirect(url_for('index'))
テンプレート
まだログインしていない’ユーザにはLogin、ログインしているユーザにはLogoutとなるようにナビゲーションメニューを修正してみます。
code: templates/base.html
<html>
<head>
{% if title %}
<title>{{ title }} - Flask Example </title>
{% else %}
<title>Flask Example</title>
{% endif %}
</head>
<body>
<div>
Navigation:
<a href="{{ url_for('index') }}">Home</a>
{% if current_user.is_anonymous %}
<a href="{{ url_for('login') }}">Login</a>
{% else %}
<a href="{{ url_for('logout') }}">Logout</a>
{% endif %}
</div>
<hr>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</body>
</html>
code: templates/index.html
{% extends "base.html" %}
{% block content %}
<h1>Hello {{ current_user.username }}!</h1>
{% endblock %}
ログイン要求
アプリケーションの特定のページを表示するには、ユーザにログイン要求をしたいときがあります。ログインしていないユーザーが保護されたページを表示しようとすると、Flask-Loginはユーザーをログインフォームに自動的にリダイレクトし、ログインプロセスが完了した後でユーザーが表示したいページにのみリダイレクトします。
ログイン画面を表示させるビュー関数を、次のようにFlask-Login に登録します。
code: app/__init__.py からの抜粋
login_manager = LoginManager(app)
login_manager.login_view = 'login'
@login_required でビュー関数をデコレートすると、そのページはログインしたユーザにしか表示されなくなります。
code: app/routs.py からの抜粋
from flask_login import login_required
@app.route('/')
@app.route('/index')
@login_required
def index():
# ...
ここまでのファイルの確認
code: apps/config.py
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'K24rMLtDxlbW_PLoQQrAwg'
#
DATABASE_NAME = os.environ.get('DATABASE_NAME') or 'app.db'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, DATABASE_NAME)
SQLALCHEMY_TRACK_MODIFICATIONS = False
code: apps/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
import flask_login
from app.config import Config
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login_manager = flask_login.LoginManager()
login_manager.init_app(app)
from app import views, models
code: apps/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired
class LoginForm(FlaskForm):
submit = SubmitField('Sign In')
code: apps/models.py
from app import db, login_manager
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(16), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True)
password = db.Column(db.String(128))
def __repr__(self):
return f'<User {self.username}>'
def set_password(self, password):
self.password = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password, password)
@login_manager.user_loader
def load_user(id):
return User.query.get(int(id))
code: apps/views.py
from flask import flash, redirect, url_for
from flask_login import (
current_user, login_user, logout_user, login_required
)
from app import app
from app.models import User
from app.forms import LoginForm
@app.route('/')
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user)
return redirect(url_for('index'))
return render_template('login.html', title='Sign In', form=form)
@app.route('/logut')
def logout():
logout_user()
return redirect(url_for('index'))
@app.route('/index')
@login_required
def index():
return render_template('index.html', title='Sign In')
code: templates/base.html
<html>
<head>
{% if title %}
<title>{{ title }} - Flask Example </title>
{% else %}
<title>Flask Example</title>
{% endif %}
</head>
<body>
<div>
Navigation:
<a href="{{ url_for('index') }}">Home</a>
{% if current_user.is_anonymous %}
<a href="{{ url_for('login') }}">Login</a>
{% else %}
<a href="{{ url_for('logout') }}">Logout</a>
{% endif %}
</div>
<hr>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</body>
</html>
code: templates/login.html
{% extends "base.html" %}
{% block content %}
<h1>Sign In</h1>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}<br>
{% for error in form.username.errors %}
<span style="color: red;">error }}</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=32) }}<br>
{% for error in form.password.errors %}
<span style="color: red;">error }}</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
{% endblock %}
code: templates/index.html
{% extends "base.html" %}
{% block content %}
<h1>Hello {{ current_user.username }}!</h1>
{% endblock %}
データベースの作成
まず、次の3つのコマンドでデータベースを作成します。
code: bash
$ flask db init
$ flask db migrate -m "users table"
$ flask db upgrade
ユーザをデータベースに登録しましょう。
code: bash
$ flask shell
Python 3.6.9 |Anaconda, Inc.| (default, Jul 30 2019, 13:42:17)
IPython: 7.13.0
Instance: /Users/goichiiisaka/Downloads/Python.Osaka/WebApplicationClass/flask/authdemo/instance
In 1: user = User(username='freddie', email='freddie@example.com') In 2: user.set_password('Queen') In 3: db.session.add(user) In 4: db.session.commit() 動作テスト
さてflaskを起動してみましょう。
code: Python
$ flask run
余談
この例では、ビュー関数やURLディスパッチは ファイルviews.pyに登録しています。
FlaskではMTVモデルを採用していて、Rails などで採用されているMVCモデルとは
「ビュー(View)」という言葉の意味が異なります。
誤解をさける意味でファイル名を routes.py とする人もいます。
参考: