注册新用户
如果新用户想成为程序的成员,必须在程序中注册,这样程序才能识别并登入用户,程序的登陆页面中要显示一个链接,把用户带到注册页面,让用户输入电子邮件地址,用户名和密码
添加用户注册表单
注册页面使用的表单要求用户输入电子邮件地址、用户名和密码,如下:
from flask_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='Pass word 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()
渲染表单,注册页面如下所示
登录页面要显示一个指向注册页面的链接,让没有账户的用户能轻易找到注册页面,改动如下:
# app/templates/auth/login.html
# ...
<p>
New User?
<a href='{{ url_for("auth.register") }}'>
Click here to register
</a>
</p>
注册新用户
处理用户注册的过程没有什么难点,提交注册表单,通过验证后,系统就使用用户填写的信息在数据库中添加一个新用户,处理这个任务的视图函数如下:
# 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)
确认账户
对于某些特定类型的程序,有必要确认注册时用户提供的信息是否正确,常见要求是能通过提供过的电子邮件地址与用户取得联系
为验证电子邮件地址,用户注册后,程序会立即发送一封确认邮件,新账户先被标记成待确认状态,用户按照邮件中的说明操作后,才能证明自己可以被联系上,账户确认过程中,往往会要求用户点击一个包含确认令牌的特殊URL链接
使用itsdangerous生成确认令牌
确认邮件中最简单的确认链接是http://www.example.com/auth/confirm/<id>
这种形式的URL,其中id是数据库分配给用户的数字id,用户点击链接后,处理这个路由的视图函数就将收到的用户id作为参数进行确认,然后将用户状态更新为已确认
但这种实现方式显然不是很安全,只要用户能判断确认链接的格式,就可以随便指定URL中的数字,从而确认任意账户,解决方法是把URL中的id换成将相同信息安全加密后得到的令牌
之前提到的对用户会话的讨论,Flask使用加密的签名cookie保护用户会话,防止被篡改,这种安全的cookie使用itsdangerous
包签名,同样的方法也可用于确认令牌上
下面这个简短的shell会话显示了如何使用itsdangerous
包生成包含用户id的安全令牌:
>>> from manage import app
>>> from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
>>> s = Serializer(app.config['SECRET_KEY'], expires_in = 3600)
>>> token = s.dumps({ 'confirm': 23})
>>> token
'eyJhbGciOiJIUzI1NiIsImV4cCI6MTQ3MTc1NDA3OSwiaWF0IjoxNDcxNzUwNDc5fQ.eyJjb25maXJtIjoyM30.Ucw75bXsyTr5iT6ktRR
wNv00aSe6vQ7LKPCRqemSUiE'
>>> data = s.loads(token)
>>> data
{u'confirm': 23}
itsdangerous提供了多种生成令牌的方法,其中TimedJSONWebSignatureSerializer
类生成具有过期时间的JSON Web签名(JSON Web Signatures, JWS),这个类的构造函数接收的参数是一个密钥,在Flask程序中可是使用SECRET_KEY
设置
dumps()
方法为指定的数据生成一个加密签名,然后再对数据和签名进行序列化,生成令牌字符串,expires_in
参数设置令牌的过期时间,单位为秒
为了解码令牌,序列化对象提供了loads()
方法,其唯一的参数是令牌字符串,这个方法会检验签名和过期时间,如果通过,返回原始数据,如果提供给loads()
方法的令牌不正确或过期,则抛出异常
我们可以将这种生成和检验令牌的功能添加到User模型中,如下:
# app/models.py
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from .import db, login_manager
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from flask import current_app
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 })
def confirm(self, token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
except:
return False
if data.get('confirm') != self.id:
return False
self.confirmed= True
db.sesiion.add(self)
return True
generate_confirmation_token()
方法生成一个令牌,有效期默认为一小时,confirm()
方法检验令牌,如果检验通过,则把新添加的confirmed
属性设为True
除了检验令牌,confirm()
方法还检查令牌中的id是否和存储在current_user
中的已登录用户匹配,如此依赖,即使恶意用户知道如何生成签名令牌,也无法确认别人的账户
由于模型中新加入了一个列用来保存账户的确认状态,因此要生成并执行一个新数据库迁移
User模型中新添加的两个方法也可以进行单元测试
发送确认邮件
当前的/register
路由把新用户添加到数据库中后,会重定向到/index
,在重定向之前,这个路由需要发送确认邮件,改动如下:
from ..email import send_email
@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()
token = user.genarate_confirmation_token()
send_email(user.email, 'Confirm Your Account',
'auth/email/confirm', user=user, token=token)
flash('A confirmation email has been sent you by email.')
return redirect(url_for('main.index'))
return render_template('auth/register.html', form=form)
注意,即便通过配置,程序已经可以在请求末尾自动提交数据库变化,这里也要添加db.session.commit()
调用,问题在于提交数据库之后才能赋予新用户id值,而确认令牌需要用到id,所以不能延后提交
认证蓝本使用的电子邮件模板保存在templates/auth/email
文件夹中以便和HTML模板区分开来,之前介绍过,一个电子邮件需要两个模板,分别用于渲染纯文本正文和富文本正文,举个栗子,确认邮件的纯文本版本:
Dear {{ user.username }},
Welcome to Flasky!
To confirm your account please click on the following link:
{{url_for('auth.confirm'), token=token, _external=True }}
Sincerely,
The Flasky Team
PS: replies to thi
默认情况下,url_for()
生成相对URL,例如url_for('auth.confirm', token='abc')
,返回的字符串是'/auth/confirm/abc'
,这显然不是能够在电子邮件中发送的正确URL,相对URL在网页的上下文中可以正常使用,因为通过添加当前页面的主机名和端口号,浏览器会将其转换成绝对URL,但通过电子邮件发送URL时,并没有这种上下文,添加到url_for()
函数中的_external=True
参数要求程序生成完整的URL,其中包含协议(http://
或 https://
)、主机名和端口
添加确认账户的视图函数如下:
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):
flash('You have confirmd your account. Thanks')
else:
flash('The confirmation link is invalid or has expired')
return redirect(url_for('main.index'))
Flask-Login提供的login_required
装饰器会保护这个路由,因此,用户点击确认邮件中的链接后,要先登录,然后才能执行这个视图函数
这个函数先检查已登录的用户是否已经确认过,如果确认过,则重定向到首页,因为很显然此时不用做什么操作,这样处理可以避免用户不小心多次点击确认令牌带来的额外工作
由于令牌确认完全在User模型中完成,所以视图函数只需调用confirm()
方法即可,然后再根据确认结果显示不同的Flash消息,确认成功后,User模型中的confirmed属性的值会被修改并添加到会话中,请求处理完后,这两个操作被提交到数据库
每个程序都可以决定用户确认账户之前可以做哪些操作,比如允许未确认的用户登录,但只显示一个页面,这个页面要求用户在获取权限之前先确认账户
这一步可使用Flask提供的before_request
钩子完成,对蓝本来说,before_request
钩子只能应用到属于蓝本的请求上,若想在蓝本中使用针对程序全局请求的钩子,必须使用before_app_request
装饰器,下例展示了如何实现这个处理程序:
# app/auth/views.py
# ...
@auth.before_app_request
def before_request():
if current_user.is_authenticated \
and not current_user.confirmed \
and request.endpoint[:5] != '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')
同时满足以下3个条件时,before_app_request
处理程序会拦截请求
1.用户已登录(
current_user.is_authenticated()
必须返回True)
2.用户的账户还未确认
3.请求的端点(使用request.endpoint
获取)不在认证蓝本中,访问认证路由要获取权限,因为这些路由的作用是让用户确认账户或执行其他账户管理操作
如果请求满足以上3个条件,则会被重定向到/auth/unconfirmed
路由,显示一个确认账户相关信息的页面
如果
before_request
或before_app_request
的回调返回响应或重定向,Flask会直接将其发送至客户端,而不会调用请求的视图函数,因此,这些回调可在必要时拦截请求
显示给未确认用户的页面只渲染一个模板,其中有如何确认账户的说明,此外还提供了一个链接,用于请求发送新的确认邮件,以防之前的邮件丢失,重新发送确认邮件的路由如下
# app/auth/views.py
@auth.route('/confirm')
@login_required
def resend_confirmation():
token = current_user.generate_confirmation_token()
send_mail(current_user.mail, 'Cpmfor, Upir 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'))
这个路由为current_user
(即已经登陆的用户,也就是目标用户)重做了一遍注册路由中的操作,这个路由也用login_required
保护,确保访问时程序知道请求再次发送邮件的是哪个用户
管理账户
拥有程序账户的用户有时可能需要修改账户信息,下面这些操作可以使用学过的技术添加到验证蓝本中
修改密码
安全意识强的用户可能希望定期修改密码,这是一个很容易实现的功能,只要用户处于登录状态,就可以放心显示一个表单,要求用户输入旧密码和替换的新密码
views.py
部分
from .forms import ChangePasswordForm
#...
@auth.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
form = ChangePasswordForm()
if form.validate_on_submit():
if current_user.verify_password(form.old_password.data):
current_user.password = form.password.data
db.session.add(current_user)
flash('Your password has been updated.')
return redirect(url_for('main.index'))
else:
flash('Invalid password.')
return render_template('auth/change_password.html', form=form)
forms.py
部分
#app/auth/forms.py
class ChangePasswordForm(Form):
old_password = PasswordField('Old password', validators=[Required()])
password = PasswordField('New password', validators=[
Required(), EqualTo('password2', message='passwords must match')])
password2 = PasswordField('Confirm new password', validators=[Required()])
submit = SubmitField('Update Password')
html
页面部分
#app/templates/base.html
#...
<ul class='nav navbar-nav navbar-right'>
{% if current_user.is_authenticated %}
<li class='dropdown'>
<a href="#" class="dropdown-toggle" data-toggle="dropdown">个人中心<b class="caret"></b></a>
<ul class='dropdown-menu'>
<li><a href="{{ url_for('auth.change_password') }}">
修改密码</a></li>
<li><a href="{{ url_for('auth.logout') }}">登出</a></li>
</ul></li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">登入
</a></li>
{% endif %}
</ul>
#...
重设密码
为了避免用户忘记密码无法登入的情况,程序可以提供重设密码功能,安全起见,有必要使用类似于确认账户时用到的令牌,用户请求重设密码后,程序会向用户注册时提供的电子邮件地址发送一封包含重设令牌的邮件,用户点击邮件中的链接,令牌验证后,会显示一个用于输入新密码的表单
实现此功能的改动如下:
forms.py
部分
# ...
class PasswordResetRequestForm(Form):
email = StringField('Email', validators=[Required(), Length(1, 64), Email()])
submit = SubmitField('Reset Password')
class PasswordResetForm(Form):
email = StringField('Email', validators=[Required(), Length(1, 64), Email()])
password = PasswordField('New Password', validators=[Required(), EqualTo('password2', message='Passwords must match')])
password2 = PasswordField('Confirm password', validators=[Required()])
submit = SubmitField('Reset Passwrord')
def validate_email(self, field):
if User.query.filter_by(email=field.data).first() is None:
raise ValidationError('Unknown email address.')
Models.py
部分
# ...
def generate_reset_token(self, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'reset': self.id})
def reset_password(self, token, new_password):
s = Serializer(current_app.config[SECRET_KEY])
try:
data = s.loads(token)
except:
return False
if data.get('reset') != self.id:
return False
self.password = new_password
db.session.add(self)
return True
views.py
部分
@auth.route('/reset', methods=['GET', 'POST'])
def password_reset_request():
if not current_user.is_anonymous:
return redirect(url_for('main.index'))
form = PasswordResetRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
token = user.generate_reset_token()
send_mail(user.email, "Reset Your password",
'auth/email/reset_password',
user=user, token=token,
next=request.args.get('next'))
flash('An email with instructions to reset your password has been'
'sent to you')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)
@auth.route('/reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
if not current_user.id_anonymous:
return redirect(url_for('main.index'))
form = PasswordResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is None:
return redirect(url_for('main.index'))
if user.reset_password(token, form.password.data):
flash('Your password has been updated.')
return redirect(url_for('auth.login'))
else:
return redirect(url_for('main.index'))
return render_template('auth/reset_password.html')
html
部分也要添加
<p>忘记密码?<a href="{{ url_for('auth.password_reset_request') }}">找回密码</a></p>
修改电子邮件地址
程序可以提供修改电子邮件地址的功能,不过接受新地址之前,必须使用确认邮件进行验证,使用这个功能时,用户在表单中输入新的电子邮件地址,为了验证这个地址,程序会发送一封包含令牌的邮件,服务器收到令牌后再更新用户对象,服务器收到令牌之前,可以把新电子邮件地址保存在一个新数据库字段中作为待定地址,或者将其和id一起保存在令牌中
forms.py
部分
class ChangeEmailForm(Form):
email = StringField('New Email', validators=[Required(), Length(1, 64), Email()])
password = PasswordField('Password', validators=[Required()])
submit = SubmitField('Update Email Address')
def vilidate_emial(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError('Email already registered.')
models.py
部分
def generate_email_change_token(self, new_email, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'change_email': self.id, 'new_email': new_email})
def change_email(self, token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
except:
return False
if data.get('change_email') != self.id:
return False
new_email = data.get('new_email')
if new_email is None:
return False
if self.query.filter_by(email=new_email).first() is not None:
return False
self.email = new_email
sb.session.add(self)
return True
views.py
部分
@auth.route('/change-email', methods=['GET', 'POST'])
@login_required
def change_email_request():
form = ChangeEmailForm()
if form.validate_on_submit():
if current_user.verify_password(form.password.data):
new_email = form.email.data
token = current_user.generate_email_change(new_email)
send_mail(new_email, 'Confirm your email address',
'auth/email/change_email',
user=current_user, token=token)
flash('An email with instructions to confirm your new email')
return redirect(url_for('main.index'))
else:
flash('Invalid email or password')
return render_template('auth/change_email.html', form=form)
@auth.route('/change/email/<token>')
@login_required
def change_email(token):
if current_user.change_email(token):
flash('Your email address has been updated.')
else:
flash('Invalid request')
return redirect(url_for('main.index'))
html部分同之前,就不赘述了