本教程详细介绍在用户注册过程中如何去验证他们的email地址。
工作流程上来讲,在用户注册一个新账户后会寄送一个确认信。直到用户按指示完成了邮件中的“验证”,否则他们的账户会一直处于“未验证”状态。这是大多数网络应用会采用的工作流程。
这当中很重要的一件事就是,未验证的用户有什么权限?或者说,对于你的应用,他们是有全部权限呢,还是被限制的权限呢,还是根本没有权限?对于本教程中的应用,未验证用户会在登录后进到一个页面,会提醒他们只有验证了账户才可以进入应用。
开始前说明一下,很多我们要增加的功能是Flask-用户和Flask-安全的扩展部分——问题来了,为什么不直接用这两个扩展呢?嗯,首先,这是个学习机会。同时,这俩扩展都有局限性,比如支持的数据库。要是你想用RethinkDB怎么办呢?
我们开始吧
Flask基本注册
我们将会要开始一Flask样例,这包括了用户基本注册。从这个github仓库获取代码库。一旦你创建和激活了virtualenv,运行下面的命令来快速开始:
1
2
3
4
5
6
7
|
$ pip install
-
r requirements.txt
$ export APP_SETTINGS
=
"project.config.DevelopmentConfig"
$ python manage.py create_db
$ python manage.py db init
$ python manage.py db migrate
$ python manage.py create_admin
$ python manage.py runserver
|
在应用运行的状态下,访问http://localhost:5000/register页面,注册一个新用户。注意,注册之后应用会自动登陆,引导你进入主页面。大概看一下,然后运行代码——尤其是user这个蓝图(Blueprint是flask的一个概念)。
完成时停止服务器。
更新当前应用
模型
首先,我们来在我们project/models.py中的User
模型里添加上confirmed
字段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
class
User(db.Model):
__tablename__
=
"users"
id
=
db.Column(db.Integer, primary_key
=
True
)
email
=
db.Column(db.String, unique
=
True
, nullable
=
False
)
password
=
db.Column(db.String, nullable
=
False
)
registered_on
=
db.Column(db.DateTime, nullable
=
False
)
admin
=
db.Column(db.Boolean, nullable
=
False
, default
=
False
)
confirmed
=
db.Column(db.Boolean, nullable
=
False
, default
=
False
)
confirmed_on
=
db.Column(db.DateTime, nullable
=
True
)
def
__init__(
self
, email, password, confirmed,
paid
=
False
, admin
=
False
, confirmed_on
=
None
):
self
.email
=
email
self
.password
=
bcrypt.generate_password_hash(password)
self
.registered_on
=
datetime.datetime.now()
self
.admin
=
admin
self
.confirmed
=
confirmed
self
.confirmed_on
=
confirmed_on
|
注意此区域是怎样默认成“False”的。还要添加个confirmed_on
字段,那是个datetime
。为了用队列分析来分析registered_on
和confirmed_on
日期的不同,所以我想包含这个datetime
。
让我们完全从头开始创建数据库并迁移吧!所以,先删除数据库dev.sqlite,以及“迁移”文件夹。
控制命令
接下来,在manage.py中,更新create_admin
命令,使新的数据库字段生效:
1
2
3
4
5
6
7
8
9
10
11
|
@manager
.command
def
create_admin():
"""Creates the admin user."""
db.session.add(User(
email
=
"ad@min.com"
,
password
=
"admin"
,
admin
=
True
,
confirmed
=
True
,
confirmed_on
=
datetime.datetime.now())
)
db.session.commit()
|
要确保导入datetime
。现在,先再一次运行下面的指令:
1
2
3
4
|
$ python manage.py create_db
$ python manage.py db init
$ python manage.py db migrate
$ python manage.py create_admin
|
register()视图函数
最后,在我们再次注册用户之前,我们需要改一下project/user/views.py中的register()
视图函数……
把下面的:
1
2
3
4
|
user
=
User(
email
=
form.email.data,
password
=
form.password.data
)
|
改成下面的:
1
2
3
4
5
|
user
=
User(
email
=
form.email.data,
password
=
form.password.data,
confirmed
=
False
)
|
明白了吗?思考下为什么要把confirmed
默认成False
。
嗯好。再运行一遍应用。转入到http://localhost:5000/register,再注册一个新用户。如果你在SQLite浏览器中打开了你的SQLite数据库,你会看到:
那么,这个我新注册的用户,michael@realpython.com没有被验证。让我们验证它。
添加email验证
产生验证令牌
邮件验证应包含一个特殊URL,使得用户只需简单地点击它,即可验证他/她的账户。理想情况下,这个URL应该看起来像这样–http://yourapp.com/confirm/<id>。这里的关键是它id
。在这个id
中用itsdangerous包,编码用户的邮件(包含时间戳)。
创建一个叫project/token.py的文件,添加下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# project/token.py
from
itsdangerous
import
URLSafeTimedSerializer
from
project
import
app
def
generate_confirmation_token(email):
serializer
=
URLSafeTimedSerializer(app.config[
'SECRET_KEY'
])
return
serializer.dumps(email, salt
=
app.config[
'SECURITY_PASSWORD_SALT'
])
def
confirm_token(token, expiration
=
3600
):
serializer
=
URLSafeTimedSerializer(app.config[
'SECRET_KEY'
])
try
:
email
=
serializer.loads(
token,
salt
=
app.config[
'SECURITY_PASSWORD_SALT'
],
max_age
=
expiration
)
except
:
return
False
return
email
|
所以在generate_confirmation_token()
函数中,通过URLSafeTimedSerializer
用在用户注册时得到的email地址生成一个令牌。那个_真实的_email在令牌中被编了码。确认令牌之后,在confirm_token()
函数中,我们可以用loads()
方法,它接管令牌和其过期时间——一个小时(3600秒)内有效——作为参数。只要令牌没过期,那它就会返回一个email。
在应用的配置中(BaseConfig()
),确保添加SECURITY_PASSWORD_SALT
:
1
|
SECURITY_PASSWORD_SALT
=
'my_precious_two'
|
更新register()视图函数
现在再从project/user/views.py更新下register()
视图函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@user_blueprint
.route(
'/register'
, methods
=
[
'GET'
,
'POST'
])
def
register():
form
=
RegisterForm(request.form)
if
form.validate_on_submit():
user
=
User(
email
=
form.email.data,
password
=
form.password.data,
confirmed
=
False
)
db.session.add(user)
db.session.commit()
token
=
generate_confirmation_token(user.email)
|
也是,要确保更新了这些导入模块:
1
|
from
project.token
import
generate_confirmation_token, confirm_token
|
处理Email验证
接下来,添加个新的视图来解决email验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@user_blueprint
.route(
'/confirm/<token>'
)
@login_required
def
confirm_email(token):
try
:
email
=
confirm_token(token)
except
:
flash(
'The confirmation link is invalid or has expired.'
,
'danger'
)
user
=
User.query.filter_by(email
=
email).first_or_404()
if
user.confirmed:
flash(
'Account already confirmed. Please login.'
,
'success'
)
else
:
user.confirmed
=
True
user.confirmed_on
=
datetime.datetime.now()
db.session.add(user)
db.session.commit()
flash(
'You have confirmed your account. Thanks!'
,
'success'
)
return
redirect(url_for(
'main.home'
))
|
把这个添加到project/user/views.py。同样,确保更新了这些导入:
1
|
import
datetime
|
现在我们通过令牌调用confirm_token()
函数。如果成功,我们更新用户,把email_confirmed
属性改成True
, 设置datetime
为验证发生的时间。还有,要是用户已经进行过一遍验证过程了——而且已经验证了——我们要提醒用户这点。
创建email模板
接着,添加一个基础email模板:
1
2
3
4
|
<p>Welcome! Thanks
for
signing up. Please follow this link to activate your account:<
/
p>
<p><a href
=
"{{ confirm_url }}"
>{{ confirm_url }}<
/
a><
/
p>
<p>Cheers!<
/
p>
|
把这个在“project/templates/user”中存为activate.html。这用了一个简单叫confirm_url
的变量,它会在register()
视图函数中被创建。
发送邮件
通过已经安装了而且设置在project/__init__.py
中的Flask-Mail的一点儿帮助来创建一个基本函数来发送邮件。
创建一个叫email.py的文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# project/email.py
from
flask.ext.mail
import
Message
from
project
import
app, mail
def
send_email(to, subject, template):
msg
=
Message(
subject,
recipients
=
[to],
html
=
template,
sender
=
app.config[
'MAIL_DEFAULT_SENDER'
]
)
mail.send(msg)
|
在“project”文件夹中存一下。
所以,我们只要简单地去处理收件人列表,主题,模板即可。我们会一点一点处理邮件配置的设置。
在project/user/views.py中(再次!)更新register()视图函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
@user_blueprint
.route(
'/register'
, methods
=
[
'GET'
,
'POST'
])
def
register():
form
=
RegisterForm(request.form)
if
form.validate_on_submit():
user
=
User(
email
=
form.email.data,
password
=
form.password.data,
confirmed
=
False
)
db.session.add(user)
db.session.commit()
token
=
generate_confirmation_token(user.email)
confirm_url
=
url_for(
'user.confirm_email'
, token
=
token, _external
=
True
)
html
=
render_template(
'user/activate.html'
, confirm_url
=
confirm_url)
subject
=
"Please confirm your email"
send_email(user.email, subject, html)
login_user(user)
flash(
'A confirmation email has been sent via email.'
,
'success'
)
return
redirect(url_for(
"main.home"
))
return
render_template(
'user/register.html'
, form
=
form)
|
还要添加下面的导入模块:
1
|
from
project.email
import
send_email
|
我们在这里将所有的东西整合到一起。这个函数基本功能是作为控制器(直接或间接):
- 处理最初注册,
- 产生令牌和确认URL,
- 寄送确认email,
- 快速验证,
- 用户登入,
- 更改用户。
你注意到_external=True
参数了吗?这增加了包含了hostname和port(在我们情况中,http://localhost:5000)的完整URL。
在我们测试这个之前,我们要去设定邮件设置。
邮件
在project/config.py中更新BaseConfig()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
class
BaseConfig(
object
):
"""Base configuration."""
# main config
SECRET_KEY
=
'my_precious'
SECURITY_PASSWORD_SALT
=
'my_precious_two'
DEBUG
=
False
BCRYPT_LOG_ROUNDS
=
13
WTF_CSRF_ENABLED
=
True
DEBUG_TB_ENABLED
=
False
DEBUG_TB_INTERCEPT_REDIRECTS
=
False
# mail settings
MAIL_SERVER
=
'smtp.googlemail.com'
MAIL_PORT
=
465
MAIL_USE_TLS
=
False
MAIL_USE_SSL
=
True
# gmail authentication
MAIL_USERNAME
=
os.environ[
'APP_MAIL_USERNAME'
]
MAIL_PASSWORD
=
os.environ[
'APP_MAIL_PASSWORD'
]
# mail accounts
MAIL_DEFAULT_SENDER
=
'from@example.com'
|
查看 official Flask-Mail documentation 以得到更多信息
如果你已经有了GMAIL账号,那你可以用它或者注册一个测试用GMAIL账户。然后把环境变量暂时设置在当前shell中:
1
2
|
$ export APP_MAIL_USERNAME
=
"foo"
$ export APP_MAIL_PASSWORD
=
"bar"
|
如果你GMAIL账号有两步授权, Google会屏蔽掉它。
现在开始测试!
第一个测试
打开应用,转入到 http://localhost:5000/register。然后用你能登陆的email地址注册。顺利的话,你应该会收到封email,看起来像这样:
点击URL,你会转到 http://localhost:5000/。保证用户在数据库里,那‘confirmed’字段是True
,有个datetime
和confirmed_on
字段绑定在一起。
好!
处理许可
如果你记得,在教程的开始部分,我们决定了“未验证用户可以登录但是他们会立刻被转入一个页面——我们称之为/unconfirmed路径——提醒用户需要验证账户才能使用应用”。
所以,我们要——
- 添加
/unconfirmed
路径 - 添加unconfirmed.html模板
- 更新
register()
视图函数 - 创建装饰器
- 更新navigation.html模板
添加/unconfirmed路径
添加下面的路径project/user/views.py:
1
2
3
4
5
6
7
|
@user_blueprint
.route(
'/unconfirmed'
)
@login_required
def
unconfirmed():
if
current_user.confirmed:
return
redirect(
'main.home'
)
flash(
'Please confirm your account!'
,
'warning'
)
return
render_template(
'user/unconfirmed.html'
)
|
你看过类似的代码,所以我们继续。
添加unconfirmed.html模板
1
2
3
4
5
6
7
8
9
10
|
{% extends "_base.html" %}
{% block content %}
<
h1
>Welcome!</
h1
>
<
p
>You have not confirmed your account. Please check your inbox (and your spam folder) - you should have received an email with a confirmation link.</
p
>
<
p
>Didn't get the email? <
a
href
=
"/"
>Resend</
a
>.</
p
>
{% endblock %}
|
在“project/templates/user”中,把这个存为 unconfirmed.html 。这次应该直截了当。现在,为了重新寄送验证email,就只加了一个假的URL。我们接下来会解决它的。
更新register()视图函数
现在简单地把:
1
|
return
redirect(url_for(
"main.home"
))
|
变成:
1
|
return
redirect(url_for(
"user.unconfirmed"
))
|
所以,送完验证email后,用户会进入/unconfirmed
路径。
创建装饰器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
# project/decorators.py
from
functools
import
wraps
from
flask
import
flash, redirect, url_for
from
flask.ext.login
import
current_user
def
check_confirmed(func):
@wraps
(func)
def
decorated_function(
*
args,
*
*
kwargs):
if
current_user.confirmed
is
False
:
flash(
'Please confirm your account!'
,
'warning'
)
return
redirect(url_for(
'user.unconfirmed'
))
return
func(
*
args,
*
*
kwargs)
return
decorated_function
|
这里我们用一个基本函数去检查用户是否验证。如果未验证,用户会进入/unconfirmed
路径。在“project”目录中,把这个存为decorators.py。
现在装饰 profile()
视图函数:
1
2
3
4
5
|
@user_blueprint
.route(
'/profile'
, methods
=
[
'GET'
,
'POST'
])
@login_required
@check_confirmed
def
profile():
... snip ...
|
确保导入装饰器:
1
|
from
project.decorators
import
check_confirmed
|
更新 navigation.html 模板
最后,更新navigation.html模板接下来的部分——
把:
1
2
3
4
5
|
<
ul
class
=
"nav navbar-nav"
>
{% if current_user.is_authenticated() %}
<
li
><
a
href
=
"{{ url_for('user.profile') }}"
>Profile</
a
></
li
>
{% endif %}
</
ul
>
|
变成:
1
2
3
4
5
6
7
|
<
ul
class
=
"nav navbar-nav"
>
{% if current_user.confirmed and current_user.is_authenticated() %}
<
li
><
a
href
=
"{{ url_for('user.profile') }}"
>Profile</
a
></
li
>
{% elif current_user.is_authenticated() %}
<
li
><
a
href
=
"{{ url_for('user.unconfirmed') }}"
>Confirm</
a
></
li
>
{% endif %}
</
ul
>
|
又该测试了!
第二次测试
打开应用,再次用能登陆的email地址注册。(可以随便从数据库删除你之前注册的老用户,这样可以再用一遍)现在,注册完会转入http://localhost:5000/unconfirmed。
确保测试了http://localhost:5000/profile路径。这会使你转到http://localhost:5000/unconfirmed。
验证email,你就会有完整页面的权限了。快去把!
重寄送email
最后,来做重新寄送的链接。增加下面的视图函数到project/user/views.py:
1
2
3
4
5
6
7
8
9
10
|
@user_blueprint
.route(
'/resend'
)
@login_required
def
resend_confirmation():
token
=
generate_confirmation_token(current_user.email)
confirm_url
=
url_for(
'user.confirm_email'
, token
=
token, _external
=
True
)
html
=
render_template(
'user/activate.html'
, confirm_url
=
confirm_url)
subject
=
"Please confirm your email"
send_email(current_user.email, subject, html)
flash(
'A new confirmation email has been sent.'
,
'success'
)
return
redirect(url_for(
'user.unconfirmed'
))
|
现在更新unconfirmed.html 模板:
1
2
3
4
5
6
7
8
9
10
|
{% extends "_base.html" %}
{% block content %}
<
h1
>Welcome!</
h1
>
<
p
>You have not confirmed your account. Please check your inbox (and your spam folder) - you should have received an email with a confirmation link.</
p
>
<
p
>Didn't get the email? <
a
href
=
"{{ url_for('user.resend_confirmation') }}"
>Resend</
a
>.</
p
>
{% endblock %}
|
第三次测试
你知道这次的演练内容了,这次保证去重新寄送一个新的确认email,测试链接。应该没问题。
最后,如果你给自己发好几个验证信,会发生什么? 每封都有效吗?测试一下。注册个新用户,寄送一些新的验证信。验证第一封试试。它可以吗?它应该可以。这样行吗?你是否认为要是新的寄出,其他email应该失效呢?
对这种事做些调查。测试下你用的其他网页应用。它们是怎么处理这种行为的?
更新测试套件
好的。这是为了主要功能。我们更新当前测试套件怎么样?因为它,嗯,有问题。
运行测试:
1
|
$ python manage.py test
|
你会看到下面的错误:
1
|
TypeError: __init__() takes at least 4 arguments (3 given)
|
要去改正它,只需要在project/util.py中更新setUp()
方法:
1
2
3
4
5
|
def
setUp(
self
):
db.create_all()
user
=
User(email
=
"ad@min.com"
, password
=
"admin_user"
, confirmed
=
False
)
db.session.add(user)
db.session.commit()
|
测试之前,在tests/test_models.py中,注释一下test_user_registration()
测试,因为我们不想为了这个测试真 的发送一封邮件。
现在再测试一下。应该全能通过!
结论
还可以做更多的:
- 富文本 vs. 纯文本email——应该都寄出。
- 重设定密码email——当用户忘记密码时,这些应该被寄出。
- 用户管理——应该允许用户更新email和密码,当email变了的话,应该重新验证一遍。
- 测试——需要写更多测试来涉及更多新功能,包含用mock/patch更新的
test_user_registration()
,来防止实际email被发送。