大多数应用程序需要都需要知道当前用户是谁。当用户与应用程序连接时,他们通过身份验证来标识身份。一旦应用程序知道用户是谁,就可以为其提供专属的服务。
最常用的身份验证方法是,让用户提供一份身份证明(通常是他们的电子邮件地址或用户名),以及只有他们知道的密码。本章将创建一个完整的 Flasky 用户认证系统。
Python 有许多优秀软件包用来做认证,但它们都没有做任何事情。本章介绍的用户认证解决方案使用多个软件包,并将它们进行整合。下面列示了本章使用的软件包的名字及其作用:
除了特定于认证的软件包之外,还将使用以下通用扩展:
为了保证存储在数据库里的用户信息安全,需要对保存在数据库中的用户密码进行加密存储。关键点在于,使用密码原文的哈希值加上一个随机盐值进行加密。
Werkzeug 的 security
模块提供了一个对密码进行安全加密的哈希实现。实现密码加密功能,只需要两个函数:生成密文及验证密码。
generate_password_hash(password, method='pbkdf2:sha256', salt_length=8)
函数用于生成密码的密文。它接收一个密码明文参数,返回加密后的密码的密文。
check_password_hash(hash, password)
函数用于验证密码。第一个参数是密码的密文,第二个参数是密码的明文。如果密码的明文和密文相匹配,返回 True
。
示例8-1对第5章的 User
模型进行修改,使其能保存用户密码。
# 示例 8-1. app/models.py: User 模型的密码哈希 from werkzeug.security import generate_password_hash, check_password_hash class User(db.Model): # ... password_hash = db.Column(db.String(128)) @property def password(self): raise AttributeError('密码是个只写属性') @password.setter def password(self, password): self.password_hash = generate_password_hash(password) def verify_password(self, password): return check_password_hash(self.password_hash, password)
密码的密文通过一个只写属性 password
来实现。当设置这个属性的值时,会调用 Werkzeug 的 generate_password_hash()
方法对密码进行加密,并把加密后的密文设置给 password_hash
字段。尝试获取 password
属性的值时,会抛出一个异常,明确告知密码的密文不能直接读取。
verify_password()
需要一个密码明文,并将该明文传递给 Werkzeug 的 check_password_hash()
方法,对该明文和存储在 User
模型中的密文进行验证,如果这个方法返回 True
,说明密码正确。
如果你从 Github 克隆了示例代码,可以使用
git checkout 8a
签出本示例。
密码加密功能至此已完成,现在可以在 Shell 中对其进行测试了:
(venv) $ flask shell >>> u = User() >>> u.password = 'cat' >>> u.password Traceback (most recent call last): File "<console>", line 1, in <module> File "/home/flask/flasky/app/models.py", line 24, in password raise AttributeError('密码是个只写属性') AttributeError: 密码是个只写属性 >>> u.password_hash 'pbkdf2:sha256:50000$moHwFH1B$ef1574909f9c549285e8547cad181c5e0213cfa44a4aba4349fa830aa1fd227f' >>> u.verify_password('cat') True >>> u.verify_password('dog') False >>> u2 = User() >>> u2.password = 'cat' >>> u2.password_hash 'pbkdf2:sha256:50000$Pfz0m0KU$27be930b7f0e0119d38e8d8a62f7f5e75c0a7db61ae16709bcaa6cfd60c44b74
虽然 u
和 u2
的密码明文是一样的(都是"cat"
),但由于盐值不同,生成的密码密文是不一样的。
示例8-2定义了三个测试,用于对 User
模型新加的功能进行测试:
# 示例 8-2. tests/test_user_model.py: 测试密码安全 import unittest from app.models import User class UserModelTestCase(unittest.TestCase): def test_password_setter(self): u = User(password = 'cat') self.assertTrue(u.password_hash is not None) def test_no_password_getter(self): u = User(password = 'cat') with self.assertRaises(AttributeError): u.password def test_password_verification(self): u = User(password = 'cat') self.assertTrue(u.verify_password('cat')) self.assertFalse(u.verify_password('dog')) def test_password_salts_are_random(self): u = User(password='cat') u2 = User(password='cat') self.assertTrue(u.password_hash != u2.password_hash)
运行 flask test
:
(venv) $ flask test test_app_exists (test_basics.BasicsTestCase) ... ok test_app_is_testing (test_basics.BasicsTestCase) ... ok test_no_password_getter (test_user_model.UserModelTestCase) ... ok test_password_salts_are_random (test_user_model.UserModelTestCase) ... ok test_password_setter (test_user_model.UserModelTestCase) ... ok test_password_verification (test_user_model.UserModelTestCase) ... ok .---------------------------------------------------------------------- Ran 6 tests in 0.379s OK
本节将为创建第二个蓝图,用于用户身份认证子系统,这个蓝图名为 auth
。为不同的子系统创建单独的蓝图是保证项目良好组织的好方法。
auth
将保存在同名的 Python 包里。该包的构造器将创建一个新蓝图,并从同一个包中的 views
模块中导入路由。
# 示例 8-3. app/auth/__init__.py: 创建用户认证蓝图 from flask import Blueprint auth = Blueprint('auth', __name__) from . import views
示例8-4展示了 auth
蓝图的路由及视图函数所在 app/auth/views.py
模块的内容。
# 示例 8-4. app/auth/views.py: 用户认证蓝图的路由和视图函数 from flask import render_template from . import auth @auth.route('/login') def login(): return render_template('auth/login.html')
auth
蓝图需要在 create_app()
工厂函数里注册:
# 示例 8-5. app/__init__.py: 注册用户认证蓝图 def create_app(config_name): # ... from .auth import auth as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix='/auth') return app
register_blueprint()
的 url_prefix
参数是可选的。如果设置了该参数,那么该蓝图里的所有路由都将加上该值作为前缀。本例中,使用 /auth
,那么蓝图中的 /login
路由最终会变成:/auth/login
。
如果你从 Github 克隆了示例代码,可以使用
git checkout 8b
签出本示例。
当用户登录到应用后,他的认证状态将保存到 Session 中,所以即使是在不同页面间跳转,认证状态都能得到维持。Flask-Login 是一个简单实用的用户认证管理系统。使用以下命令安装 Flask-Login:
(venv) $ pip install flask-login
Flask-Login 和应用程序的 User
对象紧密相连。要让 User
模型能和 Flask-Login 正常工作,需要对模型类进行扩展,使其实现 Flask-Login 要求的一些属性和方法。下表列示了 Flask-Login 需要的元素:
属性或方法 | 说明 |
---|---|
is_authenticated |
如果用户登录成功,必须返回 True ;否则返回 False |
is_active |
如果允许用户登录,返回 True ;否则返回 False |
is_anonymous |
对于常规用户(需要登录的用户)必须返回 False ;对于匿名用户,返回 True |
get_id() |
必须返回一个唯一标识,用于标识用户。返回值必须是一个 Unicode 字符串 |
这些属性和方法可以直接在模型类中逐一手动实现。不过,借助 Flask-Login 提供的 UserMixin
类,可以简化操作。
# 示例 8-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'))
注意,上例增加了 email
属性。在本应用中,可以将用户的 Email 视为用户名。
接下来,应该在 create_app()
工厂函数中,对 Flask-Login 进行初始化。
# 示例 8-7. app/__init__.py: Flask-Login 初始化 from flask_login import LoginManager login_manager = LoginManager() login_manager.login_view = 'auth.login' def create_app(config_name): # ... login_manager.init_app(app) # ...
login_manager.login_view
用来设置登录视图函数。当用户未登录而尝试访问一些需要登录之后才能访问的页面时,Flask-Login 会重定向到这个属性设置的视图函数。由于 login
视图函数是在 auth
蓝图中设置的,所以这里要加上蓝图名。
最后,Flask-Login 要求应用程序定义一个函数,用于 Flask-Login 通过用户标识从数据库中加载用户。该函数如示例8-8所示:
# 示例 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))
login_manager.user_loader
装饰器用于将函数注册为 Flask-Login 的用户加载器。它将在需要获取已登录用户的信息时,由 Flask-Login 自动调用。其参数是一个字符串类型的用户ID,由于我们的数据库中将用户ID设计成了整数(db.Integer
),所以需要在通过 Flask-SQLAlcemy 查询用户数据时,将该参数转换成整数。这个函数必须返回一个 User
模型类的实例,如果该ID的用户不存在,则应返回 None
。
为了限制某些页面只允许已登录的用户访问,Flask-Login 提供了 login_required
装饰器。通过该装饰器可以对只允许已登录用户访问的路由进行保护。
from flask_login import login_required @app.route('/secret') @login_required def secret(): return '只允许已登录用户访问!'
注意此例中的 secret()
函数使用了多个装饰器,这称为装饰器链。其执行顺序由靠着函数名最近的装饰器开始,逐一执行。本例中,装饰器链的执行顺序是: login_required
-- app.route
。必须保证 login_required
第一执行,否则会出现错误。
登录表单由 Email 文本框、密码框、“记住我” 复选框和提交按钮组成。示例8-9是是该表单的表单类:
# 示例 8-9. app/auth/forms.py: 登录表单 from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired, Length, Email class LoginForm(FlaskForm): email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()]) password = PasswordField('密码', validators=[DataRequired()]) remember_me = BooleanField('让我保持登录') submit = SubmitField('登录')
定义好表单类之后,可以在其模板中显示了。通过 Flask-Bootstrap 的 wtf.quick_form()
宏,可以快速的在模板 app/templates/auth/login.html
中显示该表单。
基础模板 base.html
的导航栏,也要加上“登录”和“注销”两个链接。如示例8-10所示:
<!-- 示例 8-10. app/templates/base.html: 导航栏中的“登录”和“注销”两个链接--> <ul class="nav navbar-nav navbar-right"> {% if current_user.is_authenticated %} <li><a href="{{ url_for('auth.logout') }}">注销</a></li> {% else %} <li><a href="{{ url_for('auth.login') }}">登录</a></li> {% endif %} </ul>
current_user
由 Flask-Login 提供。它会自动将该变量的值设置为当前已登录的用户。这个变量在模板和视图函数中都可以直接使用。它的 is_authenticated
用于标识当前用户是已登录用户(True
)或是未登录的匿名用户(False
)。
示例 8-11 展示了用户登录视图函数 login()
的实现:
# 示例 8-11. app/auth/views.py: 用户登录路由 from flask import render_template, redirect, request, url_for, flash from flask_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) next = request.args.get('next') if next is None or not next.startswith('/'): next = url_for('main.index') return redirect(next) flash('错误的用户名或密码.') return render_template('auth/login.html', form=form)
Flask-Login 的 login_user()
函数用于将当前已登录的用户记录到 Session 中。第一个参数是当前用户的实例对象,第二个参数是可选的,用于标识是否记住该用户。如果第二个参数为 True,则即使关闭浏览器,该用户也保持登录状态——即通常说的“记住我”或“自动登录”功能。默认,登录状态会保持一年,可以通过 REMEMBER_COOKIE_DURATION
配置设置其它时间值。
警告:在生产环境中应该启用安全的 HTTP(即 HTTPS)。因为在 HTTP 中,包括用户密码在内的所有数据都是明文传输的,敏感数据可以被轻易截获;而 HTTPS 中则以加密形式进行传输。有关配置 HTTPS 的内容,请关注本站推出的视频教程。
登录模板需要进行修改,如示例8-12所示。
<!-- 示例 8-12. app/templates/auth/login.html: 登录表单的模板 --> {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} {% block title %}Flasky - 登录{% endblock %} {% block page_content %} <div class="page-header"> <h1>登录</h1> </div> <div class="col-md-4"> {{ wtf.quick_form(form) }} </div> {% endblock %}
示例8-13展示用户注销登录的实现。
# 示例 8-13. app/auth/views.py: 注销登录路由 from flask_login import logout_user, login_required @auth.route('/logout') @login_required def logout(): logout_user() flash('你已成功登出.') return redirect(url_for('main.index'))
Flask-Login 的 logout_user()
函数将 Session 进行删除和重置,以实现用户注销登录的功能。
如果你从 Github 克隆了示例代码,可以使用
git checkout 8c
签出本示例。
为了验证登录功能是否正常工作,首页可以修改为通过用户的名字输出问候信息。
<!-- 示例 8-14. app/templates/index.html: 问候已登录用户 -->
你好,
{% if current_user.is_authenticated %}
{{ current_user.username }}
{% else %}
陌生人
{% endif %}!
该模板再次使用了 current_user.is_authenticated
来判断用户是否登录。
由于还没有用户注册功能,所以暂时只能在 Shell 里创建一个新用户来测试登录功能。
(venv) $ $ flask shell >>> u = User(email='[email protected]', username='john', password='cat') >>> db.session.add(u) >>> db.session.commit()
当一个新用户要成为应用的新成员时,需要进行注册并能够登录。登录页面增加一个指向注册页面的链接,以方便用户注册。
# 示例 8-15. app/auth/forms.py: 用户注册表单 from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo from wtforms import ValidationError from ..models import User class RegistrationForm(FlaskForm): email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()]) username = StringField('用户名', validators=[ DataRequired(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, '用户名只能包含字母、数字、点或下划线')]) password = PasswordField('密码', validators=[ DataRequired(), EqualTo('password2', message='两次输入的密码不一致')]) password2 = PasswordField('重复密码', validators=[DataRequired()]) submit = SubmitField('注册') def validate_email(self, field): if User.query.filter_by(email=field.data).first(): raise ValidationError('Email已经被注册了') def validate_username(self, field): if User.query.filter_by(username=field.data).first(): raise ValidationError('用户名已经被注册了')
该表单类使用 WTForms 的 Regexp(正则表达式)
验证器来确保 username
只能以字母、数字、点或下划线开头。
WTForms 的 EqualTo
验证器用来确保两次输入的密码是相同的。
该表单类还有两个方法,用于实现自定义验证。自定义验证总是以 validate_<字段名>
的格式命名。当验证失败时,自定义验证应该抛出 ValidationError
异常。
用户注册的模板位于 app/templates/auth/register.html
,和登录页面类似,该模板只需要简单的使用 Flask-Bootstrap 提供的 wtf.quick_form()
即可快速完成表单的渲染。
用户登录页面需要加上用户注册链接。
<!-- 示例 8-16. app/templates/auth/login.html: 链接到注册页面 --> <p> 新用户? <a href="{{ url_for('auth.register') }}"> 点击这里进行注册 </a> </p>
新用户注册功能实现起来并不难。当用户提交表单后,将用户从注册表单提交的信息保存到数据库中即可。示例 8-17 展示了用户注册的视图函数实现。
# 示例 8-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) db.session.commit() flash('注册成功。你现在可以登录了') return redirect(url_for('auth.login')) return render_template('auth/register.html', form=form)
如果你从 Github 克隆了示例代码,可以使用
git checkout 8d
签出本示例。
为了保证注册用户的信息有效,许多应用都会对用户进行确认。其中常用的方式之一就是发送一封带有确认链接的邮件到用户填写的Email中。只有用户点击了该链接,用户才能完成注册并进行登录。
最简单的确认链接的格式是 http://www.example.com/auth/confirm/<id>
,其中的 <id>
是该用户保存在数据库中的主键值。当确认链接的视图函数接收到该 id
参数后,可以对数据库进行更新,使该用户的状态更新为“已确认”。
但这种方式明显不安全,用户完全可以通过该 URL 猜测到实现原理,进而通过使用随机数作为 id
值来进行批量确认。
回顾第4章所讨论的用户 Session,Flask 使用加密的已签名的 Cookie 来保护 Session 数据,防止数据被篡改。Cookie 包含一个生成签名密文的 itsdangerous
包。如果 Session 的内容被篡改,这个签名将会验证失败,那么 Flask 将丢失这个 Session,并生成一个新的 Session。这一概念同样适用于确认令牌。
下面的 Shell 会话展示了,如何使用 itsdangerous
来生成带有用户 ID 的、已签名的确认令牌。
(venv) $ flask shell >>> from itsdangerous import TimedJSONWebSignatureSerializer as Serializer >>> s = Serializer(app.config['SECRET_KEY'], expires_in=3600) >>> token = s.dumps({ 'confirm': 23 }) >>> token 'eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4MTcxODU1OCwiaWF0IjoxMzgxNzE0OTU4fQ.ey ...' >>> data = s.loads(token) >>> data {'confirm': 23}
itsdangerous
包提供多种令牌生成器。其中的 TimedJSONWebSignatureSerializer
类用于生成带过期时间的 JSON Web 签名(JWSs)
。这个类的构造函数需要一个密钥,在 Flask 应用中,可以使用配置中的 SECRET_KEY
。expires_in
参数指定令牌的过期时间,单位是秒。
dump()
方法,将接收到的参数作为原始数据,并为其生成加密签名,然后将数据和签名序列化方便使用的令牌字符串。
通过 loads()
方法,可对令牌进行解码,它所需要的唯一参数就是令牌字符串。该方法会对签名及过期时间进行验证,只有两者都通过验证,才会返回原始数据;否则抛出异常。
确认令牌的生成和验证功能,可以添加到 User
模型类中。
# Example 8-18. app/models.py: 用户账号确认 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from flask import current_app from . import db class User(UserMixin, db.Model): # ... confirmed = db.Column(db.Boolean, default=False) def generate_confirmation_token(self, expiration=3600): s = Serializer(current_app.config['SECRET_KEY'], expiration) return s.dumps({'confirm': self.id}).decode('utf-8') def confirm(self, token): s = Serializer(current_app.config['SECRET_KEY']) try: data = s.loads(token.encode('utf-8')) except: return False if data.get('confirm') != self.id: return False self.confirmed = True db.session.add(self) return True
generate_confirmation_token()
方法根据用户 ID 生成有效期为 1 小时的确认令牌;confirm()
方法用于验证确认令牌的有效性。同时,该方法还会对确认令牌里的用户 ID 和存储在 current_user
中的当前登录系统的用户 ID 进行比对,只有两个 ID 一致,才会对数据库进行更新。这样,能确保验证令牌不能用于其它用户。
由于新版的
User
模型添加了confirmed
字段,所以需要使用 Flask-Migrate 做一次数据库结构更新
当前的 /auth/register
在把新用户添加到数据库后,会重定向到 /index
。现在,该路由在重定向之前,需要先执行发送确认邮件的操作。
# 示例 8-19. app/auth/views.py: 含有发送确认邮件的用户注册路由 from ..email import send_email @auth.route('/register', methods=['GET', 'POST']) def register(): form = RegistrationForm() if form.validate_on_submit(): # ... db.session.add(user) db.session.commit() token = user.generate_confirmation_token() send_email(user.email, '确认你的账户', 'auth/email/confirm', user=user, token=token) flash('一封确认邮件已经发送到你的邮箱中。') return redirect(url_for('main.index')) return render_template('auth/register.html', form=form)
原书正文描述的是重定向到
/index
,但书中之前列出的用户注册视图函数里,重定向的却是/auth/login
。同时,示例8-19代码中写的是又重定向到/index
。本站对此作出提醒,并沿用原书中的描述,重定向到/index
,即main.index
注意,在生成确认令牌前,就需要将数据保存到数据库中。不然,无法获取到用户的 ID。
邮件模板存储在 auth
蓝图子目录下。为了和普通模板作区分,特将其放在 email
子目录下,即 app/templates/auth/email/
。
# 示例 8-20. app/templates/auth/email/confirm.txt: 确认邮件的正文 亲爱的 {{ user.username }}, 欢迎来到 Flasky! 请点击下面的链接来确认你的账号: {{ url_for('auth.confirm', token=token, _external=True) }} 此致, Flasky 团队 提示: 请不要直接回复本邮件.
默认情况下,url_for()
生成的是相对URL,比如:url_for('auth.confirm', token='abc')
生成的是 /auth/confirm/abc
。这种相对 URL 发送到邮箱里,是无效的。我们需要生成带有域名和协议的绝对 URL。此时,需要给 url_for()
传入关键字参数 _external=True
。
# 示例 8-21. app/auth/views.py: 确认用户账号 from flask_login import current_user @auth.route('/confirm/<token>') @login_required def confirm(token): if current_user.confirmed: return redirect(url_for('main.index')) if current_user.confirm(token): db.session.commit() flash('你的账号确认成功,谢谢!') else: flash('确认链接无效或已过期.') return redirect(url_for('main.index'))
这个路由通过 login_required
装饰器进行了保护,所以需要用户先进行登录。
该功能首先检查登录用户是否已经确认,并在这种情况下重定向到主页,显然没有什么可做的。 如果用户错误地点击确认令牌多次,这可以防止不必要的工作。
由于令牌的验证实际上是在 User
模型中完成的,视图函数 confirm()
只需要调用模型中的相关方法,并根据返回的结果来设置不同的闪现消息即可。一旦确认成功,User
模型中的 confirmed
属性将被设置为 True
,并将其更新到 Session 中,同时保存到数据库里。
应用程序可以按需决定未确认的用户可以执行哪些操作。一种可能性是允许未经确认的用户登录,但只向他们显示一个页面,要求他们确认他们的账户,然后才能进一步操作。
这个功能可以借助第2章讨论的 Flask 的 before_request
钩子来实现。在蓝图中,before_request
只对该蓝图有效,要使整个应用都有效,需要使用 before_app_request
钩子。
# 示例 8-22. app/auth/views.py: 通过 before_app_request 处理器来过滤未确认用户 @auth.before_app_request def before_request(): if current_user.is_authenticated \ and not current_user.confirmed \ and request.blueprint != 'auth' \ and request.endpoint != 'static': return redirect(url_for('auth.unconfirmed')) @auth.route('/unconfirmed') def unconfirmed(): if current_user.is_anonymous or current_user.confirmed: return redirect(url_for('main.index')) return render_template('auth/unconfirmed.html')
before_app_request
处理会拦截满足以下条件之一的请求:
current_user.is_authenticated is True
auth
蓝图并且请求的 URL 不是一个静态文件# 示例 8-23. app/auth/views.py: 重新发送用户确认邮件 @auth.route('/confirm') @login_required def resend_confirmation(): token = current_user.generate_confirmation_token() send_email(current_user.email, '确认你的账号', 'auth/email/confirm', user=current_user, token=token) flash('一封新的确认邮件已发送到你的邮箱.') return redirect(url_for('main.index'))
如果你从 Github 克隆了示例代码,可以使用
git checkout 8e
签出本示例。
用户可能需要不时地更改账号信息。可以使用本章介绍的技术将以下任务添加到身份验证蓝图中:
本文并非《Flask Web Development(2nd edition)》的全文翻译。而只是在阅读该书时,对自认为重点的内容进行记录以及思考。由于水平有限,本文所述内容难免出现不足或错误。请通过购买正版图书来进行更系统地学习,原书地址。本站将依照本系列文章录制视频教程,届时本文未作记录的部分以及项目实际开发中所需要的知识都会在视频教程中进行补充。