写在前面
昨天我用Python+很多很多的诗词数据做了一个随机诗词生成器,忙活了一天,到最后程序写的诗也没有韵律,直接放弃,还是再来写论坛吧。
完整目录在 zhuanlan.zhihu.com/p/113539585
请注意
这次的文章有点长(32150字),可能要花点时间,可以分段阅读。(我听别人说知乎有两万字的字数限制,没有发现啊)
进入正题
在 zhuanlan.zhihu.com/p/113477674 里,我们已经创建了数据库,现在就是时候让用户注册,登录了。首先,打开app/auth/init.py,更改一处代码:
# app/auth/__init__.py
from flask import Blueprint
auth = Blueprint('auth', __name__, url_prefix='/auth') # 设置URL前缀
from . import views
这里我更改了创建Blueprint的部分,添加了URL前缀,即所有在auth蓝图中的视图URL都以/auth开头,就不用再敲它了(归根结底还是懒,逃)
现在,开始写视图函数:
# app/auth/views.py
from . import auth
from app.models import User
from flask import render_template
@auth.route('/register/')
def register():
return render_template('auth/register.html')
这里必须要吐槽一下Pycharm,昨天还没有显示我main视图中没有模板文件呢,结果今天把Pycharm重启了一下就提示了(当然我创建了模板),无奈之下只好把这个功能关了。。。
好了,回归正题。在这个视图中,我们只渲染了模板,没有做数据库操作,但是一会会弄的。但是不管是登录还是注册,都必须有表单,所以我用了flask-wtf(这里其实我纠结了很久,因为wtf渲染的表单限制太多了,但是还是用了,因为懒)。
先安装它:
pip install flask-wtf
初始化:
没有初始化,开箱即用
先在auth目录下新建forms.py,来存储所有表单:
# app/auth/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Regexp, Email, Length
class RegistrationForm(FlaskForm): # RegistrationForm继承自FlaskForm
# StringField用来获取字符型数据,validators是验证器,Regexp中填写的是正则表达式
username = StringField('用户名', validators=[DataRequired(message='请输入用户名'), Regexp('^[A-Za-z][A-Za-z0-9_.]*$',
0, '用户名只能包含字母,数字,点'
'或下划线'), Length(1, 64)])
# Email验证器用来验证输入是否是邮箱格式,但不确保邮箱是否存在
email = StringField('邮箱', validators=[DataRequired(message='请输入邮箱'), Email(message='请输入真实的邮箱地址')])
password = PasswordField('密码', validators=[DataRequired(message='请输入密码')])
submit = SubmitField('注册')
现在,更改register视图:
# app/auth/views.py
from . import auth
from app.models import User
from flask import render_template
from .forms import RegistrationForm # 导入表单
@auth.route('/register/')
def register():
form = RegistrationForm() # 实例化表单
return render_template('auth/register.html', form=form)
好了,视图总算写完了,现在来编写HTML模板,在templates中新建auth/register.html:
<!--app/templates/register.html-->
{% extends 'base.html' %}
{% from 'bootstrap/form.html' import render_form %}
{% block title %}注册 - AttributeError{% endblock %}
{% block content %}
<div class="container">
{{ render_form(form) }}
</div>
{% endblock %}
这里我们使用了Bootstrap-Flask自带的宏渲染模板,让我们来运行一下看看:

程序发生了RuntimeError!这是因为我没有设置SECRET_KEY。在config.py中加入两行:
# app/config.py
import os
class DevelopmentConfig:
# ...
# 不跟踪变化
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = 'this is my secret key!!' # 设置密钥
class ProductionConfig:
# ...
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = os.environ.get('SECRET_KEY') or 'secret key' # 生产环境首先使用环境变量中的密钥
# ...
现在在运行程序,就正常了:

但是,表单的提交按钮不是我们想要的样子。按照计划,它应该是黄色的(primary),但是它是灰色的!Bing了好久之后,发现在官方文档中:

也就是说,当没有指定button_map时,它默认使用default类的button!让我们更改到primary看看:
<!--app/templates/register.html-->
{% extends 'base.html' %}
{% from 'bootstrap/form.html' import render_form %}
{% block title %}注册 - AttributeError{% endblock %}
{% block content %}
<div class="container">
<h1>注册</h1>
<hr>
{{ render_form(form, button_map={'submit': 'primary'}) }} {# 将button_map设置为primary #}
</div>
{% endblock %}

但是,当我们提交表单时,Flask返回了Method not allowed错误!再看看flask-bootstrap的文档,发现:

可是不对啊!render_form默认就是使用post,那发生了什么???看看自己的代码,发现是自己的视图中没有填写post方式,现在补上:
# app/auth/views.py
from . import auth
from app.models import User
from flask import render_template
from .forms import RegistrationForm # 导入表单
@auth.route('/register/', methods=['GET', 'POST'])
def register():
form = RegistrationForm() # 实例化表单
return render_template('auth/register.html', form=form)
再刷新网页,bug修复了。但是,可能你注意到了,当提交空信息时,表单中的DataRequired验证器返回的错误信息却还是浏览器默认信息。我搜索了好半天,也没有找到解决办法,无奈只好在GitHub上提了一个issue。目前还没有回复,有的话我会立即更新文章。让我们先暂时不管这个小毛病。
现在,我们再在控制台中打印一下刚刚表单中的信息:
# app/auth/views.py
from . import auth
from app.models import User
from flask import render_template
from .forms import RegistrationForm # 导入表单
@auth.route('/register/', methods=['GET', 'POST'])
def register():
form = RegistrationForm() # 实例化表单
if form.validate_on_submit(): # 当form被提交时执行
print(form.username.data, form.email.data, form.password.data) # 获取表单信息
return render_template('auth/register.html', form=form)
重新提交后,在控制台中应该会出现类似下面的内容:
Sam sam@example.com 123
其中依次是用户名,邮箱,和密码。在这里,注册时只输入一次密码非常容易输错,所以我们应该再添加一个验证密码的区域:
# app/auth/forms.py
# ...
class RegistrationForm(FlaskForm): # RegistrationForm继承自FlaskForm
# ...
password = PasswordField('密码', validators=[DataRequired(message='请输入密码')])
password2 = PasswordField('确认密码', validators=[DataRequired(message='请输入确认密码'), EqualTo('password',
message='密码不一致')])
submit = SubmitField('注册')
刷新页面,奇怪的事发生了。原来的DataRequired错误信息竟然在password2中修复了,不过其他表单还是原封不动。。好了,先不管他。我们可以现在开始编写真正的注册功能,但把密码直接放在数据库里不是一个好办法。但是,通过加密,我们可以解决这个问题。我找到了 flask-bcrypt.readthedocs.io/en/latest/ 用来加密密码。先下载并初始化它:
pip install flask-bcrypt # 下载
初始化:
# app/extensions.py
# ...
from flask_bcrypt import Bcrypt
# 实例化扩展
# ...
bcrypt = Bcrypt()
# app/__init__.py
# ...
def create_app():
app = Flask(__name__) # 创建app实例
# ...
bcrypt.init_app(app)
# ...
return app # 返回app
要使用Flask-Bcrypt,要编辑User类:
# app/models.py
from .extensions import db, bcrypt # 导入SQLAlchemy
# ...
class User(db.Model): # User类继承自db.Model
# ...
password = db.Column(db.String(255)) # 存放密码
def __init__(self, password, **kwargs):
super().__init__(password=password, **kwargs)
self.password = self.set_password(password) # 初始化时将未加密的密码加密
def set_password(self, password):
return bcrypt.generate_password_hash(password) # 调用Flask-Bcrypt内置函数生成密码哈希值
def check_password(self, password):
return bcrypt.check_password_hash(self.password, password) # 检查密码是否与哈希值对应
# ...
为了为生产环境做准备,我们也可以在Role类里制作一个函数,来自动生成所需的role:
# app/models.py
from .extensions import db, bcrypt # 导入SQLAlchemy
class Role(db.Model):
__tablename__ = 'role'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64))
users = db.relationship('User', backref='role', lazy='dynamic') # 创建一个关联
@staticmethod
def insert_role():
print('Inserting roles...', end='')
roles = ['普通用户', '协管员', '管理员']
for role in roles:
if Role.query.filter_by(name=role).first() is None:
role = Role(name=role)
db.session.add(role)
db.session.commit()
for user in User.query.all():
if user.role is None:
user.role = Role.query.filter_by(name='普通用户').first()
db.session.add(user)
db.session.commit()
print('done')
def __repr__(self):
return '<Role %s>' % self.name
class User(db.Model): # User类继承自db.Model
# ...
为了更方便地开发和部署,我们可以制作一个命令来一键部署,并且将各个模型类都自动在flask shell里导入:
# app.py
from app import create_app # 导入create_app
from app.models import Role, User
from app.extensions import db
app = create_app() # 创建应用
@app.shell_context_processor # Flask内置的shell上下文装饰器
def make_shell_context():
return dict(db=db, Role=Role, User=User) # 返回包含所有模型的字典
@app.cli.command() # Flask集成了click,我们可以使用它的命令来轻松创建命令行命令
def deploy():
"""Deploy the application"""
Role.insert_role()
if __name__ == '__main__':
app.run(debug=True) # 运行应用,并开启调试模式
在这里,我查看flask的所有命令时,deploy没有出现。但是我把app.py重命名成AttributeError.py(或者其他的名字也可以),就好了。另外我也发现flask shell一直都处在production模式下,我就在根目录下的.env中添加了FLASK_ENV=development这一句,等要部署时再换回来。现在,让我们先把之前测试用的用户和角色删除,打开flask shell:
Python 3.8.1 (v3.8.1:1b293b6006, Dec 18 2019, 14:08:53)
[Clang 6.0 (clang-600.0.57)] on darwin
App: app [development]
Instance: /Users/sam/Desktop/Python/AttributeError/instance
>>> for user in User.query.all(): # 这里不用导入是因为我们已经在上下文中定义过了
... db.session.delete(user) # 循环删除
...
>>> db.session.commit() # 提交更改
>>> for role in Role.query.all():
... db.session.delete(role)
...
>>> db.session.commit()
现在,我们可以用刚才的命令deploy了:
(venv) flask deploy
Inserting roles...done
终于,我们可以继续编写视图了:
# app/auth/views.py
from . import auth
from app.models import User, Role
from flask import render_template, flash
from .forms import RegistrationForm # 导入表单
from app.extensions import db
@auth.route('/register/', methods=['GET', 'POST'])
def register():
form = RegistrationForm() # 实例化表单
if form.validate_on_submit(): # 当form被提交时执行
# 获取表单信息
username = form.username.data
email = form.email.data
password = form.password.data
user = User(username=username, email=email, password=password, role=Role.query.filter_by(name='普通用户').first())
db.session.add(user)
db.session.commit()
flash('注册成功', 'success')
return render_template('auth/register.html', form=form)
为了让闪现的消息能够显示,我们需要更改base.html:
<!--app/templates/base.html-->
{# 导入Bootstrap-Flask的内置函数 #}
{% from 'bootstrap/nav.html' import render_nav_item %}
{% from 'bootstrap/utils.html' import render_messages %}
<!DOCTYPE html>
<html lang="en">
<head>
<!--...-->
</head>
<body>
<!--...-->
<br>
{{ render_messages(container=True, dismissible=True, dismiss_animate=True) }} {# 使用Bootstrap-Flask内置函数渲染闪现消息 #}
<br>
{% block content %}{% endblock %} {# 内容块 #}
</body>
{% block scripts %} {# JS代码块 #}
{{ bootstrap.load_js() }} {# 引入bootstrap-flask内置的JavaScript #}
{% endblock %}
</html>
现在,运行程序看看:

当用户名或邮箱已经被注册时,程序会提示我们。密码和确认密码不一致时,程序也会给我们提示。当注册成功时,程序也会告诉我们。现在,我们可以来编写登录功能了。首先,安装flask-login,它集成了登录所需的东西,比如记住我和访客模式:
pip install flask-login
初始化:
# app/extensions.py
from flask_bootstrap import Bootstrap # 导入Bootstrap-Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_bcrypt import Bcrypt
from flask_login import LoginManager
# 实例化扩展
bootstrap = Bootstrap()
db = SQLAlchemy()
migrate = Migrate()
bcrypt = Bcrypt()
login_manager = LoginManager()
# app/__init__.py
# ...
def create_app():
app = Flask(__name__) # 创建app实例
# ...
login_manager.init_app(app)
# ...
return app # 返回app
但是,flask-login还需要设置user_loader,来加载用户。让我们定义它:
# app/extensions.py
# ...
from flask_login import LoginManager
from app.models import User
# 实例化扩展
# ...
login_manager = LoginManager()
login_manager.login_view = "auth.login" # 定义flask-login的登录视图
login_manager.login_message = "请登录后再访问本页" # 定义flask-login的登录消息,默认为'Please log in to access this page.'
@login_manager.user_loader # 定义用户加载器
def load_user(id):
return User.query.get(id)
但这还不够。flask-login还需要让User类继承自UserMixin基类,让我们更改models.py:
# app/models.py
from .extensions import db, bcrypt
from flask_login import UserMixin
# ...
class User(db.Model, UserMixin): # User类继承自db.Model
# ...
现在,我们来编写登录视图,先来创建表单:
# app/auth/forms.py
# ...
class RegistrationForm(FlaskForm): # RegistrationForm继承自FlaskForm
# ...
class LoginForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(message='请输入用户名')])
password = PasswordField('密码', validators=[DataRequired(message='请输入密码')])
现在,打开auth/views.py,编写视图函数:
@auth.route('/login/', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
remember = form.remember_me.data
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user, remember=remember)
flash('登录成功!', 'success')
return redirect(url_for('main.index'))
flash('用户名或密码不正确', 'warning')
return render_template('auth/login.html', form=form)
接下来,编写HTML模板:
<!--app/templates/login.html-->
{% extends 'base.html' %}
{% from 'bootstrap/form.html' import render_form %}
{% block title %}登录 - AttributeError{% endblock %}
{% block content %}
<div class="container">
<h1>登录</h1>
<p>还没有账号?<a href="{{ url_for('auth.register') }}">点击这里</a>注册</p>
<hr>
{{ render_form(form, button_map={'submit': 'primary'}) }}
</div>
{% endblock %}
另外,我们也应该在注册页面给出登录的链接:
<!--app/templates/register.html-->
{% extends 'base.html' %}
{% from 'bootstrap/form.html' import render_form %}
{% block title %}注册 - AttributeError{% endblock %}
{% block content %}
<div class="container">
<h1>注册</h1>
<p>已有账号?<a href="{{ url_for('auth.login') }}">点击这里</a>登录</p>
<hr>
{{ render_form(form, button_map={'submit': 'primary'}) }} {# 将button_map设置为primary #}
</div>
{% endblock %}
一般的网站也会在导航条上显示登录/注册链接,我们也加上:
<!--app/templates/base.html-->
{# 导入Bootstrap-Flask的内置函数 #}
{% from 'bootstrap/nav.html' import render_nav_item %}
{% from 'bootstrap/utils.html' import render_messages %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}{% endblock %} {# 标题块 #}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='Bootstrap/bootstrap.css') }}"> {# 引入自定义的Bootstrap css #}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<!--...-->
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
{{ render_nav_item('main.index', '首页') }} {# 使用Bootstrap-Flask内置的函数渲染导航链接 #}
</ul>
<ul class="navbar-nav ml-auto">
{% if not current_user.is_authenticated %}
{{ render_nav_item('auth.login', '登录') }}
{{ render_nav_item('auth.register', '注册') }}
{% else %}
<span class="navbar-text">
Hi, {{ current_user.username }}
</span>
{% endif %}
</ul>
</div>
</div>
</nav>
<!--...-->
</html>
这里,如果用户登录了,导航条右侧将显示Hi, 用户名,否则显示登录和注册链接。
除了登录,注册外,我们还需要登出。flask-login已经为我们做完了,我们只需要调用它就好了:
# app/auth/views.py
from . import auth
from app.models import User, Role
from flask import render_template, flash, redirect, url_for
from .forms import RegistrationForm, LoginForm # 导入表单
from app.extensions import db
from flask_login import login_user, login_required, logout_user, current_user
# ...
@auth.route('/logout/')
@login_required # 只有用户登录了才能登出
def logout():
logout_user()
flash('你已登出', 'success')
return redirect(url_for('main.index'))
现在,让我们在导航条上显示该链接:
<!--app/templates/base.html-->
<!--...-->
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="/">AttributeError</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
{{ render_nav_item('main.index', '首页') }} {# 使用Bootstrap-Flask内置的函数渲染导航链接 #}
</ul>
<ul class="navbar-nav ml-auto">
{% if not current_user.is_authenticated %}
{{ render_nav_item('auth.login', '登录') }}
{{ render_nav_item('auth.register', '注册') }}
{% else %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Hi, {{ current_user.username }}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">登出</a>
</div>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!--...-->
</html>
现在,用户就可以登入,登出了。但是,我们还没有验证用户的邮箱是否真实。大多数网站使用的都是验证邮箱的方法,我们也来采取这种方式。首先,你需要在 www.sendgrid.com/ 上注册一个账号,然后在控制面板中创建一个apikey,并将它复制下来,或者放到一个文件里。下面,我们来使用它来认证用户。首先,在User模型中加入confirmed行:
# app/models.py
# ...
class User(db.Model, UserMixin): # User类继承自db.Model
# ...
confirmed = db.Column(db.Boolean, default=False)
# ...
然后迁移数据库:
(venv) flask db migrate
# ...
(venv) flask db upgrade
但是,我们也要限制未认证用户的使用。如果用户未认证(confirmed=False),则不让用户访问除auth蓝图中的视图外的所有页面。取而代之的是一个提醒用户认证的页面。让我们先完成这个功能:(这里插一个小插曲,就在我写这篇文章的时候,在5:37分,知乎除了首页外全部报错502,惊讶)
# app/main/views.py
from flask import render_template, request # 导入渲染模板函数
from . import main # 导入蓝图
from flask_login import current_user
@main.route('/') # 定义路由
def index():
return render_template('main/index.html') # 返回渲染后的页面正文
@auth.route('/unconfirmed/')
@login_required
def unconfirmed():
if current_user.confirmed:
return redirect(url_for('main.index'))
return render_template('auth/unconfirmed.html')
@main.before_app_request # 在应用执行每一个请求之前执行的函数
def before_request():
if not current_user.confirmed and request.blueprint != 'auth':
return redirect(url_for('auth.unconfirmed'))
<!--app/templates/auth/unconfirmed.html-->
{% extends 'base.html' %}
{% block title %}认证你的账号 - AttributeError{% endblock %}
{% block content %}
<div class="container">
<h1>Hi, {{ current_user.username }}!</h1>
<hr>
<p>你还没有认证你的账号呢!</p>
</div>
{% endblock %}
现在运行程序,如果你上次已经登出或没有选择记住我选项,你可能会看到这个错误:(知乎看来还没完全恢复,无法上传图片,我先复制错误了)
Traceback (most recent call last):
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 2463, in __call__
return self.wsgi_app(environ, start_response)
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 2449, in wsgi_app
response = self.handle_exception(e)
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 1866, in handle_exception
reraise(exc_type, exc_value, tb)
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/_compat.py", line 39, in reraise
raise value
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 2446, in wsgi_app
response = self.full_dispatch_request()
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 1951, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 1820, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/_compat.py", line 39, in reraise
raise value
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 1947, in full_dispatch_request
rv = self.preprocess_request()
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/flask/app.py", line 2241, in preprocess_request
rv = func()
File "/Users/sam/Desktop/Python/AttributeError/app/main/views.py", line 15, in before_request
if not current_user.confirmed and request.blueprint != 'auth':
File "/Users/sam/Desktop/Python/AttributeError/venv/lib/python3.8/site-packages/werkzeug/local.py", line 347, in __getattr__
return getattr(self._get_current_object(), name)
AttributeError: 'AnonymousUserMixin' object has no attribute 'confirmed'
这次真的是报AttributeError了。。不行,必须修好,要不然白起这个名字了。看了看,是因为原本flask-login内置的AnonymousUserMixin没有confirmed属性,我们给它加上这个判断就好了:
@main.before_app_request # 在应用执行每一个请求之前执行的函数
def before_request():
if not current_user.is_anonymous and not current_user.confirmed and request.blueprint != 'auth':
return redirect(url_for('auth.unconfirmed'))
嗯,问题解决了。现在让我们先编写发邮件的函数,首先,我们需要flask-mail来简化操作。先安装它:
(venv) pip install flask-mail
初始化:
# app/extensions.py
# ...
from flask_mail import Mail
# 实例化扩展
# ...
mail = Mail()
# app/__init__.py
# ...
def create_app():
app = Flask(__name__) # 创建app实例
# ...
mail.init_app(app)
# ...
return app # 返回app
在编写发送邮件的函数前,我们需要添加一些设置。打开根目录下的.env文件,添加如下内容:
MAIL_PASSWORD=<your-apikey>
将上面的<your-apikey>替换成你刚刚在Sendgrid生成的apikey,然后,打开config.py: 将上面的<your-apikey>替换成你刚刚在Sendgrid生成的apikey,然后,打开config.py:
# app/config.py
import os
class DevelopmentConfig:
DEBUG = True # 设置为调试模式
# 设置数据库位置
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URI') or 'mysql+pymysql://root:%s@localhost:3306/%s' \
'?charset'\
'=utf8mb4' % (os.environ.get('DEV_DATABASE_PASS'),
os.environ.get('DEV_DATABASE_NAME'))
# 不跟踪变化
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = 'this is my secret key!!' # 设置密钥
MAIL_USERNAME = 'apikey'
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_SERVER = 'smtp.sendgrid.net'
MAIL_PORT = 465
MAIL_USE_SSL = True
class ProductionConfig:
DEBUG = False # 关闭调试
# 设置在生产环境中使用的数据库
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or 'mysql+pymysql://root:%s@localhost:3306/%s?charset' \
'=utf8mb4' % (os.environ.get('DATABASE_PASS'), os.environ.get('DATABASE_NAME'))
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = os.environ.get('SECRET_KEY') or 'secret key' # 生产环境首先使用环境变量中的密钥
MAIL_USERNAME = 'apikey'
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_SERVER = 'smtp.sendgrid.net'
MAIL_PORT = 465
MAIL_USE_SSL = True
# 设置在不同情况下使用的config
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
这里,我们设置了邮箱的用户名(sendgrid都是「apikey」),密码(真正的apikey,从环境变量中获取),邮箱服务器( smtp.sendgrid.net ),邮箱发送端口(465,sendgrid唯一使用SSL的端口),和邮箱发送服务器是否使用SSL(True)。现在开始使用flask-mail来发送邮件,新建app/utils.py:
# app/utils.py
from flask_mail import Message
from flask import render_template
from .extensions import mail
def send_email(from_address, to_address, title, template=None, **kwargs):
msg = Message(title, sender=from_address, recipients=[to_address]) # 创建消息
if template:
msg.body = render_template('%s.txt' % template, **kwargs) # 渲染文本正文
msg.html = render_template('%s.html' % template, **kwargs) # 渲染HTML正文
else:
msg.body = title
mail.send(msg) # 发送邮件
现在,让我们来测试一下,打开flask shell:
Python 3.8.1 (v3.8.1:1b293b6006, Dec 18 2019, 14:08:53)
[Clang 6.0 (clang-600.0.57)] on darwin
App: app [development]
Instance: /Users/sam/Desktop/Python/AttributeError/instance
>>> from app.utils import send_email # 导入该函数
>>> # 这里可以将参数设置成别的,第一个是发件人地址,第二个是收件人地址,最后是邮件正文(因为没有设置template,所以直接使用它作为正文和标题)
>>> send_email('noreply@attributeerror.com', 'sam@example.com', 'Hi, User! This is a test email and sent by Python. Can you see it?')
send: 'ehlo [127.0.0.1]\r\n'
reply: b'250-smtp.sendgrid.net\r\n'
reply: b'250-8BITMIME\r\n'
reply: b'250-PIPELINING\r\n'
reply: b'250-SIZE 31457280\r\n'
reply: b'250-AUTH PLAIN LOGIN\r\n'
reply: b'250 AUTH=PLAIN LOGIN\r\n'
reply: retcode (250); Msg: b'smtp.sendgrid.net\n8BITMIME\nPIPELINING\nSIZE 31457280\nAUTH PLAIN LOGIN\nAUTH=PLAIN LOGIN'
send: 'AUTH PLAIN AGFwaWtleQBTRy55bWRPMVBYSlFmMkZTZDZjcG9jMVRRLjZVU09jVjBJOHRuWUg1ZF9sMkg2OEZXUVFnSW0wRzFVZ1R0QTI5di04bDQ=\r\n'
reply: b'235 Authentication successful\r\n'
reply: retcode (235); Msg: b'Authentication successful'
send: 'mail FROM:<noreply@attributeerror.com> size=402\r\n'
reply: b'250 Sender address accepted\r\n'
reply: retcode (250); Msg: b'Sender address accepted'
send: 'rcpt TO:<samzhang951@outlook.com>\r\n'
reply: b'250 Recipient address accepted\r\n'
reply: retcode (250); Msg: b'Recipient address accepted'
send: 'data\r\n'
reply: b'354 Continue\r\n'
reply: retcode (354); Msg: b'Continue'
data: (354, b'Continue')
send: b'Content-Type: text/plain; charset="utf-8"\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 7bit\r\nSubject: Hi, User! This is a test email and sent by Python. Can you see it?\r\nFrom: noreply@attributeerror.com\r\nTo: samzhang951@outlook.com\r\nDate: Sat, 21 Mar 2020 19:01:13 +0800\r\nMessage-ID: <158478847246.17263.7630660357922288153@bogon>\r\n\r\nHi, User! This is a test email and sent by Python. Can you see it?\r\n.\r\n'
reply: b'250 Ok: queued as wkngCyhORL6Q5SKRAqPjuQ\r\n'
reply: retcode (250); Msg: b'Ok: queued as wkngCyhORL6Q5SKRAqPjuQ'
data: (250, b'Ok: queued as wkngCyhORL6Q5SKRAqPjuQ')
send: 'quit\r\n'
reply: b'221 See you later\r\n'
reply: retcode (221); Msg: b'See you later'
下面,让我们来编写验证用户的函数。首先,我们需要生成令牌。itsdangerous提供给了我们这个功能,我们只需要调用它即可(但其实自己还是没明白具体为什么这样写,是从 github.com/miguelgrinb… copy过来的,还请大神指点):
# app/models.py
from flask import current_app
from itsdangerous import Serializer
from .extensions import db, bcrypt
from flask_login import UserMixin
# ...
class User(db.Model, UserMixin): # User类继承自db.Model
# ...
def generate_confirmation_token(self, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'confirm': self.id}).decode('utf-8')
def confirm(self, token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token.encode('utf-8'))
except:
return False
if data.get('confirm') != self.id:
return False
self.confirmed = True
db.session.add(self)
return True
# ...
现在,我们就可以编写认证的视图了:
# app/auth/views.py
# ...
@auth.route('/confirm/<token>/')
@login_required
def confirm_user(token):
user = current_user._get_current_object()
if user.confirm(token):
db.session.commit()
flash('验证用户成功!', 'success')
return redirect(url_for('main.index'))
flash('令牌不正确,验证失败', 'error')
return redirect(url_for('main.index'))
# ...
但是这还不够。我们必须在注册时就生成验证令牌,并发送验证邮件给用户。再更改views.py:
# app/auth/views.py
# ...
from flask_login import login_user, login_required, logout_user, current_user
from app.utils import send_email
@auth.route('/register/', methods=['GET', 'POST'])
def register():
form = RegistrationForm() # 实例化表单
if form.validate_on_submit(): # 当form被提交时执行
# 获取表单信息
username = form.username.data
email = form.email.data
password = form.password.data
user = User(username=username, email=email, password=password, role=Role.query.filter_by(name='普通用户').first())
db.session.add(user)
db.session.commit()
token = user.generate_confirmation_token()
send_email('noreply@attributeerror.com', user.email, '认证你的账号', template='auth/email/confirm', user=user,
token=token)
flash('注册成功', 'success')
return render_template('auth/register.html', form=form)
# ...
现在,我们可以把之前创建的用户删除,重新创建一个,来测试该功能。
但是,有时可能用户没有收到邮件或把邮件不小心删除了,因此我们需要提供重新发送按钮。首先,创建该视图:
# app/auth/views.py
# ...
@auth.route('/confirm/re-send/')
@login_required
def re_send_confirm():
user = current_user._get_current_object()
token = user.generate_confirmation_token()
send_email('noreply@attributeerror.com', user.email, '认证你的账号', template='auth/email/confirm', user=user,
token=token)
flash('一封新的验证邮件发送成功!', 'success')
return redirect(url_for('main.index'))
# ...
然后,更改unconfirmed.html,来让用户能重新发送邮件:
<!--app/templates/auth/unconfirmed.html-->
{% extends 'base.html' %}
{% block title %}认证你的账号 - AttributeError{% endblock %}
{% block content %}
<div class="container">
<h1>Hi, {{ current_user.username }}!</h1>
<hr>
<p>你还没有认证你的账号呢!</p>
<p>没有收到验证邮件?请检查您的垃圾邮件,或<a href="{{ url_for('auth.re_send_confirm') }}">点击这里</a>重新发送验证邮件!</p>
</div>
{% endblock %}
你可能发现了,现在发送邮件,网页都会无响应1-2秒钟,这是程序在发送邮件。我们可以使用异步发送邮件来解决这个问题:
# app/utils.py
from flask_mail import Message
from flask import render_template, current_app
from .extensions import mail
import threading
def send_async_email(msg):
with current_app.app_context():
mail.send(msg)
def send_email(from_address, to_address, title, template=None, **kwargs):
msg = Message(title, sender=from_address, recipients=[to_address]) # 创建消息
if template:
msg.body = render_template('%s.txt' % template, **kwargs) # 渲染文本正文
msg.html = render_template('%s.html' % template, **kwargs) # 渲染HTML正文
else:
msg.body = title
threading.Thread(target=send_async_email, args=[msg]) # 异步发送邮件
你可能注意到了,现在我们的注册页面没有实现跳转到登录页面,这很不方便。让我们把它加上:
# app/auth/views.py
# ...
@auth.route('/register/', methods=['GET', 'POST'])
def register():
form = RegistrationForm() # 实例化表单
if form.validate_on_submit(): # 当form被提交时执行
# 获取表单信息
username = form.username.data
email = form.email.data
password = form.password.data
user = User(username=username, email=email, password=password, role=Role.query.filter_by(name='普通用户').first())
db.session.add(user)
db.session.commit()
token = user.generate_confirmation_token()
send_email('noreply@attributeerror.com', user.email, '认证你的账号', template='auth/email/confirm', user=user,
token=token)
flash('注册成功,你现在可以登录了', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
# ...
但是,一旦有用户进入了需要登录才能访问的页面,他会先进入登录页面,然后直接跳转到首页,而不是他刚刚访问的页面。flask-login已经在地址栏中告诉我们用户来自哪个页面了,我们只需要跳转即可。但是,我们必须验证url的安全性。在utils.py中添加一些代码:
# app/utils.py
from urllib.parse import urlparse, urljoin
from flask_mail import Message
from flask import render_template, current_app, request
from .extensions import mail
import threading
# ...
def is_safe_url(target):
ref_url = urlparse(request.host_url) # 获取程序内的主机url
test_url = urlparse(urljoin(request.host_url, target)) # 将目标URl转换为绝对路径
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc # 验证是否属于内部url
接下来,是跳转功能的实现:
# app/auth/views.py
# ...
from app.utils import send_email, is_safe_url
# ...
@auth.route('/login/', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
remember = form.remember_me.data
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user, remember=remember)
next = request.args.get('next')
flash('登录成功!', 'success')
if is_safe_url(next):
return redirect(next)
return redirect(url_for('main.index'))
flash('用户名或密码不正确', 'warning')
return render_template('auth/login.html', form=form)
到目前为止,我们已经将用户登入,登出,注册,和验证邮箱的功能做完了。这篇文章我花了将近5天才写完,很可能没有人能看到这了,不过还是要做一下笔记。
如果你看到这里了并且你不想敲代码,可以clone我在GitHub上的仓库:(本次版本号为694e079) github.com/samzhangjy/…
别忘了执行 pip install -r requirements.txt和flask db upgrade来下载Flask扩展和更新数据库!最后,你也要更新你的.env文件,写上你的sendgrid信息。
写在最后
啊,终于写完了。如果哪里有错误,还请大神指教!
还有,我在Bootstrap-Flask提的issue还没有回复,如果有我会更新文章。