☆☆ 新着記事 ☆☆

2019年3月6日水曜日

Flask(3) Flask-Loginを使ってログイン管理

Flask-Loginの機能を使って、ユーザのログイン管理を行います。

今回の設定でできるようになること。

1. 初期画面
ログインを要求するページにアクセスすると、ログインページにredirectする。
(下の画像は、index.htmlにアクセスしようとして、login.htmlにリダイレクト)



2. ユーザ名・パスワードチェック
入力されたユーザ名・パスワード(ハッシュ化)をDBに登録されているものと比較し、
マッチしなければエラー・メッセージ。




3. ユーザ登録画面
各フィールドのバリデーションとCSRF(Cross Site Request Forgery)対策




等です。
各ファイルの最終の記述は、最後の方にまとめてあります。



Step1: ユーザのパスワード・チェック

ハッシュ化されたパスワードを管理するために、 flaskのDependencyであるwerkzeugを利用する。(dependencyなので、flask インストール時に自動でインストール済み)



DBのUser Tableを定義したmodels.py を変更する。

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)

check_password_hashは、入力されたパスワードが保存されたパスワードと等しいかをチェックする。

Step2:  Flask-Login

ユーザのログイン状態を管理する為のextension.

(venv) $ pip install flask-login

__init__.py.に、以下の記述を追加し、起動できるようにする。

from flask_login import LoginManager
app = Flask(__name__)
# ...
login = LoginManager(app) <- extensionを起動させる際の標準的な書き方
# ...

Step3:  Flask-Login

Flask-Loginの関数で利用する4つ。

is_authenticated:
 a property that is True if the user has valid credentials or False otherwise.

is_active:
 a property that is True if the user's account is active or False otherwise.

is_anonymous:
 a property that is False for regular users, and True for a special, anonymous user.

get_id():
 a method that returns a unique identifier for the user as a string (unicode, if using Python 2).

flask_loginは、これらのクラスを簡単に実装するために、UserMixinというusermixin classを用意している。


app/models.py: Flask-Login user mixin class
# ...
from flask_login import UserMixin
class User(UserMixin, db.Model):
    # ...
というように、データベースのモデルと同列に記述する。


次に、

①  Flaskのセッションを利用する。

ユーザが新しいページに移った際、Flask-Login は、セッションからIDを取出し、メモリーに格納する。Flask-LoginはユーザのDB構造を関知していないので、次の設定をする。

app/models.py: Flask-Login user loader function
from app import login
# ...
@login.user_loader <- * デコレータ(loader function)
def load_user(id):
    return User.query.get(int(id)) ← Userテーブルに付与されるidは数字

*Decoratorの命名規則(convention)
@login_manager.user_loader
def load_user(user_id)
    return User.query.get(int(user_id))



* The id that Flask-Login passes to the function as an argument is going to be a string, so databases that use numeric IDs need to convert the string to integer as you see above.

これで、セッションを管理する為の準備が整いました。


② ログイン

ログインできるようにview functionを変更する。
app/routes.py: Login view function logic
# ...
from flask_login import current_user, login_user
from app.models import User

# ...
@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = LoginForm()

 '''◇ Userの存在とpassword check'''

    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, remember=form.remember_me.data)
        return redirect(url_for('index'))

    return render_template('login.html', title='Sign In', form=form)


*current_user()
ログイン状態のステータス・チェック。

route に以下のように記述することで、ログインしているユーザを特定のページに誘導する、
という使い方をする。

def register():
   if current_user.is_authenticated:
       return redirect(url_for('home'))



* login_user( )
@app.route("/login")
def lingin()
で定義したファンクション。


current_user: Flask-Loginで定義されている変数。データー・ベースから取得したユーザ・オブジェクトやログインしていない場合は、特別なアノニマス・ユーザであったりする。

is_authenticated, which comes in handy to check if the user is logged in or not. When the user is already logged in, I just redirect to the index page.

user = User.query.filter_by(username=form.username.data).first()

htmlのformから送られてくるユーザが入力したユーザ名 username=form.username.dataとする。 
これをfilter_by() method of the SQLAlchemy query object.を使ってデータベースとマッチングさせる。 first()で、ユーザデータがあればユーザオブジェクトが返されるし、なければNoneが返される。

因みに, first()メソッドの代わりに、 all() method を利用すると、マッチするオブジェクトがリスト形式ですべて格納される。

check_password():格納されているハッシュ化されたパスワードと、入力されたパスワードが合致するかを検証。
ユーザが存在しない、パスワードがマッチしない場合は、redirect(url_for('index'))。2つの条件がポジティブであれば、Flask-Login で定義されているlogin_user() functionが呼び出される。(ここでは、同じくindex.htmlにリダイレクトされる。) login_user() functionが実行されると、ユーザはログイン中として登録され、以降、どのページに遷移しても、
ユーザにアサインされたcurrent_user variableを持つことになる。

③ ログアウト

Flask-Loginのlogout_user() functionを利用。
 app/routes.pyに以下の記述を追加する。
# ...
from flask_login import logout_user
 # ...
@app.route('/logout')
def logout():
    logout_user()
    return redirect(url_for('index'))

ユーザのログイン・ログアウトの状態に合わせたナビバーのリンク表示を切り替える。
app/templates/base.html: Conditional login and logout links
    <div>
        Microblog:
        <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>

is_anonymous :UserMixin classを利用して、Flask-Loginがユーザー・オブジェクトに追加する属性の一つ。 ログインしていない場合にTrueを返す。

④ Loginを要求する。
https://youtu.be/CSHx6eCkmv0?t=38m32s

Flask-Login では、ログインしているユーザのみに閲覧を許可するprotected pageを、ログインしていないユーザが閲覧しようとした時に、自動的にログイン・ページにリダイレクトさせて、ログイン後だけに、リダイレクト・バックを許可する便利な機能があるので、実装する。

This can be added in app/__init__.py:
# ...
login = LoginManager(app)
login.login_view = 'login'

'login': URLと紐づいているlogin viewのファンクション(end point)名
loginを求めるページには、viewファイル(routes.py)で@login_requiredというデコレータを付ける

例)
app/routes.py:

from flask_login import login_required
@app.route('/')
@app.route('/index')
@login_required
def index():
    # ...

db = SQALchemy(app)
login_manager.login_view = 'login'
login_manager.login_message_category = 'info'


⑤ログイン後、閲覧しようとしていたページにリダイレクト・バック
https://youtu.be/CSHx6eCkmv0?t=41m41s

ユーザが最初にログインしていない状態で、@login_required decorator でプロテクトされたページにアクセスすると、例えば、loginページにリダイレクトされるが、その際、このデコレータは、ある種の query string agumentを付加的に保持する。
(URLは、 /login?next=/index. のようになる。)

next query string argumentは、オリジナルのURLに付加されるので、この機能を使ってリダイレクト・バックする。



app/routes.py: Redirect to "next" page
from flask import request
from werkzeug.urls import url_parse
@app.route('/login', methods=['GET', 'POST'])
def login():
    # ...
    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, remember=form.remember_me.data)
        next_page = request.args.get('next')
        if not next_page or url_parse(next_page).netloc != '':
            next_page = url_for('index')
        return redirect(next_page)
    # ...

Flask-Loginのlogin_user() functionが実行されユーザがログインすると、 ‘next’ query string argument が取得される。Flaskは、クライアントが送信した全てのrequest情報を取得する。 特に、request.args attribute(属性)は、ディクショナリ形式となる。

リダイレクト先には、3つの可能性がある。

•If the login URL does not have a next argument, then the user is redirected to the index page.


•If the login URL includes a next argument that is set to a relative path (or in other words, a URL without the domain portion), then the user is redirected to that URL.


•If the login URL includes a next argument that is set to a full URL that includes a domain name, then the user is redirected to the index page.(別URLにリダイレクトさえるトラップを仕込もうとした攻撃に有効な対策)

To determine if the URL is relative or absolute, I parse it with Werkzeug's url_parse() function and then check if the netloc component is set or not.

⑥ログイン・ユーザ名をhtml上に表示させる

app/templates/index.html: Pass current user to template
{% extends "base.html" %}
{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% for post in posts %}
    <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
    {% endfor %}
{% endblock %}

view functionのuser template argument 一式を全て削除する。
app/routes.py: Do not pass user to template anymore
@app.route('/')
@app.route('/index')
def index():
   # ... (削除するだけ)
    return render_template("index.html", title='Home Page', posts=posts)

This is a good time to test how the login and logout functionality works. Since there is still no user registration, the only way to add a user to the database is to do it via the Python shell, so run flask shell and enter the following commands to register a user:
>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()

If you start the application and try to access http://localhost:5000/ or http://localhost:5000/index, you will be immediately redirected to the login page, and after you log in using the credentials of the user that you added to your database, you will be returned to the original page, in which you will see a personalized greeting.

⑦ ユーザ登録(User Registration)

app/forms.py: User registration form

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.models import User
# ...

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')
    def validate_username(self, username):
        user = User.query.filter_by(username=username.data).first()
        if user is not None:
            raise ValidationError('Please use a different username.')
    def validate_email(self, email):
        user = User.query.filter_by(email=email.data).first()
        if user is not None:
            raise ValidationError('Please use a different email address.')

⑧ register.htmlを作る。

app/templates/register.html: Registration template
{% extends "base.html" %}
{% block content %}
    <h1>Register</h1>
    <form action="" method="post">
        {{ 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.email.label }}<br>
            {{ form.email(size=64) }}<br>
            {% for error in form.email.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.password2.label }}<br>
            {{ form.password2(size=32) }}<br>
            {% for error in form.password2.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

login form に registration form, へのリンクを作成する。

app/templates/login.html: Link to registration page
    <p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
user registrations をルーティングする為にroutes.pyを修正する:

app/routes.py: User registration view function
from app import db
from app.forms import RegistrationForm
# ...
@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(username=form.username.data, email=form.email.data)
        user.set_password(form.password.data)
        db.session.add(user)
        db.session.commit()
        flash('Congratulations, you are now a registered user!')
        return redirect(url_for('login'))
    return render_template('register.html', title='Register', form=form)

 ============今回修正した各ファイルの最終形============
04 __init__.py

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from config import Config
from flask_login import LoginManager
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login = LoginManager(app)
login.login_view = 'login'  # loginユーザにのみ閲覧を許可する為の設定
                        # loginを求めるページには、viewファイル(routes.py)で

@login_requiredというデコレータを付ける
from app import routes, models

04 forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.models import User
class LoginForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    password = PasswordField('Password', validators=[DataRequired()])
    remember_me = BooleanField('Remember Me')
    submit = SubmitField('Sign In')

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')

    def validate_username(self, username):
        user = User.query.filter_by(username=username.data).first()
        if user is not None:
            raise ValidationError('Please use a different username.')
    def validate_email(self, email):
        user = User.query.filter_by(email=email.data).first()
        if user is not None:
            raise ValidationError('Please use a different email address.')

04 models.py

from datetime import datetime
from app import db
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(64), index=True, unique=True)


    email = db.Column(db.String(120), index=True, unique=True)


    password_hash = db.Column(db.String(128))


    posts = db.relationship('Post', backref='author', lazy='dynamic')

    def __repr__(self):


        return '<User {}>'.format(self.username)





    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)





# Sessionを管理するFlask-Loginの為の設定


@login.user_loader <-デコレータ


def load_user(id):


    return User.query.get(int(id))








class Post(db.Model):


    id = db.Column(db.Integer, primary_key=True)


    body = db.Column(db.String(140))


    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)


    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))





    def __repr__(self):


        return '<Post {}>'.format(self.body)








04 routes.py


from flask import render_template, flash, redirect, url_for


from app import app


from app.forms import LoginForm


from flask_login import current_user, login_user # Log-inしたユーザ処理用


from app.models import User # Log-inしたユーザ処理用


from flask_login import logout_user # ユーザのログアウト処理用


from flask import request # redirect back用


from werkzeug.urls import url_parse # redirect back用


from app import db  #ユーザレジとレーション用


from app.forms import RegistrationForm #ユーザレジとレーション用








@app.route('/')


@app.route('/index')

def index():
    user = {'username': 'Miguel'}
    posts = [
        {
            'author': {'username': 'John'},
            'body': 'Beautiful day in Portland!'
        },
        {
            'author': {'username': 'Susan'},
            'body': 'The Avengers movie was so cool!'
        }
    ]
    return render_template('index.html', title='Home', user=user, posts=posts)








@app.route('/login', methods=['GET', 'POST'])


def login():


    if current_user.is_authenticated:


   return redirect(url_for('index'))





    form = LoginForm()


    if form.validate_on_submit():


       flash('Login requested for user {}, remember_me={}'.format(


            form.username.data, form.remember_me.data))





        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, remember=form.remember_me.data)


# redirect back用の設定


             next_page = request.args.get('next')


        if not next_page or url_parse(next_page).netloc != '':


            next_page = url_for('index')


        return redirect(next_page)





return redirect(url_for('index')) # redirect backするので不要に。





    return render_template('login.html', title='Sign In', form=form)





@app.route('/logout')


def logout():


    logout_user()


    return redirect(url_for('index'))





@app.route('/register', methods=['GET', 'POST'])


def register():


    if current_user.is_authenticated:


        return redirect(url_for('index'))


    form = RegistrationForm()


    if form.validate_on_submit():


        user = User(username=form.username.data, email=form.email.data)


        user.set_password(form.password.data)


        db.session.add(user)


        db.session.commit()


        flash('Congratulations, you are now a registered user!')


        return redirect(url_for('login'))


    return render_template('register.html', title='Register', form=form)





④ base.html





<html>


    <head>


        {% if title %}


        <title>{{ title }} - Microblog</title>


        {% else %}


        <title>Welcome to Microblog</title>


        {% endif %}


    </head>


    <body>


    <div>


        Microblog:


        <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>





④ index.html




{% extends "base.html" %}





{% block content %}


    <h1>Hi, {{ user.username current_user.username }}!</h1>


    {% for post in posts %}


    <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>


    {% endfor %}


{% endblock %}





④register.html





{% extends "base.html" %}





{% block content %}


    <h1>Register</h1>


    <form action="" method="post">


       {{ 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.email.label }}<br>


            {{ form.email(size=64) }}<br>


            {% for error in form.email.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.password2.label }}<br>


            {{ form.password2(size=32) }}<br>


            {% for error in form.password2.errors %}


            <span style="color: red;">[{{ error }}]</span>


            {% endfor %}


        </p>


        <p>{{ form.submit() }}</p>


    </form>


{% endblock %}





 

0 件のコメント:

コメントを投稿