Flask 教程 第五章:用户登录

百度云搜索,搜各种资料:http://bdy.lqkweb.com

搜网盘,搜各种资料:http://www.swpan.cn

本文翻译自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创建一个新用户,将其写入数据库,然后重定向到登录页面以便用户登录。

注册表单

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • 第二部分 Blog例子 第八章 用户验证 大部分程序需要追踪用户身份。当用户连接到程序,通过一系列步骤使自己的身份...
    易木成华阅读 1,281评论 0 4
  • ​   在第1章,我们已经了解了Flask的基本知识,如果想要进一步开发更复杂的Flask应用,我们就得了解F...
    懵懂_傻孩纸阅读 2,934评论 0 4
  • 【百度云搜索,搜各种资料:http://bdy.lqkweb.com】 【搜网盘,搜各种资料:http://www...
    攻城狮笔记阅读 1,563评论 4 5
  • 4 创建一个社交网站 在上一章中,你学习了如何创建站点地图和订阅,并且为博客应用构建了一个搜索引擎。在这一章中,你...
    lakerszhy阅读 2,159评论 0 7
  • 大多数程序都需要进行用户跟踪。用户链接程序时需要进行身份认证,通过这一过程,让程序知道自己的身份。程序知道用户是谁...
    藕丝空间阅读 960评论 0 0