第五章:用户登录

本文翻译自The Flask Mega-Tutorial Part V: User Logins

这是Flask Mega-Tutorial系列的第五部分,我将告诉你如何创建一个用户登录子系统。

你在第三章中学会了如何创建用户登录表单,在第四章中学会了运用数据库。本章将教你如何结合这两章的主题来创建一个简单的用户登录系统。

本章的GitHub链接为:Browse, Zip, Diff.

密码哈希

第四章中,用户模型设置了一个password_hash字段,到目前为止还没有被使用到。 这个字段的目的是保存用户密码的哈希值,并用于验证用户在登录过程中输入的密码。 密码哈希的实现是一个复杂的话题,应该由安全专家来搞定,不过,已经有数个现成的简单易用且功能完备加密库存在了。

其中一个实现密码哈希的包是Werkzeug,当安装Flask时,你可能会在pip的输出中看到这个包,因为它是Flask的一个核心依赖项。 所以,Werkzeug已经安装在你的虚拟环境中。 以下Python shell会话演示了如何哈希密码:

>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'pbkdf2:sha256:50000$vT9fkZM8$04dfa35c6476acf7e788a1b5b3c35e217c78dc04539d295f011f01f18cd2175f'

在这个例子中,通过一系列已知没有反向操作的加密操作,将密码foobar转换成一个长编码字符串,这意味着获得密码哈希值的人将无法使用它逆推出原始密码。 作为一个附加手段,多次哈希相同的密码,你将得到不同的结果,所以这使得无法通过查看它们的哈希值来确定两个用户是否具有相同的密码。

验证过程使用Werkzeug的第二个函数来完成,如下所示:

>>> from werkzeug.security import check_password_hash
>>> check_password_hash(hash, 'foobar')
True
>>> check_password_hash(hash, 'barfoo')
False

向验证函数传入之前生成的密码哈希值以及用户在登录时输入的密码,如果用户提供的密码执行哈希过程后与存储的哈希值匹配,则返回True,否则返回False

整个密码哈希逻辑可以在用户模型中实现为两个新的方法:

from werkzeug.security import generate_password_hash, check_password_hash

# ...

class User(db.Model):
    # ...

    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)

使用这两种方法,用户对象现在可以在无需持久化存储原始密码的条件下执行安全的密码验证。 以下是这些新方法的示例用法:

>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('mypassword')
>>> u.check_password('anotherpassword')
False
>>> u.check_password('mypassword')
True

Flask-Login简介

在本章中,我将向你介绍一个非常受欢迎的Flask插件Flask-Login。 该插件管理用户登录状态,以便用户可以登录到应用,然后用户在导航到该应用的其他页面时,应用会“记得”该用户已经登录。它还提供了“记住我”的功能,允许用户在关闭浏览器窗口后再次访问应用时保持登录状态。可以先在你的虚拟环境中安装Flask-Login来做好准备工作:

(venv) $ pip install flask-login

和其他插件一样,Flask-Login需要在app/__init__py中的应用实例之后被创建和初始化。 该插件初始化代码如下:

# ...
from flask_login import LoginManager

app = Flask(__name__)
# ...
login = LoginManager(app)

# ...

为Flask-Login准备用户模型

Flask-Login插件需要在用户模型上实现某些属性和方法。这种做法很棒,因为只要将这些必需项添加到模型中,Flask-Login就没有其他依赖了,它就可以与基于任何数据库系统的用户模型一起工作。

必须的四项如下:

  • is_authenticated: 一个用来表示用户是否通过登录认证的属性,用TrueFalse表示。

  • is_active: 如果用户账户是活跃的,那么这个属性是True,否则就是False(译者注:活跃用户的定义是该用户的登录状态是否通过用户名密码登录,通过“记住我”功能保持登录状态的用户是非活跃的)。

  • is_anonymous: 常规用户的该属性是False,对特定的匿名用户是True

  • get_id(): 返回用户的唯一id的方法,返回值类型是字符串(Python 2下返回unicode字符串).

我可以很容易地实现这四个属性或方法,但是由于它们是相当通用的,因此Flask-Login提供了一个叫做UserMixinmixin类来将它们归纳其中。 下面演示了如何将mixin类添加到模型中:

# ...
from flask_login import UserMixin

class User(UserMixin, db.Model):
    # ...

用户加载函数

用户会话是Flask分配给每个连接到应用的用户的存储空间,Flask-Login通过在用户会话中存储其唯一标识符来跟踪登录用户。每当已登录的用户导航到新页面时,Flask-Login将从会话中检索用户的ID,然后将该用户实例加载到内存中。

因为数据库对Flask-Login透明,所以需要应用来辅助加载用户。 基于此,插件期望应用配置一个用户加载函数,可以调用该函数来加载给定ID的用户。 该功能可以添加到app/models.py模块中:

from app import login
# ...

@login.user_loader
def load_user(id):
    return User.query.get(int(id))

使用Flask-Login的@login.user_loader装饰器来为用户加载功能注册函数。 Flask-Login将字符串类型的参数id传入用户加载函数,因此使用数字ID的数据库需要如上所示地将字符串转换为整数。

用户登入

让我们回顾一下登录视图函数,它实现了一个模拟登录,只发出一个flash()消息。 现在,应用可以访问用户数据,并知道如何生成和验证密码哈希值,该视图函数就可以完工了。

# ...
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()
    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)

login()函数中的前两行处理一个非预期的情况:假设用户已经登录,却导航到应用的/login URL。 显然这是一个不可能允许的错误场景。 current_user变量来自Flask-Login,可以在处理过程中的任何时候调用以获取用户对象。 这个变量的值可以是数据库中的一个用户对象(Flask-Login通过我上面提供的用户加载函数回调读取),或者如果用户还没有登录,则是一个特殊的匿名用户对象。 还记得那些Flask-Login必须的用户对象属性? 其中之一是is_authenticated,它可以方便地检查用户是否登录。 当用户已经登录,我只需要重定向到主页。

相比之前的调用flash()显示消息模拟登录,现在我可以真实地登录用户。 第一步是从数据库加载用户。 利用表单提交的username,我可以查询数据库以找到用户。 为此,我使用了SQLAlchemy查询对象的filter_by()方法。 filter_by()的结果是一个只包含具有匹配用户名的对象的查询结果集。 因为我知道查询用户的结果只可能是有或者没有,所以我通过调用first()来完成查询,如果存在则返回用户对象;如果不存在则返回None。 在第四章中,你已经看到当你在查询中调用all()方法时, 将执行该查询并获得与该查询匹配的所有结果的列表。 当你只需要一个结果时,通常使用first()方法。

如果使用提供的用户名执行查询并成功匹配,我可以接下来通过调用上面定义的check_password()方法来检查表单中随附的密码是否有效。 密码验证时,将验证存储在数据库中的密码哈希值与表单中输入的密码的哈希值是否匹配。 所以,现在我有两个可能的错误情况:用户名可能是无效的,或者用户密码是错误的。 在这两种情况下,我都会闪现一条消息,然后重定向到登录页面,以便用户可以再次尝试。

如果用户名和密码都是正确的,那么我调用来自Flask-Login的login_user()函数。 该函数会将用户登录状态注册为已登录,这意味着用户导航到任何未来的页面时,应用都会将用户实例赋值给current_user变量。

然后,只需将新登录的用户重定向到主页,我就完成了整个登录过程。

用户登出

提供一个用户登出的途径也是必须的,我将会通过Flask-Login的logout_user()函数来实现。其视图函数代码如下:

# ...
from flask_login import logout_user

# ...

@app.route('/logout')
def logout():
    logout_user()
    return redirect(url_for('index'))

为了给用户暴露登出链接,我会在导航栏上实现当用户登录之后,登录链接自动转换成登出链接。修改base.html模板的导航栏部分后,代码如下:

    <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类后Flask-Login添加的,表达式current_user.is_anonymous仅当用户未登录时的值是True

要求用户登录

Flask-Login提供了一个非常有用的功能——强制用户在查看应用的特定页面之前登录。 如果未登录的用户尝试查看受保护的页面,Flask-Login将自动将用户重定向到登录表单,并且只有在登录成功后才重定向到用户想查看的页面。

为了实现这个功能,Flask-Login需要知道哪个视图函数用于处理登录认证。在app/__init__.py中添加代码如下:

# ...
login = LoginManager(app)
login.login_view = 'login'

上面的'login'值是登录视图函数(endpoint)名,换句话说该名称可用于url_for()函数的参数并返回对应的URL。

Flask-Login使用名为@login_required的装饰器来拒绝匿名用户的访问以保护某个视图函数。 当你将此装饰器添加到位于@app.route装饰器下面的视图函数上时,该函数将受到保护,不允许未经身份验证的用户访问。 以下是该装饰器如何应用于应用的主页视图函数的案例:

from flask_login import login_required

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

剩下的就是实现登录成功之后自定重定向回到用户之前想要访问的页面。 当一个没有登录的用户访问被@login_required装饰器保护的视图函数时,装饰器将重定向到登录页面,不过,它将在这个重定向中包含一些额外的信息以便登录后的回转。 例如,如果用户导航到/index,那么@login_required装饰器将拦截请求并以重定向到/login来响应,但是它会添加一个查询字符串参数来丰富这个URL,如/login?next=/index。 原始URL设置了next查询字符串参数后,应用就可以在登录后使用它来重定向。

下面是一段代码,展示了如何读取和处理next查询字符串参数:

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()函数登录后,应用获取了next查询字符串参数的值。 Flask提供一个request变量,其中包含客户端随请求发送的所有信息。 特别是request.args属性,可用友好的字典格式暴露查询字符串的内容。 实际上有三种可能的情况需要考虑,以确定成功登录后重定向的位置:

  • 如果登录URL中不含next参数,那么将会重定向到本应用的主页。

  • 如果登录URL中包含next参数,其值是一个相对路径(换句话说,该URL不含域名信息),那么将会重定向到本应用的这个相对路径。

  • 如果登录URL中包含next参数,其值是一个包含域名的完整URL,那么重定向到本应用的主页。

前两种情况很好理解,第三种情况是为了使应用更安全。 攻击者可以在next参数中插入一个指向恶意站点的URL,因此应用仅在重定向URL是相对路径时才执行重定向,这可确保重定向与应用保持在同一站点中。 为了确定URL是相对的还是绝对的,我使用Werkzeug的url_parse()函数解析,然后检查netloc属性是否被设置。

在模板中显示已登录的用户

你还记得在实现用户子系统之前的第二章中,我创建了一个模拟的用户来帮助我设计主页的事情吗? 现在,应用实现了真正的用户,我就可以删除模拟用户了。 取而代之,我会在模板中使用Flask-Login的current_user

{% 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 %}

并且我可以在视图函数传入渲染模板函数的参数中删除user了:

@app.route('/')
@app.route('/index')
def index():
    # ...
    return render_template("index.html", title='Home Page', posts=posts)

这正是测试登录和注销功能运作机制的好时机。 由于仍然没有用户注册功能,所以添加用户到数据库的唯一方法是通过Python shell执行,所以运行flask shell并输入以下命令来注册用户:

>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()

如果启动应用并尝试访问 http://localhost:5000/http://localhost:5000/index ,会立即重定向到登录页面。在使用之前添加到数据库的凭据登录后,就会跳转回到之前访问的页面,并看到其中的个性化欢迎。

用户注册

本章要构建的最后一项功能是注册表单,以便用户可以通过Web表单进行注册。 让我们在app/forms.py中创建Web表单类来开始吧:

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

代码中与验证相关的几处相当有趣。首先,对于email字段,我在DataRequired之后添加了第二个验证器,名为Email。 这个来自WTForms的另一个验证器将确保用户在此字段中键入的内容与电子邮件地址的结构相匹配。

由于这是一个注册表单,习惯上要求用户输入密码两次,以减少输入错误的风险。 出于这个原因,我提供了passwordpassword2字段。 第二个password字段使用另一个名为EqualTo的验证器,它将确保其值与第一个password字段的值相同。

我还为这个类添加了两个方法,名为validate_username()validate_email()。 当添加任何匹配模式validate_ <field_name>的方法时,WTForms将这些方法作为自定义验证器,并在已设置验证器之后调用它们。 本处,我想确保用户输入的username和email不会与数据库中已存在的数据冲突,所以这两个方法执行数据库查询,并期望结果集为空。 否则,则通过ValidationError触发验证错误。 异常中作为参数的消息将会在对应字段旁边显示,以供用户查看。

我需要一个HTML模板以便在网页上显示这个表单,我其存储在app/templates/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 %}

登录表单模板需要在其表单之下添加一个链接来将未注册的用户引导到注册页面:

    <p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>

最后,我来实现处理用户注册的视图函数,存储在app/routes.py中,代码如下:

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)

这个视图函数的逻辑也是一目了然,我首先确保调用这个路由的用户没有登录。表单的处理方式和登录的方式一样。在if validate_on_submit()条件块下,完成的逻辑如下:使用获取自表单的username、email和password创建一个新用户,将其写入数据库,然后重定向到登录页面以便用户登录。

精雕细琢之后,用户已经能够在此应用上注册帐户,并进行登录和注销。 请确保你尝试了我在注册表单中添加的所有验证功能,以便更好地了解其工作原理。 我将在未来的章节中再次更新用户认证子系统,以增加额外的功能,比如允许用户在忘记密码的情况下重置密码。 不过对于目前的应用来讲,这已经无碍于继续构建了。

Last updated