Python + Flask を使ったWebアプリ作成④(ログイン認証編)











続きです。
今回はログイン認証の実装をします。


*参考



*環境

  • MacOS
  • Python 3.6.3
  • Flask 1.0.2
  • Flask-Bcrypt 0.7.1
  • Flask-Login 0.4.1


*flask-bcrypt とは

パスワードをハッシュ化するために用いられる、Flask拡張です。
パスワードを安全に保管するために、元データの数値をランダムな固定長の値に変換します。ハッシュ値は不可逆変換なので元の値に戻すことはできず、元データを隠した状態で保管しておくことができます。
一般的に使われるログイン認証ではこうして変換されたハッシュ値を保管しておき、ログインする際に入力したパスワードとハッシュ値を比較して認証しています。
このハッシュ値の生成に使われるのがハッシュ関数で、そのひとつが今回使うbcryptです。


*flask-bcrypt の使い方

ターミナルで下記コマンドを実行してインストールします。
$ pip install flask-bcrypt

試しに動作確認をしてみます。
ターミナルからPython環境に入って実行してみます。
generate_password_hash()を実行するとハッシュ値が生成されます。実行するたびに異なるハッシュ値が生成されます。
$ python
>>> from flask_bcrypt import Bcrypt
>>> bcrypt = Bcrypt()
>>> bcrypt.generate_password_hash('testing')
b'$2b$12$3filnPcJroJ.TcSPVKZtzuG4nKUibqtsfgVJKo.vlOfT10GBgSBF2'

>>> bcrypt.generate_password_hash('testing').decode('utf-8')
'$2b$12$JDp0nJ/aug5nwuLZ/i1e/ug.kXGwNXhbsFwhl4mpfpNtnEqxm1KA6'

>>> bcrypt.generate_password_hash('testing').decode('utf-8')
'$2b$12$SyDFg5XWkcnT6UmjCmbhwOEtIB8Y9A8TCOA4pqUitZyrEd3KSGxgq'

check_password_hash()を使うとハッシュ値が指定した文字列のものと一致しているか判定することができます。
>>> hash_pw = bcrypt.generate_password_hash('testing').decode('utf-8')
>>> bcrypt.check_password_hash(hash_pw, 'password')
False
>>> bcrypt.check_password_hash(hash_pw, 'testing')
True


*ハッシュ値の生成

__init__.pyBcryptを追加します。


<flaskblog/init.py>
from flask import Flask  
from flask_sqlalchemy import SQLAlchemy  
from flask_bcrypt import Bcrypt  <-- 追加
  
app = Flask(__name__)  
  
app.config['SECRET_KEY'] = 'cfb33786023cc152019e747a051f73c6'  
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'  
db = SQLAlchemy(app)  
bcrypt = Bcrypt(app)   <-- 追加
  
from flaskblog import routes

routes.pyに下記を追加します。
アカウント登録時にハッシュ値を生成してDBに保存する処理を追加し、ログイン時にハッシュ値が正しいかの認証処理を追加します。

<flaskblog/routes.py>
from flask import render_template, url_for, flash, redirect, request   <-- requestを追加
from flaskblog import app, db, bcrypt   <-- bcryptを追加
from flaskblog.forms import RegistrationForm, LoginForm
from flaskblog.models import User, Post


posts = [
    {
        'author': 'Corey Schafer',
        'title': 'Blog Post 1',
        'content': 'First post content',
        'date_posted': 'April 20, 2018'
    },
    {
        'author': 'Jane Doe',
        'title': 'Blog Post 2',
        'content': 'Second post content',
        'date_posted': 'April 21, 2018'
    }
]


@app.route('/')
@app.route('/home')
def home():
    return render_template('home.html', posts=posts)


@app.route('/about')
def about():
    return render_template('about.html', title='About')


@app.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        # ----↓ 追加ここから ----
        hashed_password = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
        user = User(username=form.username.data, email=form.email.data, password=hashed_password)
        db.session.add(user)
        db.session.commit()
        flash('Your account has been created! You are now able to log in', 'success')
        return redirect(url_for('login'))
        # ----↑ 追加ここまで ----
    return render_template('register.html', title='Register', form=form)


*Flask-Login とは

ユーザーセッション管理を便利に扱うためのFlask拡張です。
デコレーターを使うことで、ログイン、ログアウト、セッションを簡単に記憶しておくことができます。

下記コマンドを実行してインストールします。
$ pip install flask-login


*ログイン処理を追加

LoginManager()でインスタンスを作成し、login_viewにログインページのviewを指定します。
<flaskblog/__init__.py>
from flask import Flask  
from flask_sqlalchemy import SQLAlchemy  
from flask_bcrypt import Bcrypt  
from flask_login import LoginManager  <-- 追加
  
app = Flask(__name__)  
  
app.config['SECRET_KEY'] = 'cfb33786023cc152019e747a051f73c6'  
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'  
db = SQLAlchemy(app)  
bcrypt = Bcrypt(app)  
# ---- 追加ここから ----
login_manager = LoginManager(app)
login_manager.login_view = 'login'
login_manager.login_message_category = 'info'
# ---- 追加ここまで ----
  
from flaskblog import routes

models.pyのUserクラスにUserMixinを継承させます。
次にuser_loaderのデコレーターを付与したメソッドを作成し、ログイン時にユーザー情報を取得する方法を実装します。

<flaskblog/models.py>
from datetime import datetime  
from flaskblog import db, login_manager  
from flask_login import UserMixin   <-- 追加
  

# ---- 追加 ----
@login_manager.user_loader  
def load_user(user_id):  
    return User.query.get(int(user_id))  
  

# ---- UserMixinを引数に追加 ----
class User(db.Model, UserMixin):  
    id = db.Column(db.Integer, primary_key=True)  
    username = db.Column(db.String(20), unique=True, nullable=False)  
    email = db.Column(db.String(120), unique=True, nullable=False)  
    image_file = db.Column(db.String(20), nullable=False, default='default.jpg')  
    password = db.Column(db.String(60), nullable=False)  
    posts = db.relationship('Post', backref='author', lazy=True)  
  
    def __repr__(self):  
        return "User('{}', '{}', '{}')".format(self.username, self.email, self.image_file)  
  
  
class Post(db.Model):  
    id = db.Column(db.Integer, primary_key=True)  
    title = db.Column(db.String(100), nullable=False)  
    date_posted = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)  
    content = db.Column(db.Text, nullable=False)  
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)  
  
    def __repr__(self):  
        return "Post('{}', '{}')".format(self.title, self.date_posted)


routes.pyにログイン時に認証をハッシュ値を判定する処理を追加します。
認証が正しければlogin_user()でログイン済みのユーザーとして記録し、logout_user()でログアウト済みのユーザーとして記録します。
認証した場合にしか表示させたくないページには@login_requiredのデコレータを付与します。
こうすることでアクセスしようとするとログインページが表示され、認証後にそのページに遷移させることができます。
アクセスしようとしていた次のページは、セッションの'next'キーで取得することができます。

<flaskblog/routes.py>
from flask import render_template, url_for, flash, redirect, request
from flaskblog import app, db, bcrypt
from flaskblog.forms import RegistrationForm, LoginForm
from flaskblog.models import User, Post
from flask_login import login_user, current_user, logout_user, login_required  <-- 追加


@app.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        # ----↓ 追加ここから ----
        user = User.query.filter_by(email=form.email.data).first()
        if user and bcrypt.check_password_hash(user.password, form.password.data):
            login_user(user, remember=form.remember.data)
            next_page = request.args.get('next')
            return redirect(next_page) if next_page else redirect(url_for('home'))
        # ----↑ 追加ここまで ----
        else:
            flash('Login Unsuccessful. Please check username and password', 'danger')
    return render_template('login.html', title='Login', form=form)
  

# ----↓ 追加ここから ----
@app.route('/logout')  
def logout():  
    logout_user()  
    return redirect(url_for('home'))  
  
  
@app.route('/account')  
@login_required  
def account():  
    return render_template('account.html', title='Account')
# ----↑ 追加ここまで ----


*登録済アカウントのチェックを追加

アカウント登録時、既に登録済みのアカウントはエラーにする必要があります。
FlaskFormではvalidate_ + フィールド名のクラスメソッドを作ると、validate_on_submit()をした際に実行してくれるので、forms.pyRegistrationFormクラスにユーザー名とパスワードのバリデーションチェックメソッドを追加します。

<flaskblog/forms.py>
from flask_wtf import FlaskForm  
from wtforms import StringField, PasswordField, SubmitField, BooleanField  
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError  
from flaskblog.models import User  
  
  
class RegistrationForm(FlaskForm):  
    username = StringField('Username',  
                           validators=[DataRequired(), Length(min=2, max=20)])  
    email = StringField('Email',  
                        validators=[DataRequired(), Email()])  
    password = PasswordField('Password', validators=[DataRequired()])  
    confirm_password = PasswordField('Confirm Password',  
                                     validators=[DataRequired(), EqualTo('password')])  
    submit = SubmitField('Sign Up')  
  
 # ---- 追加ここから ----
    def validate_username(self, username):  
        user = User.query.filter_by(username=username.data).first()  
        if user:  
            raise ValidationError('That username is taken. Please choose a different one.')  
  
    def validate_email(self, email):  
        email = User.query.filter_by(email=email.data).first()  
        if email:  
            raise ValidationError('That email is taken. Please choose a different one.')  
 # ---- 追加ここまで ----
  
  
class LoginForm(FlaskForm):  
    email = StringField('Email',  
                        validators=[DataRequired(), Email()])  
    password = PasswordField('Password', validators=[DataRequired()])  
    remember = BooleanField('Remember me')  
    submit = SubmitField('Login')


*アカウント名の表示ページを追加

ログインしている際に、アカウント名を表示するページを作成します。
template配下にaccount.html新規作成します。

<flaskblog/template/account.html>
{% extends "layout.html" %}  
{% block content %}  
    <h1>{{ current_user.username }}</h1>  
{% endblock content %}

Navberにアカウント名を表示するためのリンクを追加します。
<flaskblog/template/layout.html>
<!DOCTYPE html>  
<html lang="en">  
<head>  
    <!-- Required meta tags -->  
  <meta charset="utf-8">  
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">  
  
    <!-- Bootstrap CSS -->  
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">  
    <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='main.css') }}">  
  
    <meta charset="UTF-8">  
    {% if title %}  
    <title>Flask Blog - {{ title }}</title>  
    {% else %}  
    <title>Flask Blog</title>  
    {% endif %}  
</head>  
<body>  
    <header class="site-header">  
        <nav class="navbar navbar-expand-md navbar-dark bg-steel fixed-top">  
            <div class="container">  
                <a class="navbar-brand mr-4" href="/">Flask Blog</a>  
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarToggle" aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">  
                    <span class="navbar-toggler-icon"></span>  
                </button>  
                <div class="collapse navbar-collapse" id="navbarToggle">  
                    <div class="navbar-nav mr-auto">  
                        <a class="nav-item nav-link" href="{{ url_for('home') }}">Home</a>  
                        <a class="nav-item nav-link" href="{{ url_for('about') }}">About</a>  
                    </div>  
                    <!-- Navbar Right Side -->  
  <div class="navbar-nav">  
                        <!-- ↓修正ここから -->  
                        {% if current_user.is_authenticated %}  
                            <a class="nav-item nav-link" href="{{ url_for('account') }}">Account</a>  
                            <a class="nav-item nav-link" href="{{ url_for('logout') }}">Logout</a>  
                        {% else %}  
                            <a class="nav-item nav-link" href="{{ url_for('login') }}">Login</a>  
                            <a class="nav-item nav-link" href="{{ url_for('register') }}">Register</a>  
                        {% endif %}  
                        <!-- ↑修正ここまで -->  
                    </div>  
                </div>  
            </div>  
        </nav>  
    </header>  
    <main role="main" class="container">  
        <div class="row">  
            <div class="col-md-8">  
                {% with messages = get_flashed_messages(with_categories=true) %}  
                  {% if messages %}  
                    {% for category, message in messages %}  
                      <div class="alert alert-{{ category }}">  
                          {{ message }}  
                      </div>  
                    {% endfor %}  
                  {% endif %}  
                {% endwith %}  
                {% block content %}{% endblock %}  
            </div>  
            <div class="col-md-4">  
                <div class="content-section">  
                    <h3>Our Sidebar</h3>  
                    <p class='text-muted'>You can put any information here you'd like.  
                    <ul class="list-group">  
                        <li class="list-group-item list-group-item-light">Latest Posts</li>  
                        <li class="list-group-item list-group-item-light">Announcements</li>  
                        <li class="list-group-item list-group-item-light">Calendars</li>  
                        <li class="list-group-item list-group-item-light">etc</li>  
                    </ul>  
                    </p>  
                </div>  
            </div>  
        </div>  
    </main>  
    <!-- Optional JavaScript -->  
 <!-- jQuery first, then Popper.js, then Bootstrap JS -->  <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>  
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>  
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>  
</body>  
</html>

アカウント登録

















登録成功
















ログイン














ログイン後













アカウント名表示














ログアウト















ログアウトした状態で http://localhost/account にアクセス
(ログイン画面が表示される)
















ログイン後
















*所感

Flaskには便利な拡張があるので、ログイン処理も簡単に実装することができました。次のページの情報も保存しておくことができる点も良かったです。
ハッシュ関数は他にもあるかと思うので、最近のものも色々試してみて検討してみようと思います。