这一次,我们完成用户认证的功能:
1. 将密码在数据库中加密:
程序要进行用户追踪,程序知道用户是谁之后,就能针对性的提供体验。需要用户提供用户名和密码。
要是想保证数据库中存放密码的安全性,那么就不存放明文密码,存放密码的散列值,我们使用Werkzeug来实现密码散列:
所以我们改变models.py中的User模型来支持密码散列
#coding: utf-8
from . import db
from werkzeug.security import generate_password_hash, check_password_hash
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
users = db.relationship('User', backref='role', lazy='dynamic')
def __repr__(self):
return '<Role %r>' % self.name
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=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))
@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) # 对比数据库中密码散列值和用户输入的密码,正确返回True
def __repr__(self):
return '<User %r>' % self.username
2. 创建认证蓝本:
与用户认证相关的路由可以在auth蓝本中定义,对于不同程序功能,我们尽量使用不同的蓝本。所以创建app目录下的auth文件夹,前面创建的main文件夹是主页基础功能。
这里创建蓝本的方式与前面差不多:
在auth/init.py中:
from flask import Blueprint
auth = Blueprint('auth', __name__)
from . import views
在auth/views.py中:
from flask import render_template
from . import auth
@auth.route('/login')
def login():
return render_template('auth/login.html')
# 这里需要注意了,这个模板文件需要保存在auth这个文件夹中
# 但是这个文件夹又需要保存在app/templates中
# flask认为模板的路径是相对于程序模板文件夹而言的。
最后需要在create_app()中将蓝本注册到程序上:
from .auth import auth as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth')
# 这里加上了prefix,注册后蓝本中定义的所有路由都会加上这个前缀
# 所有views中定义的/login会变成/auth/login
3. 使用Flask-Login拓展来认证用户:
用户登录程序后,他们的认证状态要被记录下来,这样浏览不同的页面时才能记住这个状态。
要想使用Flask-Login,程序User模型必须实现几个方法,这个拓展提供了一个UserMixin类,包含了方法的默认实现。
修改User模型:
class User(UserMixin, db.Model): # 下面保持不变,新增一个email字段
然后我们需要在工厂函数中初始化flask-login:
login_manager = LoginManager() # 创建一个登录实例
login_manager.session_protection = 'strong'
login_manager.login_view = 'auth.login' # 设置登录页面的端点,路由在蓝本中定义,所以要加上蓝本的名字
最后在create_app()中初始化app:
login_manager.init_app(app)
最后flask-login要求程序使用指定的标识符加载用户:这是一个回调函数。返回我们正在登录的这个user对象,这个方法我们在models.py中定义了
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
然后我们需要创建登录时用到的表单: 因为属于auth功能,所以写入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('Password', validators=[DataRequired()])
remember_me = BooleanField('Keep me logged in')
submit = SubmitField('Log In')
这里只是表单的功能,我们还需要一个html来展示表单的页面,我们放在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 %}
然后我们需要在auth的视图函数中关联上表单,因为我们使用了Flask-Login这个拓展来认证用户,所以我们遵循这个拓展的使用方法,我们首先看Flask-Login官网上面的例子:
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
# Login and validate the user.
# 这里的user需要是User类的一个实例,所以上面一定有一些代码能得到这个实例对象
login_user(user) # 这个函数的作用是将这个user登入,第二个参数是是否在他的session过期后依然记住这个user
flask.flash('Logged in successfully.')
next = flask.request.args.get('next')
# is_safe_url should check if the url is safe for redirects.
# See http://flask.pocoo.org/snippets/62/ for an example.
if not is_safe_url(next):
return flask.abort(400)
return flask.redirect(next or flask.url_for('index'))
return flask.render_template('login.html', form=form)
在使用了login_user()将user登录之后,我们可以在任何地方使用current_user来获取当前用户,然后任何需要用户登录状态下才能实现的功能都可以用login_required装饰器来定义!!!!
然后我们改进成适用于我们程序的方法:
@auth.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm() # 创建一个对象
# get请求时,视图函数直接渲染模板显示表单
# POST请求时,拓展的下面这个函数会验证表单数据
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,拓展的官方文档说If you do not, your application will be vulnerable to open redirects
# 然后我尝试直接使用return redirect(url_for('main.index'))暂时没有发现问题
next = request.args.get('next')
if next is None or not next.startswith('/'):
next = url_for('main.index')
return redirect(next)
flash('Invalid username or password.')
return render_template('auth/login.html', form=form)
然后我们运行,manager.py发现报错:'A secret key is required to use CSRF.'
,所以我们需要在config文件中加上SECRET_KEY = ‘’
结果运行成功!!!!!
然后我们需要一个登出的路由,重定向到首页:
@auth.route('/logout')
@login_required
def logout():
logout_user() # 这也是flask-login拓展提供的将当前用户登出的方法
flash('you logged out')
return redirect(url_for('main.index'))
所以,总结使用Flask-login来认证用户的方法:
在登录的时候,首先获取一个当前用户的user实例,在登录的时候,使用login_user()来将这个user实例登录到应用里面,在使用的过程中使用current_user来获取当前用户,在需要登录状态的前提下实现的功能使用login_required装饰器来装饰,在需要将用户退出登录状态的时候,使用logout_user()来登出。
4. 下面我们实现注册新用户的功能:
添加用户注册表单:
class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Length(1, 64),
Email()])
username = StringField('Username', validators=[
DataRequired(), 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=[
DataRequired(), EqualTo('password2', message='Passwords must match.')])
password2 = PasswordField('Confirm password', validators=[DataRequired()])
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.')
这还是只实现了注册新用户的功能,我们需要一个注册新功能的展示页面:
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Register{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Register</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
然后我们还需要登录页面能有一个按钮能让用户跳转到注册页面:
<br>
<p>New user? <a href="{{ url_for('auth.register') }}">Click here to register</a>.</p>
最后别忘了,我们什么都准备好了,只差完成注册部分的路由,这里记住,一般做好了路由会想到怎么样才会让这个路由被调用呢?答案是我们在模板里面使用a标签结合url_for()来调用这个路由
@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('You can now login.')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
5. 实现注册后需要到邮箱确认账号的功能:
向用户邮箱发送确认注册邮件:
用户注册后,新账户首先被标记为待确认状态,需要用户按照收到邮件中的说明操作后,才可以证明自己被联系上。往往用户只需要点击一个包含确认令牌的特殊URL:
使用itsdangerous生成确认令牌:
这玩意儿有很多生成令牌的方法,其中TimedJSONWebSignatureSerializer类生成具有过期时间的JSON web签名
所以我们将生成和检验这种令牌的功能添加到User模型:
from itsdangerous import TimedJSONWebSignatureSerializer
from flask import current_app
数据表新增一个字段:
confirmed = db.Column(db.Boolean, default=False)
# 生成一个令牌,有效期默认一小时
def generate_confirmation_token(self, expiration=3600):
# 下面生成具有过期时间的JSON web签名
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'confirm': self.id}) # 为指定的数据生成一个加密签名,然后生成令牌字符串
# 检验令牌
def confirm(self, token):
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token) # 这个方法会检验签名和过期时间,如果通过就返回原始数据
except:
return False
if data.get('confirm') != self.id:
return False
# 这里是核对成功后,将user的confirmed字段变成True
self.confirmed = True
db.session.add(self)
db.session.commit()
return True
现在令牌已经做出来了,我们需要向用户发送确认邮件,之前我们注册后就直接重定向到index,现在我们需要在这之前,发送一封确认邮件:
@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('You can now login.')
# return redirect(url_for('auth.login'))
# 现在我们要生成令牌然后发送邮件
token = user.generate_confirmation()
send_email(user.email, 'Confirmation of your new account', 'auth/email/confirm', user=user, token=token)
# 这里给用户发送邮件:收件人,邮件标题,邮件模板,给模板传的参数
# 一个电子邮件需要两个模板,分别用于渲染纯文本正文和富文本正文
flash('we have sent a confirmation email to you, please confirm it!!!')
return redirect(url_for('main.index'))
return render_template('auth/register.html', form=form)
邮件模板内容:
<p>Dear {{ user.username }},</p>
<p>Welcome to <b>Flasky</b>!</p>
<p>To confirm your account please <a href="{{ url_for('auth.confirm', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.confirm', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>
这里在邮件里面用户点击按钮会打开网页,路由为auth.confirm。
所以接下来,我们还需要实现这个路由功能:
# 这个是发送给用户的邮件中的路由链接
from flask_login import current_user
@auth.route('/confirm/<token>')
@login_required # 这个修饰器会保护这个路由,只有用户打开链接登陆后,才可以执行下面的视图函数
def confirm(token):
# 这里是检查confirmed字段!!!
if current_user.confirmed:
# 首先检查登录的用户是否已经确认过,如果已经确认过,就不用再做什么工作了,直接重定向到首页
return redirect(url_for('main.index'))
# 这里是使用confirm方法!!!
if current_user.confirmed(token):
# 直接调用user模型中的验证令牌方法,直接使用flash来显示验证结果。
flash('you have confirmed your acount!')
else:
flash('The confirmation link is invalid or it has expired')
return redirect(url_for('main.index'))
处理用户没有进行邮件认证直接登录的问题
这里有一个问题,如果用户没有在邮件里面确认注册就直接登录,我们可以让用户登录到一个页面,在页面里面告诉用户需要去邮箱里面确认
这个步骤我们可以使用before_request来完成,但是这个只能应用到属于蓝本的请求上,如果要在蓝本中使用针对全局请求的钩子,则需要使用before_app_request修饰器:
# 这个部分处理请求前验证账号是否被激活
@auth.before_app_request
def before_request():
if current_user.is_authenticated \
and not current_user.confirmed \
and request.endpoint \
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')
这只是实现了功能,我们还是需要模板:
{% extends "base.html" %}
{% block title %}Flasky - Confirm your account{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>
Hello, {{ current_user.username }}!
</h1>
<h3>You have not confirmed your account yet.</h3>
<p>
Before you can access this site you need to confirm your account.
Check your inbox, you should have received an email with a confirmation link.
</p>
<p>
Need another confirmation email?
<a href="{{ url_for('auth.resend_confirmation') }}">Click here</a>
</p>
</div>
{% endblock %}
可以看到这个模板有一个链接,功能是再次发送邮件,我们来定义这个路由:
# 这个部分是告诉账户未激活账户页面的链接,用于再次发送确认邮件
@auth.route('/confirm')
@login_required
def resend_confirmation():
token = current_user.generate_confirmation_token()
send_email(current_user.email, 'Confirm Your Account',
'auth/email/confirm', user=current_user, token=token)
flash('A new confirmation email has been sent to you by email.')
return redirect(url_for('main.index'))
6. 用户角色
因为在web程序中,并不是所有的用户都有同样的地位,就比如管理员有着supreme的地位。
首先改进Role模型:
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
default = db.Column(db.Boolean, default=False, index=True)
permissions = db.Column(db.Integer)
users = db.relationship('User', backref='role', lazy='dynamic')
def __repr__(self):
return '<Role %r>' % self.name
然后列出用户的权限,权限用数字表示
class Permission:
FOLLOW = 1
COMMENT = 2
WRITE = 4
MODERATE = 8
ADMIN = 16
现在我们可以创建角色了,但是将角色手动添加到数据库很麻烦,所以我们在Role类中添加一个方法来完成这个功能。
@staticmethod
def insert_roles():
# 这个函数并不是直接创建新的角色,而是通过角色名来查询现有的角色,再进行更新
roles = {
'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
'Moderator': [Permission.FOLLOW, Permission.COMMENT,
Permission.WRITE, Permission.MODERATE],
'Administrator': [Permission.FOLLOW, Permission.COMMENT,
Permission.WRITE, Permission.MODERATE,
Permission.ADMIN],
}
default_role = 'User'
for r in roles:
role = Role.query.filter_by(name=r).first()
if role is None:
role = Role(name=r)
role.reset_permissions()
for perm in roles[r]:
role.add_permission(perm)
role.default = (role.name == default_role)
db.session.add(role)
db.session.commit()
def add_permission(self, perm):
if not self.has_permission(perm):
self.permissions += perm
def remove_permission(self, perm):
if self.has_permission(perm):
self.permissions -= perm
def reset_permissions(self):
self.permissions = 0
def has_permission(self, perm):
return self.permissions & perm == perm
下面我们给用户赋予角色,大多数用户在注册时候被赋予的角色就是普通用户,但是当某一个邮箱出现用来注册的时候,那就直接赋予管理员权限,所以给User类添加一个初始化方法:
def __init__(self, **kwargs):
super(User, self).__init__(**kwargs)
if self.role is None:
if self.email == current_app.config['FLASKY_ADMIN']:
self.role = Role.query.filter_by(name='Administrator').first()
if self.role is None:
self.role = Role.query.filter_by(default=True).first()
下面我们实现角色验证功能,即特定的路由只能让拥有特定权限的用户才可以使用。
首先我们在User中增加一个方法用来检查是否有指定的权限:
def can(self, perm):
return self.role is not None and self.role.has_permission(perm)
def is_administrator(self):
return self.can(Permission.ADMIN)
class AnonymousUser(AnonymousUserMixin):
def can(self, permissions):
return False
def is_administrator(self):
return False
login_manager.anonymous_user = AnonymousUser
现在如果我们想让特定的视图函数只对特定的用户开放,那么我们就需要自定义一个装饰器
在app目录下新建一个decorators.py文件:
from functools import wraps
from flask import abort
from flask_login import current_user
from .models import Permission
def permission_required(permission):
# 用来检查常规权限
def decorator(f):
@wraps(f)
# 使用装饰器时,被装饰后的函数已经是另外一个函数了,函数名等函数属性会发生变化
# 这样的改变会对测试有所影响,所以这个wraps装饰器可以消除这样的副作用。
def decorated_function(*args, **kwargs):
if not current_user.can(permission):
abort(403) # 用户没有权限就返回403错误码,即HTTP禁止错误
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
# 专门用来检查管理员权限
return permission_required(Permission.ADMIN)(f)
那么如何使用我们刚刚定义的装饰器呢?
from decorators import admin_requires, permission_required
from .models import Permission
@main.route('/admin')
@login_required
@admin_required
def for_admins_only():
return 'For administrators!'