用户认证

Flask 的认证扩展

  • Flask-Login:管理已登录用户的用户会话
  • Werkzeug:计算密码散列值并进行核对
  • itsdangerous:生成并核对加密安全令牌

密码的安全性

若想保证数据库中用户密码的安全,关键在于不能存储密码本身,而要存储密码的散列值。计算密码 散列值 的函数接收密码作为输入,使用一种或多种加密算法转换密码,最终得到一个和原始密码没有关系的字符序列。核对密码时,密码散列值可代替原始密码,因为计算散列值的函数是可复现的:只要输入一样,结果就一样。

Salted Password Hashing - Doing it Right

使用Werkzeug实现密码散列

Werkzeug 中的security 模块能够很方便地实现密码散列值的计算。这一功能的实现只需要两个函数,分别用在注册用户和验证用户阶段。

  • generate_password_hash(password, method=pbkdf2:sha1, salt_length=8):这个函数将原始密码作为输入,以字符串形式输出密码的散列值,输出的值可保存在用户数据库中。method 和salt_length 的默认值就能满足大多数需求。
  • check_password_hash(hash, password):这个函数的参数是从数据库中取回的密码散列
    值和用户输入的密码。返回值为True 表明密码正确。
def gen_password_hash(password):
return generate_password_hash(password)

def verify_password(password_hash, password):
return check_password_hash(password_hash, password)


使用 Flask-Login 认证用户

用户登录程序后,他们的认证状态要被记录下来,这样浏览不同的页面时才能记住这个状态。Flask-Login 是个非常有用的小型扩展,专门用来管理用户认证系统中的认证状态,且不依赖特定的认证机制。

安装

$ pip install flask-login


准备用于登录的用户模型

要想使用Flask-Login 扩展,程序的User 模型必须实现几个方法。需要实现的方法如表1所示。

表1 Flask-Login要求实现的用户方法

方法

说明

is_authenticated()

如果用户已经登录,必须返回True,否则返回False

is_active()

如果允许用户登录,必须返回True,否则返回False。如果要禁用账户,可以返回False

is_anonymous()

对普通用户必须返回False

get_id()

必须返回用户的唯一标识符,使用Unicode 编码字符串

这4个方法可以在模型类中作为方法直接实现,不过还有一种更简单的替代方案。Flask-Login 提供了一个UserMixin 类,其中包含这些方法的默认实现,且能满足大多数需求。修改后的User 模型如示例 所示。

示例6 app/models.py:修改User 模型,支持用户登录

from flask_login import UserMixin

class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key = True)
email = db.Column(db.String(64), unique=True, index=True)
username = db.Column(db.String(64), unique=True, index=True)
password_hash = db.Column(db.String(128))
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))


示例7 app/init.py:初始化Flask-Login

from flask_login import LoginManager

login_manager = LoginManager()
login_manager.session_protection = 'strong'
login_manager.login_view = 'auth.login'

def create_app(config_name):
# ...
login_manager.init_app(app)
# ...


LoginManager 对象的session_protection 属性可以设为None、'basic' 或'strong',以提供不同的安全等级防止用户会话遭篡改。设为'strong' 时,Flask-Login 会记录客户端IP地址和浏览器的用户代理信息,如果发现异动就登出用户。login_view 属性设置登录页面的端点。

最后,Flask-Login 要求程序实现一个回调函数,使用指定的标识符加载用户。这个函数的定义如示例8 所示。

示例8 app/models.py:加载用户的回调函数

from . import login_manager

@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))


加载用户的回调函数接收以Unicode 字符串形式表示的用户标识符。如果能找到用户,这个函数必须返回用户对象;否则应该返回None。

保护路由

为了保护路由只让认证用户访问,Flask-Login 提供了一个login_required 修饰器。用法演

示如下:

from flask_login import login_required

@app.route('/secret')
@login_required
def secret():
return 'Only authenticated users are allowed!'


如果未认证的用户访问这个路由,Flask-Login 会拦截请求,把用户发往登录页面。

添加登录表单

呈现给用户的登录表单中包含一个用于输入电子邮件地址的文本字段、一个密码字段、一个“记住我”复选框和提交按钮。这个表单使用的Flask-WTF 类如示例9 所示。

示例9 app/auth/forms.py:登录表单

from flask_wtf import Form
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import Required, Length, Email

class LoginForm(Form):
email = StringField('Email', validators=[Required(), Length(1, 64),Email()])

password = PasswordField('Password', validators=[Required()])
remember_me = BooleanField('Keep me logged in')
submit = SubmitField('Log In')


电子邮件字段用到了WTForms 提供的Length() 和Email() 验证函数。PasswordField 类表示属性为type="password" 的​​<input>​​ 元素。BooleanField 类表示复选框。

登录页面使用的模板保存在auth/login.html 文件中。这个模板只需使用Flask-Bootstrap 提

供的wtf.quick_form() 宏渲染表单即可。登录表单在浏览器中渲染后的样子如图8-1 所示。

base.html 模板中的导航条使用Jinja2 条件语句,并根据当前用户的登录状态分别显示“Sign In”或“Sign Out”链接。这个条件语句如示例8-10 所示。

示例10 app/templates/base.html:导航条中的Sign In 和Sign Out 链接

<ul class="nav navbar-nav navbar-right">
{% if current_user.is_authenticated() %}
<li><a href="{{ url_for('auth.logout') }}">Sign Out</a></li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
{% endif %}
</ul>


判断条件中的变量current_user 由Flask-Login 定义,且在视图函数和模板中自动可用。这个变量的值是当前登录的用户,如果用户尚未登录,则是一个匿名用户代理对象。如果是匿名用户,is_authenticated() 方法返回False。所以这个方法可用来判断当前用户是否已经登录。

登入用户

视图函数login() 的实现如示例8-11 所示。

示例11 app/auth/views.py:登录路由

from flask import render_template, redirect, request, url_for, flash
from flask.ext.login import login_user
from . import auth
from ..models import User
from .forms import LoginForm

@auth.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 is not None and user.verify_password(form.password.data):
login_user(user, form.remember_me.data)
return redirect(request.args.get('next') or url_for('main.index'))
flash('Invalid username or password.')
return render_template('auth/login.html', form=form)


这个视图函数创建了一个LoginForm 对象,用法和第4 章中的那个简单表单一样。当请求类型是GET 时,视图函数直接渲染模板,即显示表单。当表单在POST 请求中提交时,Flask-WTF 中的validate_on_submit() 函数会验证表单数据,然后尝试登入用户。

为了登入用户,视图函数首先使用表单中填写的email 从数据库中加载用户。如果电子邮件地址对应的用户存在,再调用用户对象的verify_password() 方法,其参数是表单中填写的密码。如果密码正确,则调用Flask-Login 中的login_user() 函数,在用户会话中把用户标记为已登录。login_user() 函数的参数是要登录的用户,以及可选的“记住我”布尔值,“记住我”也在表单中填写。如果值为False,那么关闭浏览器后用户会话就过期了,所以下次用户访问时要重新登录。如果值为True,那么会在用户浏览器中写入一个长期有效的cookie,使用这个cookie 可以复现用户会话。

按照第4 章介绍的“Post/ 重定向/Get 模式”,提交登录密令的POST 请求最后也做了重定向,不过目标URL 有两种可能。用户访问未授权的URL 时会显示登录表单,Flask-Login会把原地址保存在查询字符串的next 参数中,这个参数可从request.args 字典中读取。如果查询字符串中没有next 参数,则重定向到首页。如果用户输入的电子邮件或密码不正确,程序会设定一个Flash 消息,再次渲染表单,让用户重试登录。

我们需要更新登录模板以渲染表单。修改内容如示例8-12 所示。

示例12 app/templates/auth/login.html:渲染登录表单

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Login{% endblock %}
{% block page_content %}

<div class="page-header">
<h1>Login</h1>
</div>

<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>

{% endblock %}


登出用户

退出路由的实现如示例8-13 所示。

示例13 app/auth/views.py:退出路由

from flask_login import logout_user, login_required
@auth.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.')
return redirect(url_for('main.index'))


为了登出用户,这个视图函数调用Flask-Login 中的logout_user() 函数,删除并重设用户

会话。随后会显示一个Flash 消息,确认这次操作,再重定向到首页,这样登出就完成了。

测试登录

为验证登录功能可用,可以更新首页,使用已登录用户的名字显示一个欢迎消息。模板中生成欢迎消息的部分如示例8-14 所示。

示例14 app/templates/index.html:为已登录的用户显示一个欢迎消息

Hello,
{% if current_user.is_authenticated() %}
{{ current_user.username }}
{% else %}
Stranger
{% endif %}!


在这个模板中再次使用current_user.is_authenticated() 判断用户是否已经登录。

注册新用户

如果新用户想成为程序的成员,必须在程序中注册,这样程序才能识别并登入用户。程序的登录页面中要显示一个链接,把用户带到注册页面,让用户输入电子邮件地址、用户名和密码。

添加用户注册表单

注册页面使用的表单要求用户输入电子邮件地址、用户名和密码。这个表单如示例8-15所示。

示例15 app/auth/forms.py:用户注册表单

from flask.ext.wtf import Form
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import Required, Length, Email, Regexp, EqualTo
from wtforms import ValidationError
from ..models import User

class RegistrationForm(Form):
email = StringField('Email', validators=[Required(), Length(1, 64),Email()])
username = StringField('Username', validators=[Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,'Usernames must have only letters,numbers, dots or underscores')])
password = PasswordField('Password', validators=[Required(), EqualTo('password2', message='Passwords must match.')])
password2 = PasswordField('Confirm password', validators=[Required()])
submit = SubmitField('Register')

def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')

def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already in use.')


这个表单使用WTForms 提供的Regexp 验证函数,确保username 字段只包含字母、数字、下划线和点号。这个验证函数中正则表达式后面的两个参数分别是正则表达式的旗标和验证失败时显示的错误消息。

安全起见,密码要输入两次。此时要验证两个密码字段中的值是否一致,这种验证可使用WTForms 提供的另一验证函数实现,即EqualTo。这个验证函数要附属到两个密码字段中的一个上,另一个字段则作为参数传入。

这个表单还有两个自定义的验证函数,以方法的形式实现。如果表单类中定义了以validate_ 开头且后面跟着字段名的方法,这个方法就和常规的验证函数一起调用。本例分别为email 和username 字段定义了验证函数,确保填写的值在数据库中没出现过。自定义的验证函数要想表示验证失败,可以抛出ValidationError 异常,其参数就是错误消息。

显示这个表单的模板是/templates/auth/register.html。和登录模板一样,这个模板也使用wtf.quick_form() 渲染表单。

登录页面要显示一个指向注册页面的链接,让没有账户的用户能轻易找到注册页面。改动如示例8-16 所示。

示例16 app/templates/auth/login.html:链接到注册页面

<p>
New user?
<a href="{{ url_for('auth.register') }}">
Click here to register
</a>
</p>


注册新用户

处理用户注册的过程没有什么难以理解的地方。提交注册表单,通过验证后,系统就使用用户填写的信息在数据库中添加一个新用户。处理这个任务的视图函数如示例8-17 所示。

示例17 app/auth/views.py:用户注册路由

@auth.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
user = User(email=form.email.data,username=form.username.data,password=form.password.data)
db.session.add(user)
flash('You can now login.')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)