第九章 用户认证

大多数程序都需要进行用户跟踪。用户链接程序时需要进行身份认证,通过这一过程,让程序知道自己的身份。程序知道用户是谁后,就能提供有针对性的个性化体验。最常用的认证方法要求用户提供一个身份证明(用户的电子邮件、电话或用户名)和一个密码。

Flask的认证扩展

  • Flask-Login:管理已登录用户的用户会话(session)。
  • Werkzeug:计算密码散列值并进行核对。
  • itsdangerous:生成并核对加密安全令牌。

使用pip安装Flask-Login:

(flask)$ pip install flask-login

密码安全性

若要保证数据库中用户密码的安全,关键在于不能存储密码本身,而要存储密码的散列值(非明文)。计算密码散列值的函数接手密码作为输入,使用一种或多种加密算法转换密码,最终得到一个和原始密码没有关系的字符序列。核对密码时,密码散列值可以替代原始密码,因为计算散列值的函数是可以复现的:只要输入一样,结果就一样。

使用Werkzeug实现密码散列

Werkzeug中的security模块能够很方便地实现密码散列值的计算。这一功能的实现只需要两个函数,分别用在注册用户和验证用户阶段。

  • generate_password_hash(password, method=pbkdf2:sha1, salt_length=8):这个函数将原始密码作为输入,以字符串形式输出密码的散列值,输出的值可保存在用户数据库中,method和salt_length的默认值就能满足大多数需求。
  • check_password_hash(hash, password):这个函数的参数是从数据库中取回的密码散列值和用户输入的密码。返回值为True表明密码正确。

修改app/models.py, 在在User模型中加入密码散列

from werkzeug.security import generate_password_hash, check_password_hash
class User(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))
    @property
    def password(self):
        raise ArithmeticError('非明文密码,不可读。')
    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password=password)
    def verify_password(self, password):
        return check_password_hash(self.password_hash, password=password)
    def __repr__(self):
        return '<User %r>' % self.username

计算密码散列值的函数通过名为password的只写属性实现。设定这个函数的值时,赋值方法会调用Werkzeug提供的generate_password_hash()函数,并把得到的结果赋值给password_hash字段。如果试图读取password属性的值,则会返回错误,原因很明显,因为生曾散列值后就无法还原成原来的密码了。

verify_password方法接受一个参数(即密码),将其传给Werkzeug提供的check_password_hash()函数,和存储在User模型中的密码散列值进行比对。如果这个方法返回True,就表明密码是正确的。

准备用于登录的用户模型

要想使用Flask-Login扩展,程序的User模型必须实现几个方法。需要实现的方法如下表:

方法 说明
is_authenticated() 如果用户已经登录,必须返回True,否则返回False
is_active() 如果允许用户登录,必须返回True,否则返回False。如果要禁用账户,可以返回False
is_anonymous() 对普通用户必须返回False
get_id() 必须返回用户的唯一标示符,使用Unicode编码字符串

这四个方法可以在模型类中作为方法直接实现,不过还有一种更简单的替代方法。Flask-Login提供了一个UserMixin类,其中包含这些方法的默认实现,且能满足大多数需求。在app/models.py中修改User模型,支持用户登录

from flask.ext.login import UserMixin, AnonymousUserMixin
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))

修改app/__init__.py, 初始化Flask-Login

from flask.ext.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__)
    app.config.from_object(config[config_name])
    config[config_name].init_app(app=app)
    mail.init_app(app=app)
    moment.init_app(app=app)
    db.init_app(app=app)
    login_manager.init_app(app=app)
    # 注册蓝本 main
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
    # 注册蓝本 auth
    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/auth')
    return app

LoginManager对象的session_protection属性可以设为None、'basic'或'strong',以提供不同的安全等级防止用户会话遭篡改。设置为'strong'时,Flask-Login会记录客户端IP地址和浏览器的用户代理信息,如果发现移动就登出用户。login_view属性设置登录页面的端点,前面需要加上蓝本的名字。

最后,Flask-Login要求程序实现一个回调函数,使用指定的标示符加载用户
修改app/models.py,加载用户的回调函数

from .import login_manager
@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

加载用户的回调函数接受以Unicode字符串形式表示的用户标示符。如果能找到用户,这个函数必须返回用户对象;否在应该返回None。

添加登录表单

呈现给用户的登录表单中包含一个用户输入电子邮件地址的文本字段、一个密码字段、一个“记住我”复选框和提交按钮。这个表单使用Flask-WTF类。
修改app/auth/forms.py登录表单

from flask.ext.wtf import Form
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email 

class LoginForm(Form):
    email = StringField(u'邮箱', validators=[DataRequired(), Length(6, 64, message=u'邮件长度要在6和64之间'),
                        Email(message=u'邮件格式不正确!')])
    password = PasswordField(u'密码', validators=[DataRequired()])
    remember_me = BooleanField(label=u'记住我', default=False)
    submit = SubmitField(u'登 录')

电子邮件字段用到了WTForms提供的Length()Email()验证函数。PasswordeField类表示属性为type=”password”<input>元素。BooleanField类表示复选框。

登录页面使用的模板保存在app/templates/auth/login.html文件中。

{% extends 'main/common/base.html' %}
{% block title %}
    {{ super() }}
    登录
{% endblock %}
{% block content %}
    <div class="log-reg">
        {% include 'common/alert-wrong.html' %}<!-- 错误信息flash提示 end -->
        <!-- 错误信息form提示 -->
        {% for field_name, field_errors in loginForm.errors|dictsort if field_errors %}
            {% for error in field_errors %}
                <div class="error">
                    <div class="alert alert-danger alert-dismissible" role="alert">
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                        <strong>{{ loginForm[field_name].label }}错误:</strong> {{ error }}
                    </div>
                </div>
            {% endfor %}
        {% endfor %}
        <!-- 错误信息form提示 end -->
        <form method="post" role="form">
            {{ loginForm.hidden_tag() }}
            <div class="input-group input-group-lg">
                <span class="input-group-addon"><i class="glyphicon glyphicon-envelope"></i> </span>
                {{ loginForm.email(class="form-control", placeholder="邮箱",required="", autofocus="") }}
            </div>
            <div class="input-group input-group-lg">
                <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                {{ loginForm.password(class="form-control", placeholder="密 码", required="") }}
            </div>
            <div class="well-lg">
                <div class="row pull-left">
                    {{ loginForm.remember_me() }} {{ loginForm.remember_me.label }}
                </div>
            </div>
            {{ loginForm.submit(class="btn btn-lg btn-primary pull-right") }}
            <input class="btn btn-lg btn-primary pull-right" type="reset" value="重 置">
        </form>
    </div>
{% endblock %}

登入用户

app/auth/views.py, 视图函数login()的实现

from flask import render_template, request, redirect, url_for, flash
from flask.ext.login import login_user
from ..models import User
from .forms import LoginForm
from .import main
@auth.route('/login', methods=['GET', 'POST'])
def login():
    login_form = LoginForm(prefix='login')
    if login_form.validate_on_submit():
        user = User.query.filter_by(email=login_form.email.data.strip()).first()       
        if user is not None and user.verify_password(login_form.password.data.strip()):
            login_user(user=user, remember=login_form.remember_me.data)
            return redirect(request.args.get('next') or url_for('main.index'))
        elif user is None:
            flash(u'邮箱未注册!')
        elif not user.verify_password(login_form.password.data.strip()):
            flash(u'密码不正确!')
    return render_template('main/login.html', loginForm=login_form)

登出用户

app/main/views.py 退出路由

from flask.ext.login import logout_user, login_required
@auth .route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('main.index'))

这个函数中调用Flask-Login中的logout_user()函数,删除并重设用户会话。随后重定向到首页,这个操作就完成。其中,这个函数用到了保护路由,Flash-Login提供的一个login_required修饰器,如果未认证的用户访问这个路由,Flash-Login会拦截请求,把用户发往登录页面。

测试登录

为了验证登录功能是否成功,可以把自己的成果展示一下了。
http://localhost:5000/auth/login, 打开这个URL,溜一溜成果。

Login01
Login01

VERY GOOD!!!页面显示成功拉。

那么输入邮箱和密码试试,到底该用什么邮箱和密码呢。对了,我们现在还没有创建用户注册功能。只有在数据库中直接创建新用户了。shell的伟大就显示出来了。

(flask) $ python manage.py shell
 >>>u = User(email=u'eastossifrage@gmail.com', username=u'东方鹗', password=u'123456')
>>> db.session.add(u)
>>> db.session.commit()

什么情况,怎么这么多错误,连新用户都注册不了。先不要着急,我们冷静下来想一想,好像我们仅仅创建了数据库模型(app/models.py),数据库还没有创建,好了,想到问题所在,那么我们就把相应的工具拿出来——Flask—Migrate。

使用init子命令来创建迁移仓库:

(flask)$ python manage.py db init

创建迁移脚本:

(flask)$ python manage.py db migrate -m “initial migration”

更新数据库:

(flask)$ python manage.py db upgrade

按照上面的提示操作之后,再利用shell执行注册用户操作。新创建的用户就可以登录了。登录之后显示的首页如下图。

Login02
Login02

优化主页

为了能够体现出用户的状态(登录与否)。我们现在需要优化主页。如果用户已登录,则在导航条中显示用户名和登出链接,如果未登录则显示登录和注册链接。
app/templates/common/logined.html

<ul class="nav navbar-nav navbar-right">
    {% if current_user.is_authenticated() %}
        <li><p class="navbar-text"> <a href="{{ url_for('auth.index') }}">{{ current_user.username }}</a> | <a href="{{ url_for('main.logout') }}">注销</a></p></li>
    {% else %}
        <li><p class="navbar-text"> <a href="{{ url_for('auth.login') }}">登录</a> | <a href="{{ url_for('auth.register') }}">注册</a></p></li>
    {% endif %}
</ul>

app/templates/main/common/header.html

<div class="navbar-wrapper">
    <div class="container">
        <div class="navbar navbar-inverse navbar-static-top" role="navigation">
            <div class="container">
                <div class="navbar-header">
                    <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                        <span class="sr-only">Toggle navigation</span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                    </button>
                    {% include 'common/brand.html' %}
                </div>
                    <div class="navbar-collapse collapse">                    
                    {% include 'common/logined.html' %}
                    {% include 'common/search.html' %}
                </div>
            </div>
        </div>
    </div>
</div>

注册新用户

处理用户注册的过程没有什么难以理解。提交注册表单,通过验证后,系统就使用填写的信息在数据库中添加一个新用户。

app/templates/main/register.html 注册新用户视图页面

{% extends 'main/common/base.html' %}
{% block title %}
    {{ super() }}
    注册
{% endblock %}
{% block content %}
    <div class="log-reg">
        {% for field_name, field_errors in registerForm.errors|dictsort if field_errors %}
        {% for error in field_errors %}
            <div class="alert alert-danger alert-dismissible" role="alert">
                <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                <strong>{{ registerForm[field_name].label }}错误:</strong> {{ error }}
            </div>
        {% endfor %}
    {% endfor %}
        <form method="post" role="form">
            <div class="input-group input-group-lg">
                <span class="input-group-addon"><i class="glyphicon glyphicon-envelope"></i> </span>
                {{ registerForm.email(class="form-control", placeholder="邮 箱", required="", autofocus="", title="邮箱正确格式:xxx@xxx.xxx") }}
            </div>
            <div class="input-group input-group-lg">
                <span class="input-group-addon"><i class="glyphicon glyphicon-user"></i> </span>
                {{ registerForm.username(class="form-control", placeholder="用户名", required="") }}
            </div>
            <div class="input-group input-group-lg">
                <span class="input-group-addon" ><i class="glyphicon glyphicon-lock"></i> </span>
                {{ registerForm.password(class="form-control", placeholder="密 码", required="") }}
            </div>
            <div class="input-group input-group-lg">
                <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                {{ registerForm.password2(class="form-control", placeholder="重新输入密码", required="") }}
            </div>
            {{ registerForm.submit(class="btn btn-lg btn-primary pull-right") }}
            <input class="btn btn-lg btn-primary pull-right" type="reset" value="重 置">
        </form>
    </div>
{% endblock %}

app/auth/views.py 注册新用户路由

@auth.route('/register', methods=['GET', 'POST'])
def register():
    register_form = RegisterForm(prefix='register')
    if register_form.validate_on_submit():
        user = User(email=register_form.email.data.strip(),
                    username=register_form.username.data.strip(),
                    password=register_form.password.data.strip())
        db.session.add(user)
        db.session.commit()        
        token = user.generate_confirmation_token()
        send_email(to=user.email, subject=u'请求确认你的账户', template='main/email/confirm', user=user, token=token)
        flash(message=u'一封确认邮件已发至您的邮箱')
        login_user(user=user)
        return redirect(url_for('main.confirming'))
    return render_template('main/register.html', registerForm=register_form)

通过以上程序,你就可以实现新用户的注册功能了。但是以上程序并不能正确运行,因为示例5-12中有一部分是理由邮箱确认账户的功能。请继续学习下面的内容。

确认账户

为了验证电子邮件地址,用户注册后,程序会立即发送一封确认邮件。新账户先被标记成待确认状态,用户按照邮件中的说面操作后,曾你证明自己可以被联系上。账户确认过程中,往往会要求用户点击一个包含确认令牌的特殊URL链接。

使用itsdangerous生成确认令牌

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

dumps()方法为指定的数据生曾一个加密签名,然后再对数据和签名进行序列化,生成令牌字符串。expires_in参数设置令牌的过期时间,单位为秒。

为了解码令牌,序列化对象提供了loads()方法,其唯一的参数是令牌字符串。这个方法会检验签名和过期时间,如果通过,返回原始数据。如果提供给loads()方法的令牌不正确或过期了,则抛出异常。

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

app/models.py 确认用户账户

from . import db
from flask.ext.login import UserMixin, AnonymousUserMixin
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'], expires_in=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
    def __repr__(self):
        return '<User %r>' % self.username

generate_confirmation_token()方法生成一个令牌,有效期默认为一小时。confirm()方法检验令牌,如果检验通过,则把新添加的confirmed属性设置为True。除了检验令牌,confirm()方法还检查令牌中的id是否和存储在current_user中的已登录用户匹配。如此一来,及时恶意用户知道如何生成签名令牌,也无法确认别人的账户。

发送确认邮件

5-12 app/auth/views.py中的代码所示,/register路由先把新用户添加到数据库中(** 注意,即便通过config.py配置,程序已经可以在请求末尾自动提交数据库变化,这里也需要添加db.session.commit()调用。问题在于,提交数据库之后才能赋予新用户id值,而确认令牌需要用到id,所以不能延后提交 **),在重定向之前,发送确认邮件。

电子邮件模板保存在templates/email文件夹中,以便和HTML模板区分开来。一个电子邮件需要两个模板,分别用于渲染纯文本正负和富文本正负。

app/templates/auth/email/confirm.txt, 确认邮件的纯文本正文

尊敬的 {{ user.username }}, 您好!
    欢迎来到藕丝空间!
    请点击下面的链接来确认您的账户:
    {{ url_for('auth.confirm', token=token, _external=True) }}
                                                藕丝团队敬上
    注意:请不要回复该邮件!

app/templates/auth/email/confirm.html, 确认邮件的富文本正文

<p>尊敬的 <strong>{{ user.username }}</strong>, 您好!</p>
<p>欢迎来到藕丝空间!</p>
<p>请点击下面的链接来确认您的账户:</p>
<p><a href="{{ url_for('auth.confirm', token=token, _external=True) }}">{{ url_for('auth.confirm', token=token, _external=True) }}</a></p>
<p class="pull-right">                                            藕丝团队敬上</p>
<p>注意:请不要回复该邮件!

默认情况下,url_for()生成相对URL,例如url_for('auth.confirm', token='abc')返回的字符串是/auth/confirm/abc。这显然不是能够在电子邮件中发送的正确URL。相对URL在网页的上下文中可以正常使用,因为通过添加当前页面的主机名和端口号,浏览器会将其转换成绝对URL。但通过电子邮件发送的URL时,并没有这种上下文。添加到url_for()函数中的_external=True参数要求程序生成完整的URL,其中包含协议(http://或https://),主机名和端口。

app/auth/views.py, 确认用户的账户

from flask.ext.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(u'您已经成功的对您的账户进行了邮件确认。非常感谢!')
    else:
        flash(u'本链接已经失效或者过期。')
        return redirect(url_for('auth.unconfirmed'))
    return redirect(url_for('auth.confirmed'))

Flask-Login提供的login_required修饰器会保护这个路由,因此,用户点击确认邮件中的链接后,要先登录,然后才能执行这个视图函数。

这个函数先检查已登录的用户是否确认国,如果确认国,则重定向到首页,因为很显然此时不用做什么操作。这样处理可以避免用户不小心多次点击确认令牌带来的额外工作。

由于令牌确认完全在User模型中完成,所以视图函数只需要调用confirm()方法即可,然后再根据确认结果显示不同的flash消息。确认成功后,User模型中confirmed属性的值会被修改并添加到会话中,请求处理完后,这两个操作被提交到数据库。

在确定用户账户之前,我们可以自由决定用户该进行那些操作。现在我们需要显示一个页面,要求用户在获取权限之前先确认账户。这一步可以使用Flask提供的before_request钩子完成。对于蓝本来说,before_request钩子只能应用到属于蓝本的请求上。若想在蓝本中使用针对全局请求的钩子,必须使用before_app_request修饰器。

app/auth/views.py, 在before_aap_request处理程序中过滤未确认账户

@auth.before_app_request
def before_request():
    if current_user.is_authenticated():
        # current_user.ping()
        if 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/email/unconfirmed.html')

同时满足一下3个条件,before_app_request处理程序会拦截请求。

  • 用户已经登录(current_user.is_authenticated()必须返回True)。
  • 用户账户还未确认。
  • 请求的端点(使用request.endpoint获取)不再认证蓝本中。访问认证路由要获取权限,因为这些路由的作用是让用户确认账户或执行其他账户管理操作。

如果请求满足以上3个条件,则会被重定向到/auth/unconfirmed路由,显示一个确认账户相关信息页面。

为了防止之前的邮件丢失。我们需要重新发送确认邮件的功能。

5-18 app/auth/views.py, 重新发送账户确认邮件

@auth.route('/confirm')
@login_required
def resend_confirmation():
    token = current_user.generate_confirmation_token()
    send_email(to=current_user.email, subject=u'请求确认你的账户',
               template='auth/email/confirm', user=current_user, token=token)
    flash(message=u'一封注册确认邮件已发至您的邮箱')
    return redirect(url_for('auth.confirming'))

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

管理账户

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

修改密码

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

app/auth/forms.py,修改密码表单

class ChangePasswordForm(Form):
    old_password = PasswordField(u'旧密码', validators=[DataRequired()])
    password = PasswordField(u'密码', validators=[DataRequired(), EqualTo(u'password2', message=u'密码必须一致!')])
    password2 = PasswordField(u'重输密码', validators=[DataRequired()])
    submit = SubmitField(u'更新密码')

app/templates/auth/config/changer_password.html, 修改密码页面

{% extends 'auth/common/base.html' %}
{% block title %}
    {{ super() }}
    修改密码
{% endblock %}
{% block content %}
    <h1 class="page-header">修改密码</h1>
    <div class="center-auth">
        {% include 'common/alert.html' %}<!-- flash提示 end -->
        <!-- 错误信息changePasswordForm提示 -->
        {% for field_name, field_errors in changePasswordForm.errors|dictsort if field_errors %}
            {% for error in field_errors %}
                <div class="error">
                    <div class="alert alert-danger alert-dismissible" role="alert" style="margin-top: 20px">
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                        <strong>{{ changePasswordForm[field_name].label }}错误:</strong> {{ error }}
                    </div>
                </div>
            {% endfor %}
        {% endfor %}
        <!-- 错误信息changePasswordForm提示 end -->
        <form method="post" role="form">
            {{ changePasswordForm.hidden_tag() }}
                <div class="input-group">
                    <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                    {{ changePasswordForm.old_password(class="form-control", maxlength="64", placeholder="旧密码", required="") }}
                </div>
                <div class="input-group">
                    <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                    {{ changePasswordForm.password(class="form-control", maxlength="64", placeholder="密码", required="") }}
                </div>
                <div class="input-group">
                    <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                    {{ changePasswordForm.password2(class="form-control", maxlength="64", placeholder="重新输入密码", required="") }}
                </div>
                {{ changePasswordForm.submit(class="btn btn-primary pull-right") }}
        </form>
    </div>
{% endblock %}

app/auth/views.py, 修改密码路由

@auth.route('/change_password', methods=['GET', 'POST'])
@login_required
def change_password():
    change_password_form = ChangePasswordForm(prefix='change_password')
    if change_password_form.validate_on_submit():
        if current_user.verify_password(change_password_form.old_password.data.strip()):
            current_user.password = change_password_form.password.data.strip()
            db.session.add(current_user)
            flash({'success': u'您的账户密码已修改成功!'})
        else:
            flash({'error': u'无效的旧密码!'})
    return render_template('auth/config/change_password.html', changePasswordForm=change_password_form)

重设密码

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

app/auth/forms.py, 重置密码表单

class PasswordResetRequestForm(Form):
    email = StringField(u'邮箱', validators=[DataRequired(), Length(6, 64, message=u'邮件长度要在6和64之间'),
                        Email(message=u'邮件格式不正确!')])
    submit = SubmitField(u'发送')

class PasswordResetForm(Form):
    email = StringField(u'邮箱', validators=[DataRequired(), Length(6, 64, message=u'邮件长度要在6和64之间'),
                        Email(message=u'邮件格式不正确!')])
    password = PasswordField(u'密码', validators=[DataRequired(), EqualTo(u'password2', message=u'密码必须一致!')])
    password2 = PasswordField(u'重输密码', validators=[DataRequired()])
    submit = SubmitField(u'确认')
    def validate_email(self, field):
        if User.query.filter_by(email=field.data).first() is None:
            raise ValidationError(u'邮箱未注册!')

app/templates/auth/password/password_reset_.html, 忘记密码页面(输入注册邮箱,程序会往注册邮箱里发送一封包含重设令牌的邮件)

{% extends 'main/common/base.html' %}
{% block title %}
    {{ super() }}
    重置密码
{% endblock %}
{% block content %}
    <div class="log-reg">
        {% include 'common/alert.html' %}<!-- flash提示 end -->
        <!-- 错误信息form提示 -->
        {% for field_name, field_errors in passwordResetRequestForm.errors|dictsort if field_errors %}
            {% for error in field_errors %}
                <div class="error">
                    <div class="alert alert-danger alert-dismissible" role="alert">
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                        <strong>{{ passwordResetRequestForm[field_name].label }}错误:</strong> {{ error }}
                    </div>
                </div>
            {% endfor %}
        {% endfor %}
        <!-- 错误信息form提示 end -->
        <!-- Modal -->
        <form method="post" role="form">
            {{ passwordResetRequestForm.hidden_tag() }}
            <label>填写您所注册的邮箱</label>
            <div class="input-group input-group-lg">
                <span class="input-group-addon"><i class="glyphicon glyphicon-envelope"></i> </span>
                {{ passwordResetRequestForm.email(class="form-control", placeholder="邮箱", required="", autofocus="") }}
            </div>
            {{ passwordResetRequestForm.submit(class="btn btn-lg btn-primary pull-right") }}
            <input type="reset" class="btn btn-lg btn-default pull-right">
        </form>
    </div>
{% endblock %}

app/templates/auth/password/password_reset.html, 重置密码页面

{% extends 'main/common/base.html' %}
{% block title %}
    {{ super() }}
    重置密码
{% endblock %}
{% block content %}
    <div class="log-reg">
        {% include 'common/alert.html' %}<!-- flash提示 end -->
        <!-- 错误信息form提示 -->
        {% for field_name, field_errors in passwordResetForm.errors|dictsort if field_errors %}
            {% for error in field_errors %}
                <div class="error">
                    <div class="alert alert-danger alert-dismissible" role="alert">
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                        <strong>{{ passwordResetForm[field_name].label }}错误:</strong> {{ error }}
                    </div>
                </div>
            {% endfor %}
        {% endfor %}
        <!-- 错误信息form提示 end -->
        <!-- Modal -->
        <form method="post" role="form">
            {{ passwordResetForm.hidden_tag() }}
                <div class="input-group">
                    <span class="input-group-addon"><i class="glyphicon glyphicon-envelope"></i> </span>
                    {{ passwordResetForm.email(class="form-control", placeholder="邮箱", maxlength="64", required="", autofocus="") }}
                </div>
                <div class="input-group">
                    <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                    {{ passwordResetForm.password(class="form-control", placeholder="密码", maxlength="64", required="") }}
                </div>
                <div class="input-group">
                    <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                    {{ passwordResetForm.password2(class="form-control", placeholder="重新输入密码", maxlength="64", required="") }}
                </div>
                {{ passwordResetForm.submit(class="btn btn-lg btn-primary pull-right") }}
                <input type="reset" class="btn btn-lg btn-default pull-right">
        </form>
    </div>
{% endblock %}

包含带有令牌的重设密码的提示页面以及重设密码成功的页面,请发挥想象力,自己完成。

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'))
    password_reset_request_form = PasswordResetRequestForm()
    if password_reset_request_form.validate_on_submit():
        user = User.query.filter_by(email=password_reset_request_form.email.data.strip()).first()
        if user:
            token = user.generate_reset_token()
            send_email(to=user.email, subject=u'重置密码',
                       user=user, token=token, template='auth/password/reset_password',
                       next=request.args.get('next'))
            flash(user.username)
            flash(u'一封重置密码的确认邮件已发至您的邮箱')
            flash(user.email)
            return redirect(url_for('auth.password_reset_confirming'))
        else:
            flash({'error':u'邮箱未注册!'})
    return render_template('auth/password/password_reset_.html', passwordResetRequestForm=password_reset_request_form)

@auth.route('/reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
    ''' 重设密码路由 '''
    if not current_user.is_anonymous():
        return redirect(url_for('main.index'))
    password_reset_form = PasswordResetForm()
    if password_reset_form.validate_on_submit():
        user = User.query.filter_by(email=password_reset_form.email.data.strip()).first()
        if user is None:
            return redirect(url_for('main.index'))
        if user.reset_password(token, password_reset_form.password.data):
            flash(user.username)
            flash(u'您的账户密码已重置,请使用新密码登录!')
            return redirect(url_for('auth.password_reset_confirmed'))
        else:
            return redirect(url_for('main.index'))
    return render_template('auth/password/password_reset.html', passwordResetForm=password_reset_form)

@auth.route('/password/confirming')
def password_reset_confirming():
    ''' 包含一份带有令牌的重设密码页面的路由 '''
    return render_template('auth/password/reset_password_confirming.html')

@auth.route('/password/confirmed')
def password_reset_confirmed():
    ''' 重设密码成功页面的路由 '''
    return render_template('auth/password/reset_password_confirmed.html')

修改用户昵称

程序可以提供修改用户昵称的功能,不过修改用户昵称的操作,必须使用密码进行权限确认。

app/auth/forms.py, 修改用户昵称表单

class ChangeUsernameForm(Form):
    username = StringField(u'用户名', validators=[DataRequired(), Length(1, 64, message=u'用户名长度要在1和64之间'),
                           Regexp(ur'^[\u4E00-\u9FFF]+$', flags=0, message=u'用户名必须为中文')])
    password = PasswordField('Password', validators=[DataRequired()])
    submit = SubmitField(u'更新昵称')
    def validate_username(self, field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError(u'用户名已被注册!')

app/templates/auth/config/change_username.html, 修改用户昵称页面

{% extends 'auth/common/base.html' %}
{% block title %}
    {{ super() }}
    修改昵称
{% endblock %}
{% block content %}
    <h1 class="page-header">修改昵称</h1>
    <div class="center-auth">
        {% include 'common/alert.html' %}<!-- flash提示 end -->
        <!-- 错误信息changeUsernameForm提示 -->
        {% for field_name, field_errors in changeUsernameForm.errors|dictsort if field_errors %}
            {% for error in field_errors %}
                <div class="error">
                    <div class="alert alert-danger alert-dismissible" role="alert" style="margin-top: 20px">
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                        <strong>{{ changeUsernameForm[field_name].label }}错误:</strong> {{ error }}
                    </div>
                </div>
            {% endfor %}
        {% endfor %}
        <!-- 错误信息changeUsernameForm提示 end -->
        <form method="post" role="form">
            {{ changeUsernameForm.hidden_tag() }}
            <div class="input-group">
                <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i> </span>
                {{ changeUsernameForm.password(class="form-control", maxlength="64", placeholder="当前密码", required="") }}
            </div>
            <div class="input-group">
                <span class="input-group-addon"><i class="glyphicon glyphicon-user"></i> </span>
                {{ changeUsernameForm.username(class="form-control", maxlength="64", value=current_user.username, required="") }}
            </div>
            {{ changeUsernameForm.submit(class="btn btn-primary pull-right") }}
        </form>
    </div>
{% endblock %}

app/auth/views.py, 重设用户昵称路由

@auth.route('/change-username', methods=['GET', 'POST'])
@login_required
def change_username():
    change_username_form = ChangeUsernameForm(prefix='change_username')
    if change_username_form.validate_on_submit():
        if current_user.verify_password(change_username_form.password.data):
            current_user.username = change_username_form.username.data.strip()
            db.session.add(current_user)
            flash({'success': u'昵称更新成功!'})
        else:
            flash({'error': u'密码错误!'})
    return render_template('auth/config/change_username.html', changeUsernameForm=change_username_form)

修改电子邮件地址

程序可以提供修改电子邮件的功能,不过接受新昵称之前,必须使用确认邮件进行验证。
由于设计思路也是基于令牌的邮件验证,本课件将不在展示MVC各阶段的代码,如有需要,请下载源码进行阅读。

<code class="btn btn-primary pull-right">ousi373_login.rar</code>

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容

  • 22年12月更新:个人网站关停,如果仍旧对旧教程有兴趣参考 Github 的markdown内容[https://...
    tangyefei阅读 35,127评论 22 257
  • 第二部分 Blog例子 第八章 用户验证 大部分程序需要追踪用户身份。当用户连接到程序,通过一系列步骤使自己的身份...
    易木成华阅读 1,255评论 0 4
  • 最近在学习flask,用到flask-login,发现网上只有0.1版本的中文文档,看了官方已经0.4了,并且添加...
    ZZES_ZCDC阅读 5,858评论 3 24
  • 4 创建一个社交网站 在上一章中,你学习了如何创建站点地图和订阅,并且为博客应用构建了一个搜索引擎。在这一章中,你...
    lakerszhy阅读 2,097评论 0 7
  • 一个现代 web 应用程序需要做的最常见的事情就是处理用户。拥有基本账号功能的一个应用程序需要处理很多的事情,像注...
    邪恶的Sheldon阅读 449评论 0 0