flask-用户认证(下)

来源:互联网 发布:嘉兴软件开发制作 编辑:程序博客网 时间:2024/06/05 09:28

确认账户

对于某些特定类型的程序,有必要确认注册时用户提供的信息是否正确。常见要求是能通过提供的电 子邮件地址与用户取得联系。

为验证电子邮件地址, 用户注册后,程序会立即发送一封确认邮件。新账户先被标记成未激活状态,用户点击邮件中的链接后,才能激活。账户确认过程中,往往会要求用户点击一个包含确认令牌的特殊 URL 链接。

1. 使用itsdangerous生成确认令牌

确认邮件中最简单的确认链接是 http://www.example.com/auth/confirm/<id> 这种形式的URL,其 中 id 是数据库分配给用户的数字 id。用户点击链接后,处理这个路由的视图函数就将收到的用户id作为参数进行确认,然后将用户状态更新为已激活。

但是这种方式不是很安全,只要用户能判断确认链接的格式,就可以随便指定URL中的数字,从而确认任意账户。解决方法是把 URL 中的 id换成将相同信息安全加密后得到的令牌。

itsdangerous 提供了多种生成令牌的方法。其中,TimedJSONWebSignatureSerializer 类生成具有 过期时间的 JSON Web 签名(JSON Web Signatures, JWS)。这个类的构造函数接收的参数是一个密钥,在 Flask 程序中可使用 SECRET_KEY 设置。

dumps()方法为指定的数据生成一个加密签名,然后再对数据和签名进行序列化,生成令牌字符串。expires_in 参数设置令牌的过期时间,单位为秒。 为了解码令牌, 序列化对象提供了 loads()方法,其唯一的参数是令牌字符串。这个方法会检验签 名和过期时间, 如果通过,返回原始数据。如果提供给 loads() 方法的令牌不正确或过期了,则抛 出异常。

我们可以将这种生成和检验令牌的功能可添加到 User 模型中。

【 app/models.py:确认用户账户】

from itsdangerous import TimedJSONWebSignatureSerializer as Serializerfrom flask import current_appclass 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.session.add(self)        return True

generate_confirmation_token() 方法生成一个令牌,有效期默认为一小时。

confirm()方法检验令牌,如果检验通过,则把新添加的 confirmed 属性设为 True。

由于模型中新加入了一个列用来保存账户的确认状态,因此要生成并执行一个新的数据库迁移

2. 发送确认邮件

/register 路由把新用户添加到数据库中后,会重定向到 /index。在重定向之前,这个路由需要发 送确认邮件。

首先编辑邮件: 

【confirm.txt】

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 TeamNote: replies to this email address are not monitored.

【confirm.html】

<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>

默认情况下, url_for() 生成相对 URL,例如 url_for('auth.confirm', token='abc') 返回的字符串是 '/auth/confirm/abc'。这不能够在电子邮件中发送的正确URL。

_external=True 参数要求程序生成完整的 URL,其中包含协议(http://或 https:// )、主机名和端口。

【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()        token = user.generate_confirmation_token()        send_email(user.email, 'Confirm Your Account',                   'auth/mail/confirm', user=user, token=token)        flash('A confirmation email has been sent to you by emai        return redirect(url_for('auth.login'))    return render_template('auth/register.html', form=form)

【 app/auth/views.py:确认用户的账户】

@auth.route('/confirm/<token>')@login_requireddef confirm(token):    if current_user.confirmed:        return redirect(url_for('main.index'))    if current_user.confirm(token):        flash('You have confirmed 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_requestdef before_request():    if current_user.is_authenticated \            and not current_user.confirmed \            and request.endpoint \            and request.endpoint[:5] != 'auth.':        return redirect(url_for('auth.unconfirmed'))                @auth.route('/unconfirmed')def unconfirmed():    if current_user.is_anonymous or current_user.confirm        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 路由,显示一个确认账户相关信息的页面。

显示给未确认用户的页面只渲染一个模板,其中有如何确认账户的说明,此外还提供了一个链接,用于请求发送新的确认邮件,以防之前的邮件丢失。

【app/templates/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 %}

【app/auth/views.py:重新发送账户确认邮件】

@auth.route('/confirm')@login_requireddef 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'))

这个路由为 current_user(即已登录的用户,也是目标用户)重做了一遍注册路由中的操作。这个路由也用 login_required 保护,确保访问时程序知道请求再次发送邮件的是哪个用户。

【注意】

凡是修改了models.py,都要执行数据库迁移和数据库更新两个操作:

python manage.py db migratepython manage.py db upgrade

管理账户

拥有程序账户的用户有时可能需要修改账户信息。

- 修改密码

安全意识强的用户可能希望定期修改密码。这是一个很容易实现的功能,只要用户处于登录状态,就 可以放心显示一个表单,要求用户输入旧密码和替换的新密码。

  • 第1步:建立表单

【app/auth/forms.py:增加修改密码表单】

class ChangePasswordForm(Form):    old_password = PasswordField('Old password', validators=[DataRequired()])    password = PasswordField('New password', validators=[        DataRequired(), EqualTo('password2', message='Passwords must match')])    password2 = PasswordField('Confirm new password', validators=[DataRequired()])    submit = SubmitField('Update Password')
  • 第2步:建立模板

【app/templates/auth/change_password.html:增加修改密码模板】

{% extends "base.html" %}{% import "bootstrap/wtf.html" as wtf %}{% block title %}Flasky - Change Password{% endblock %}{% block page_content %}<div class="page-header">    <h1>Change Your Password</h1></div><div class="col-md-4">    {{ wtf.quick_form(form) }}</div>{% endblock %}
  • 第3步:建立路由

【app/auth/views.py:增加修改密码路由】

@auth.route('/change-password', methods=['GET', 'POST'])@login_requireddef 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)

重设密码

为避免用户忘记密码无法登入的情况,程序可以提供重设密码功能。与修改密码不同的是,安全起 见,有必要使用类似于确认账户时用到的令牌。用户请求重设密码后,程序会向用户注册时提供的电 子邮件地址发送一封包含重设令牌的邮件。用户点击邮件中的链接,令牌验证后,会显示一个用于输 入新密码的表单。

  • 第1步:修改模型

【app/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
  • 第2步:建立表单

【app/auth/forms.py:增加重设密码表单】

class PasswordResetRequestForm(Form):    email = StringField('Email', validators=[DataRequired(), Length(1, 64),                                             Email()])    submit = SubmitField('Reset Password')            def validate_email(self, field):        if User.query.filter_by(email=field.data).first() is None:            raise ValidationError('Unknown email address.')
class PasswordResetForm(Form):    email = StringField('Email', validators=[DataRequired(), 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 Password')            def validate_email(self, field):        if User.query.filter_by(email=field.data).first() is None:            raise ValidationError('Unknown email address.')
  • 第3步:建立模板

【app/templates/auth/reset_password.html:增加重设密码模板】

{% extends "base.html" %}{% import "bootstrap/wtf.html" as wtf %}{% block title %}Flasky - Password Reset{% endblock %}{% block page_content %}<div class="page-header">    <h1>Reset Your Password</h1></div><div class="col-md-4">    {{ wtf.quick_form(form) }}</div>{% endblock %}

邮件内容

【app/templates/auth/email/reset_password.html】

<p>Dear {{ user.username }},</p><p>To reset your password <a href="{{ url_for('auth.password_reset', 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.password_reset', token=token, _external=True) }}</p><p>If you have not requested a password reset simply ignore this message.</p><p>Sincerely,</p><p>The Flasky Team</p><p><small>Note: replies to this email address are not monitored.</small></p>

【app/templates/auth/email/reset_password.txt】

Dear {{ user.username }},To reset your password click on the following link:{{ url_for('auth.password_reset', token=token, _external=True) }}If you have not requested a password reset simply ignore this message.Sincerely,The Flasky TeamNote: replies to this email address are not monitored.

-第4步:建立路由

【app/auth/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_email(user.email, 'Reset Your Password',                       'auth/email/reset_password',                       user=user, token=token)        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.is_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.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', form=form)