Flask的认证拓展
大多数程序都要进行用户跟踪,用户连接程序时都会进行身份认证,通过这认证让程序知道自己的身份,然后根据身份提供相对的体验,最常用的认证方法要求用户提供一个身份证明和一个密码,优秀的Python认证包很多,但没有一个能实现所有功能,这里介绍的认证方案使用了多个包,并编写了胶水程序让它良好运作,使用的包列表如下:
- Flask-Login:管理已登录用户的用户会话
- Werkzeug:计算密码散列值并进行核对
- itsdangerous:生成并核对加密安全令牌
除了认证相关的包以外,还有下列常规用途的拓展:- Flask-Mail:发送与认证相关的电子邮件
- Flask-Bootstrap:HTML模板
- Flask-WTF:web表单
密码安全性
设计web程序时,人们往往会高估数据库中用户信息的安全性,如果攻击者入侵服务器获取了数据库,用户的安全就处在风险之中,众所周知,大多数用户都在不同的网站中使用相同的密码,因此,即便不保存任何敏感信息,攻击者获得存储在数据库中的密码之后,也能访问用户在其他网站中的账户
若想保证数据库中用户密码的安全,关键在于不能存储密码本身,而要存储密码的散列值,计算密码散列值的函数接受密码作为输入,使用一种或多种加密算法转换密码,最终得到一个和原始密码没有关系的字符序列,核对密码时,密码散列值可代替原始密码,因为计算散列值的函数是可复现:是要输入一样,结果就一样
计算密码散列值是个复杂的任务,很难正确处理,因此建议不要自己实现,而是使用经过社区成员审查且声誉良好的库
使用Werkzeug实现密码散列
Werkzeug中的security模块能够能方便的实现密码散列值的计算,这一功能的实现只需要两个函数,分别用在注册用户验证和用户验证阶段
generate_password_hash(password, method=pbkdf2:sha1, salt_length=8):
这个函数将原始密码作为输入,以字符串形式输出密码的散列值,输出的值可保存在用户数据库中,
method
和salt_length
方法的默认值就能满足大多数需求- check_password_hash(hash, password):
这个函数的参数是从数据库中取回的密码散列值和用户输入的密码,返回值为True
表明密码正确
下例是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 is not a readable attribute')
@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
,说明密码是正确的
密码散列功能已经完成,可以在shell里测试:
(env) PS D:\flasky> python manage.py shell
>>> u = User()
>>> u.password = 'fang'
>>> u.password_hash
'pbkdf2:sha1:1000$ZvfamwXs$77d1ded9771b39bdcf7eec57ca508afd18188e75'
>>> u.verify_password('fang')
True
>>> ue = User()
>>> ue.password = 'fang'
>>> ue.password_hash
'pbkdf2:sha1:1000$3fY4i1ps$5fa0bc975a342d0da68a7c9888a75fe70bcd7042'
可以看出,即使用户u和ue使用了相同的密码,它们的密码散列值也完全不一样,为了确保这个功能今后可持续使用,我们可以把上述测试写成单元测试,以便于重复执行
我们要在tests
包中新建一个模块,编写几个新测试,测试最近对User
模型所做的修改:
import unittest
from app.models import User
class UserModelTestCase(unittest.TestCase):
def test_password_setter(self):
u = User(password = 'fang')
self.assertTrue(u.password_hash is not None)
def test_no_password_getter(self):
u = User(password = 'fang')
with self.assertRaises(AttributeError):
u.password
def test_password_verification(self):
u = User(password = 'fang')
self.assertTrue(u.verify_password('fang'))
self.assertFalse(u.verify_password('cat'))
def test_password_salts_are_random(self):
u = User(password='fang')
u2 = User(password='fang')
self.assertTrue(u.password_hash != u2.password_hash)
创建认证蓝本
把创建程序的过程移入工厂函数后,可以使用蓝本在全局作用域中定义路由,与用户认证系统相关的路由可在auth
蓝本中定义,对于不同的程序功能,我们要使用不同的蓝本,这可以保持代码整齐有序
auth蓝本保存在同名Python包中,蓝本的包构造文件创建蓝本对象,再从views.py
模块中引入路由:
# app/auth/__init__.py
from flask import Blueprint
auth = Blueprint('auth', __name__)
from . import views
app/auth/views.py
模块引入蓝本,然后使用蓝本的route装饰器定义与认证相关的路由
下例代码中添加了一个/login
路由,渲染同名占位模版:
from flask import render_template
from . import auth
@auth.route('/login')
def login():
return render_template('auth/login.html')
要强调的是,为render_template()
指定的模版文件保存在auth
文件夹中,这个文件夹必须在app/templates
中创建,因为Flask认为模板的路径是相对于程序模板文件夹而言的,为了避免与main蓝本和后续添加的蓝本发生模板命名冲突,可以把蓝本使用的模板保存在单独的文件夹中
我们也可将蓝本配置成使用其独立的文件夹保存模板,如果配置了多个模板文件夹,
render_template()
函数会首先搜索程序配置的模板文件夹,然后再搜索蓝本配置的模板文件夹
auth蓝本要在creat_app()
工厂函数中附加到程序上,如下例所示:
def create_app(config_name):
# ...
from .auth import auth as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth')
return app
注册蓝本时使用的url_prefix
是可选参数,如果使用了这参数,注册后蓝本中定义的所有路由都会加上特定的前缀,即这个例子中的/auth
,例如,/login
路由会注册成/auth/login
, 在开发web服务器中,完整的URL就变成了http://localhost:5000/auth/login
使用Flask-Login认证用户
用户登录程序后,它们的认证状态要被记录下来,这样浏览不同的页面时才能记住这个状态,Flask-Login是个非常有用的小型拓展,专门用来管理用户认证系统中的认证状态,而且不依赖特定的认证机制
先安装:
(venv) $ pip install flask-login
准备用于登录的用户模型
要想使用Flask-Login拓展,程序的User模型必须实现几个方法:
方法 | 说明 |
---|---|
is_authenticated() | 如果用户已经登录,必须返回True,否则返回False |
is_active() | 如果允许用户登录,必须返回True,否则返回False |
is_anonymous() | 对普通用户必须返回False |
get_id() | 必须返回用户的统一标识符,使用Unicode编码字符串 |
这4个方法可以在模型类中作为方法直接实现,不过还有一种更简单的替代方案,Flask-Login提供了一个UserMixin
类,其中包含这些方法的默认实现,且能满足大多数需求,修改后的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)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
password_hash = db.Column(db.String(128))
上例中同时还添加了email字段,在这个程序中,用户使用电子邮件地址登录,因为相对于用户名而言,用户更不容易忘记自己的电子邮件地址
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):
app = Flask(__name__)
# ...
login_manager.init_app(app)
return app
LoginManager对象的session_protection
属性可以设为None
、'basic
‘或'strong'
,以提供不同的安全等级防止用户会话遭篡改
设为'strong'
时,Flask-Login会记录客户端IP地址和浏览器的用户代理信息,如果发现异动就登出用户,login_view
属性设置登陆页面的端点,登录路由在蓝本中定义,因此要在前面加上蓝本的名字
最后Flask-Login要求程序实现一个回调函数,使用特定的标识符加载用户,这个函数定义如下:
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类如下:
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()
宏渲染表单即可登录表单在浏览器中渲染的样子如下
base.html
模板中的导航条使用Jinja2条件语句,并根据当前用户的登陆状态分别显示”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()
的实现如下:
# 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)
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对象,用法和简单表单一样,当请求类型是GET时,视图函数直接渲染模板,即显示表单,当表单在POST请求中提交时,Flask-WTF中的validate_on_submit()
函数会验证表单数据,然后尝试登入用户
为了登入用户,视图函数首先使用表单中填写的email从数据库中加载用户,如果电子邮件地址对应的用户存在,再调用用户对象的verify_password()
方法,其参数是表单中填写的密码,如果密码正确,则调用Flask-Login中的login_user()
函数,在用户会话中把用户标记为已登录,login_user()
函数的参数是要登录的用户,以及可选的’记住我’布尔值,’记住我’也在表单中填写, 如果值为False,那么关闭浏览器后用户会话就过期了,所以下次用户访问时要重新登陆,如果值为True,那么会在用户浏览器中写入一个长期有效的cookie,使用这个cookie可以复现用户会话
按照之前介绍的”Post/重定向/Get模式“,提交登录密令的POST请求最后也做了重定向,不过目标URL有两种可能,用户访问未授权的URL时会显示登录表单,Flask-Login会把原地址保存在查询字符串的next
参数中,这个参数可从request.args
字典中读取,如果查询字符串中没有next
参数,则重定向到首页,如果用户输入的电子邮件或密码不正确,程序会设定一个Flash消息,再次渲染表单,让用户重新登录
在生产服务器上,登录路由必须使用安全的HTTP,从而加密传送给服务器的表单数据,如果没使用安全的HTTP,登录密令在传输过程中可能会被截取,在服务器上花再多的精力用于保证密码安全都无济于事
我们需要更新登录模板以渲染表单,如下:
# 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 %}
登出用户
退出路由的实现如下:
from flask_login import login_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消息,确认这次操作,再重定向到首页,这样就完成了登出
测试登录
为验证登录功能可用,可以更新首页,使用已登录用户的名字显示一个欢迎消息
Hello,
{% if current_user.is_authenticated %}
{{ current_user.username }}
{% else %}
Stranger
{% endif %}
这个模板中再次使用current_user.is_authenticated
判断用户是否登录
目前我们还没创建用户注册功能。所以新用户可先在shell中注册:
>>> u = User(email='xxxxxxx@126.com', username='admin', password='123456')
>>> db.session.add(u)
>>> db.session.commit()
登录用户后的页面: