密码哈希

用户模型有一个password_hash字段,到目前为止尚未使用。它是用于保存用户密码的哈希值,密码用于验证用户在登录过程中输入的密码。密码散列是一个复杂的主题,应交给安全专家,但有几个易于使用的库以一种简单地从应用程序调用的方式实现所有逻辑。

其中一个实现密码散列的包是Werkzeug,在安装Flask,它已自动安装上了(虚拟环境中),因为是核心依赖之一。以下Python shell会话将演示如何散列密码:

(venv) [root@python blog]#python3
Python 3.9.0 (default, Oct 16 2020, 10:57:11) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'pbkdf2:sha256:50000$G6lpo6G5$017b9bc06a89d2886a0cf255cb0db7ab34242cfcf7eb45900eade8cffe63f059'

PS:退出python shell有两种方法:1)exit()或quit(),回车;2)Ctrl+Z后,回车。
上述示例中,密码 foobar 经过一系列没有已知的反向操作的加密操作,转换为长编码的字符串,这意味着获得散列密码的人无法用它来得到原始密码。作为一项额外措施,如果多次散列相同的密码,那么将得到不同的结果。因此,使得无法通过查看其哈希值来确定两个用户是否具有相同的密码。

验证过程得使用Werkzeug的第二个功能来完成,如下:

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

验证函数check_password_hash()采用先前生成的密码哈希值和用户在登录时输入的密码。True表示用户输入的密码与哈希值匹配,否则返回False。

整个密码哈希逻辑在用户模型可作为两个新方法实现,更新代码:
app/models.py:密码哈希、验证

from app import db
from datetime import datetime
from werkzeug.security import generate_password_hash,check_password_hash

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

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

有了上述这俩个方法,一个用户对象现在就可以进行安全密码验证,而无需存储原始密码。以下是上述新方法的示例:

(venv) [root@python blog]#  flask shell
[2020-10-30 09:44:03,312] INFO in __init__: blog startup
Python 3.9.0 (default, Oct 16 2020, 10:57:11) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
App: app [production]
Instance: /root/blog/instance
>>> 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-Login是非常流行Flask扩展。用于管理用户登录状态,以便做到诸如用户可登录到应用程序,然后在应用程序“记住”用户登录并导航到不同页面。它还提供“记住我”功能,即使是在关闭浏览器窗口后,用户也可保持登录状态。在虚拟环境中安装Flask-Login:版本0.4.1

(venv) [root@python blog]#pip3 install flask-login
Collecting flask-login
  Using cached https://files.pythonhosted.org/packages/c1/ff/bd9a4d2d81bf0c07d9e53e8cd3d675c56553719bbefd372df69bf1b3c1e4/Flask-Login-0.4.1.tar.gz
Requirement already satisfied: Flask in .\venv\lib\site-packages (from flask-login)
Requirement already satisfied: Werkzeug>=0.14 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: click>=5.1 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: Jinja2>=2.10 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: itsdangerous>=0.24 in .\venv\lib\site-packages (from Flask->flask-login)
Requirement already satisfied: MarkupSafe>=0.23 in .\venv\lib\site-packages (from Jinja2>=2.10->Flask->flask-login)
Installing collected packages: flask-login
  Running setup.py install for flask-login ... done
Successfully installed flask-login-0.4.1

和其他扩展一样,需要在app/__init__.py中的应用程序实例之后立即创建和初始化Flask-Login。 app/__init__.py:Flask-Login初始化

# ...
from flask_login import LoginManager

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

# ...

为Flask-Login准备用户模型

Flask-Login扩展与应用程序的用户模型一起使用,并期望在其中实现某些属性和方法。这种做法很好,因为只要将将这些必需的项添加到模型中,Flask-Login就没有任何其他要求。因此,例如,它可以与基于任何数据系统的用户模型一起使用。

以下列出4个必需项目:

is_authenticated:一个属性,如果用户具有有效凭据则是True,否则是False。
is_active:属性,如果用户的账户处于活动状态则是True;其他状态下是False。
is_anonymous:属性,普通用户则是False;匿名用户则是True。
get_id():一个方法,以字符串形式返回用户的唯一标识符。
我们可轻松地实现上述4个,但由于实现相当通用,Flask-Login提供了一个名为UserMixin的mixin类,它包含适用于大多数用户模型类的通用实现。以下将mixin类添加到模型中:
app/models.py:添加Flask-Login用户mixin类

#...
from flask_login import UserMixin

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

用户加载器功能

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

因为Flask-Login对数据库一无所知,所以在加载用户时需要应用程序的帮助。因此,扩展期望应用程序配置一个用户加载函数,它可以被调用去加载给定ID的用户。这个函数添加到app/models.py模块中:
app/models.py:Flask-Login用户加载函数

from app import login
# ...

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

使用@login.user_loader装饰器向Flask-Login注册用户加载函数。Flask-Login传递给函数的id作为一个参数将是一个字符串,所以需要将字符串类型转换为int型以供数据库使用数字ID。

用户登录

这儿重新访问 登录视图 函数,那时现实了发出flash()消息的虚假登录。既然应用程序可访问用户数据库,并且知道如何生存、验证密码哈希,那么就可以完成视图功能。
app/routes.py:实现登录视图函数的逻辑

# ...
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()函数的前两行处理了一个奇怪情况:想象一下,有一个登录用户,Ta导航到/login URL。显然这是错误的,不允许这么做。current_user变量来自Flask-Login,可在处理过程中随时使用,以表示请求客户端的用户对象。此变量的值可以是数据库中的用户的对象(Flask-Login通过上述提供的用户加载器回调读取的),如果用户尚未登录,则可以是特殊的匿名用户对象。回想一下用户对象需要Flask-Login的那4个项目(3个属性,1个方法),其中一个是 is_authenticated,它可以方便地检查用户是否登录。当用户已经登录时,只需重定向到/index页面。

代替之前的flash(),现在我们可以将用户登录为真实的。首先,从数据库加载用户。用户名来自于表单提交,因此我们可以使用查询来查找数据库以查找用户。为此,使用SQLAlchemy的filter_by()方法查询对象。得到的查询结果是只包含具有匹配用户名的对象。因为我们知道1个或0个结果,所以通过调用first()完成查询,如果存在则返回用户对象,否则返回None。调用all()将执行查询,得到查询匹配的所有结果的列表。当我们只需要一个结果时,通过使用first()方法执行查询。

如果我们得到了所提供用户名的匹配项,接下来则可以检查该表单附带的密码是否有效。这将通过调用check_password()方法完成。它将获取与用户一起存储的密码哈希值,并确定在表单输入的密码是否与哈希值匹配。因此,现在有两个可能的错误条件:用户名可能无效;或用户密码可能不正确。在任一情况下,都将flash一条消息,从重定向到登录页面,以便用户可以再次尝试。

如果用户名、密码都正确,那么将调用来自Flask-Login的login_user()函数。这个函数将在登录时注册用户,这意味着用户导航的任何未来页面都将current_user变量设置为该用户。

最后,要完成这个登录过程,只需将新登录的用户重定向到/index页面。

用户退出

为用户提供退出应用程序的选项。这得使用Flask-Login的logout_user()函数完成,即退出视图函数:
app/routes.py:退出视图函数

# ...
from flask_login import logout_user
#...

# ...

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

要想用户公开此链接,可在用户登录后使用导航栏的“登录”链接自动切换到“退出”链接。通过base.html模板中的条件来完成,更新代码:
app/templates/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属性是Flask-Login通过UserMixin类添加到用户对象的属性之一。current_user.is_anonymous表达式将只在用户没有登录时为True。

要求用户登录

Flask-Login还提供了一个非常有用的功能:强制用户在查看应用程序的某些页面之前必须登录。如果未登录用户尝试查看受保护的页面,Flask-Login将自动将用户重定向到登录表单,并且仅在登录过程完成后重定向回用户想要查看的页面。

要实现上述功能,Flask-Login需要知道处理登录的视图函数是什么。这可在app/init.py中添加:

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

#...

上述‘login’的值是登录视图的函数(或端点)名称。也就是:在url_for()调用中使用的名称来获取URL。

Flask-Login为匿名用户保护视图函数的方式是 使用一个名为@login_required的装饰器。当将这个装饰器添加到来自Flask的@app.route的装饰器下方时,这个函数将被收到保护,并且不允许未经过身份验证的用户。下方是装饰器如何用于应用程序的index视图函数:
app/routes.py:添加@login_required装饰器

#...
from flask_login import login_required
#...

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

剩下的是实现:从成功登陆 到 用户想要访问的页面的重定向。当未登录用户访问受@login_required装饰器保护的视图函数时,装饰器将重定向到登录页面,但它将在此重定向中包含一些额外信息,以便应用程序可返回到第一个页。例如,如果用户到/index,@login_required装饰器将拦截请求,并使用重定向响应/login,但它会向此URL添加一个查询字符串参数,从而形成完成的重定向URL /login?next=/index。next 查询字符串参数设置为原始URL,因此应用程序可使用这个参数在登录后重定向。
下方代码将展示如何读取、处理 next查询字符串参数:
app/routes.py:重定向到 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 页面
        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 render_template('login.html',title='Sign In',form=form)
    # ...

在用户通过调用Flask-Login的login_user()函数登录后,获取next查询字符串参数的值。这里写代码片Flask提供了一个request变量,它包含客户端随 请求 发送的所有信息。特别的是,request.args属性以友好的字典格式公开查询字符串的内容。实际上,在成功登录后,确实需要考虑三种可能的情况来确定重定向的位置:

如果登录URL没有next参数,则将页面重定向到/index页面。
如果登录URL包含next设置为相对路径的参数(即没有域部分的URL),则将用户重定向到该URL。
如果登录URL包含next设置为包含域名的完整URL的参数,则将用户重定向到/index页面。
上述第1、2种情况很明显。第3种情况是为让应用程序更安全。攻击者可在next参数中插入恶意站点的URL,因此应用程序仅在URL为相对时重定向,这可确保重定向与应用程序保持在同一站点内。要确定URL是相对的、还是绝对的,要使用Werkzeug的url_parse()函数解析它,然后检查netloc组件是否已设置。

在模板中显示登录用户

以前,创建过“假”用户来设计应用程序主页,因为那时没有用户系统。现在我们可以有真正的用户了,就可以删除“假”用户了,用真实用户了。修改index.html模板代码,使用Flask-Login的current_user替换“假”用户:
app/templates/index.html:将当前用户传递给模板

{% extends "base.html" %}

{% block content %}
    <h1>Hello,{{ current_user.username }}!</h1>
    {% for post in posts %}
        <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
    {% endfor %}
{% endblock %}

并在视图函数中删除这个user模板参数:修改代码
app/routes.py:不再将用户传递给模板

#...
@app.route('/')
@app.route('/index')
@login_required
def index():
    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', posts=posts)
#...

目前为止,就能测试:登录、退出功能了。不过暂无用户注册功能,得通过将用户添加到数据库,即用Python shell操作,运行flask shell命令,并输入以下命令来向数据库添加用户:

(venv) [root@python blog]#  flask shell
[2020-10-30 09:44:03,312] INFO in __init__: blog startup
Python 3.9.0 (default, Oct 16 2020, 10:57:11) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
App: app [production]
Instance: /root/blog/instance
>>> u = User(username='susan', email='susan@example.com')
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()

先退出上述Python shell,运行程序:
>>> quit()

(venv) [root@python blog]#flask run
 * Serving Flask app "blog.py"
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 ...

浏览器访问http://localhost:5000/或http://localhost:5000 /index,都将立即重定向到 /login登录页面。在使用刚才添加到数据库中的用户名、密码登录后,将返回到原始页面,并将看到个性化问候语。效果:

file

登录后
file

点击“Logout”按钮,即可退出用户登录,重定向到登录页面。

用户注册

构建本章最后一项功能:用户注册表单。以便用户可以通过Web表单进行注册。首先,在app/forms.py中创建Web表单类:
首先安装email模块。

(venv) # pip3 install email_validator

app/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 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附带的另一个stock validator,它将确保用户在此字段中键入的内容与电子邮件地址的结构相匹配(省了正则去匹配这是否为一个邮箱地址)。

因为这是个注册表单,因此通常都会要求用户输入密码两次以减少拼写错误的风险。因此,用了password、password2两个字段。第二个字段 用了另一个stock validator EqualTo,它将确保其值与第一个密码字段的值相同。

还为这个类添加两个方法:validate_username()、validate_email()。当添加与模式匹配任何 validate_字段名方法时,WTForms会将这些方法作为自定义验证器,并在stock validator之外调用它们。在这种情况下,确定用户输入的用户名、电子邮件地址是否在数据库中,因此这俩个方法会发出数据库查询。如果存在查询结果,则通过触发验证错误ValidationError。将在字段傍边显示包含此异常的消息让用户查看。

要在网页上显示这个Web表单,还需一个HTML模板,存于app/templates/register.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.error %}
                <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 %}

登录表单模板需要一个链接,用于发送新用户到注册表单,位于登录表单下方:
app/templates/login.html:链接到注册页面

        #...
        <p>{{ form.submit() }}</p>
    </form>
    <p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
{% endblock %}

最后,在app/routes.py中编写处理用户注册的视图函数:
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()判断内完成以下逻辑:创建一个新用户,其中提供用户名、电子邮件地址、密码,将其写入数据库,最后重定向到登录页面,以便用户登录。运行程序,效果:

(venv) [root@python blog]# flask run

file

注册信息如:belen,belen@example.com,Abc123456
点击“Register”按钮,页面转向登录页面:
file

点击“Sign In”按钮,页面转向/index页面。

至此,应用程序有了:创建账户、登录、退出的功能。在接下来的章节中,将重新访问用户身份验证子系统,以添加其他功能,如允许用户在忘记密码时重置密码。

仅修改app/models.py中User类的repr()代码,以便打印出数据库中 所有用户的信息:

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

    def __repr__(self):
        #return '<User {}>'.format(self.username)
        return '<User {}, Email {}, Password_Hash {}, Posts {}'.format(self.username, self.email, self.password_hash, self.posts)
    #...

运行flask shell命令后,就可看到刚才注册的用户 belen。

(venv) [root@python blog]#  flask shell
[2020-10-30 09:44:03,312] INFO in __init__: blog startup
Python 3.9.0 (default, Oct 16 2020, 10:57:11) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
App: app [production]
Instance: /root/blog/instance
>>> u = User.query.all()
>>> u
[<User susan, Email susan@example.com, Password_Hash pbkdf2:sha256:50000$OONOkVyy$8d008c6647ab95a5793cf60bf57eaa3bb1123d6e5b3135c5cc5e42e02eddae32, Posts SELECT post.id AS post_id, post.body AS post_body, post.timestamp AS post_timestamp, post.user_id AS post_user_id
FROM post
WHERE ? = post.user_id, <User belen, Email belen@example.com, Password_Hash pbkdf2:sha256:50000$PEDt5NxS$cf6c958c97b6ad28d9495d138cb5a310f6f2389534b0cafa3002dd3cec9af9d1, Posts SELECT post.id AS post_id, post.body AS post_body, post.timestamp AS post_timestamp, post.user_id AS post_user_id
FROM post
WHERE ? = post.user_id]

目前为止,项目结构:

microblog/
    app/
        templates/
            base.html
            index.html
            login.html
            register.html
        __init__.py
        forms.py
        models.py
        routes.py
    migrations/
    venv/
    app.db
    config.py
    microblog.py