Web开发 ------ 基于Flask的 任务清单管理系统(二):用户认证

本文详细介绍了基于Flask的任务清单管理系统用户认证部分,包括用户注册、登录、邮箱验证的过程。通过Flask-Login插件管理认证状态,使用数据库模型进行用户管理,并通过邮箱验证确认用户身份。注册时发送验证邮件,用户点击链接完成邮箱验证。登录时检查用户认证状态,未验证的用户将被重定向到确认页面。文章还涵盖了单元测试、数据库迁移和代码提交到GitHub的流程。

任务清单管理系统(二)

项目地址:https://github.com/lvah/TodoList
在这里插入图片描述

我们的项目的一个主要流程:
在这里插入图片描述
用户第一次访问我们的网站,首先要注册,每个用户只能注册一个账户,我们通过邮箱来验证。
注册的时候通过邮箱验证 confirm给邮箱地址发送一个地址,验证成功,就可以登陆
登陆过程中我们先判断邮箱有没有验证,
如果有验证,则允许访问任务清单管理系统,添加或者删除任务等;
如果没有验证,我们会返回一个邮箱没有验证的页面,点击链接进行重新验证,我们会给邮箱重新发送一个邮件进行验证。

一、用户认证

大多数程序都要进行用户跟踪。用户连接程序时会进行身份认证,通过这一过程,让程序知道自己的身
份。程序知道用户是谁后,就能提供有针对性的体验。

1.数据库模型

(1)技术要点

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

  • generate_password_hash(password, method= pbkdf2:sha1 , salt_length=8) :密码加密的散列值。
    • generate_password_hash(明文密码,加密方法,加密盐长度)
  • check_password_hash(hash, password) :密码散列值和用户输入的密码是否匹配.

测试hash加密:
加密方法:在密码后面加一系列长度,在对密码进行哈希加密
在这里插入图片描述

@property 是经典类中的一种装饰器,新式类中具有三种:
在这里插入图片描述

(2)核心代码

# app/models.py

'''
设计数据库模型:
    1). 用户信息: User
    2). 用户角色信息: Role
    3). 用户角色: 用户 = 1:n, 一对多关系,外键写在多的一端。
'''

from werkzeug.security import generate_password_hash, check_password_hash
from app import db
from flask_login import UserMixin
from . import login_manager

'''
Flask-Login 提供了一个 UserMixin 类,包含常用方法的默认实现,且能满足大多数需求。
1). is_authenticated 用户是否已经登录?
2). is_active 是否允许用户登录?False代表用户禁用
3). is_anonymous 是否匿名用户? 
4). get_id() 返回用户的唯一标识符
'''
# Flask中一个Model子类就是数据库中的一个表。默认表名'User'.lower() ===> user
class User(UserMixin,db.Model):
    __tablename__ = 'users'  # 自定义数据表的表名
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username = db.Column(db.String(100), unique=True, nullable=False)
    password_hash = db.Column(db.String(200), nullable=True)
    # 电子邮件地址email,相对于用户名而言,用户更不容易忘记自己的电子邮件地址。
    email = db.Column(db.String(64), unique=True, index=True)
    # 外键关联
    role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute')

    @password.setter
    def password(self, password):
        # generate_password_hash(password, method= pbkdf2:sha1 , salt_length=8):密码加密的散列值。
        self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
        # check_password_hash(hash, password) :密码散列值和用户输入的密码是
        return check_password_hash(self.password_hash, password)

    def __repr__(self):
        return "<User: %s>" % (self.username)

class Role(db.Model):
    __tablename__ = 'roles'
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(100), unique=True, nullable=False)
    # 做了两件事情: 1). Role添加属性users    2). User添加属性role
    users = db.relationship('User', backref='role')

    def __repr__(self):
        return "<Role: %s>" % (self.name)
  • manage中 数据库迁移 和shell 添加
# manage

from flask_migrate import Migrate, MigrateCommand
from flask_script import Manager, Shell
from app import create_app, db

# 动态创建app对象
app = create_app('default')
# 脚本管理
manager = Manager(app)
# 将数据库迁移插件与数据库db和app关联
migrate = Migrate(app, db)

def make_shell_context():
    return dict(app=app, db=db)
    

if __name__ == '__main__':
    # 初始化 Flask-Script、Flask-Migrate 和为 Python shell 定义的上下文。
    manager.add_command("shell", Shell(make_context=make_shell_context))
    manager.add_command('db', MigrateCommand)
    manager.run()

添加成功:
在这里插入图片描述

在这里插入图片描述

提交数据库

python manage.py db init 执行了之后是在目录中新增了一个文件,里面存储对代码所有更改的版本
python manage.py db migrate:当数据库代码有所更改时,执行这个代码,在目录中生成一个新的文件,打开文件,里面记录所更改的内容。def upgrade()是其中一个函数,
执行python manage.py db upgrade代码,就是提交到数据库

# python manage.py db init 执行了之后是在目录中新增了一个文件,里面存储对代码所有更改的版本
(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>python manage.py db init

Creating directory F:\ziliao\python_kaifa\my_code\09_\TodoList\migrations ...  done
Creating directory F:\ziliao\python_kaifa\my_code\09_\TodoList\migrations\versions ...
done
Generating F:\ziliao\python_kaifa\my_code\09_\TodoList\migrations\alembic.ini ...  done
Generating F:\ziliao\python_kaifa\my_code\09_\TodoList\migrations\env.py ...  done
Generating F:\ziliao\python_kaifa\my_code\09_\TodoList\migrations\README ...  done
Generating F:\ziliao\python_kaifa\my_code\09_\TodoList\migrations\script.py.mako ...  do
ne
Please edit configuration/connection/logging settings in 'F:\\ziliao\\python_kaifa\\my_c
ode\\09_\\TodoList\\migrations\\alembic.ini' before proceeding.


# python manage.py db migrate:当数据库代码有所更改时,执行这个代码,在目录中生成一个新的文件,打开文件,里面记录所更改的内容。def upgrade()是其中一个函数,执行python manage.py db upgrade代码,就是提交到数据库
(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>python manage.py db migrate

INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'roles'
INFO  [alembic.autogenerate.compare] Detected added table 'users'
Generating F:\ziliao\python_kaifa\my_code\09_\TodoList\migrations\versions\7c280d603bcf_
.py ...  done


(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>python manage.py db upgrade

INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> 7c280d603bcf, empty message



 ### 在交互式环境中添加用户和用户角色并提交数据库:

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>python manage.py shell

In [1]: role=Role(name='管理员')

In [2]: user=User(username="daliu")

In [3]: user.password = 'daliu'

In [4]: user.role=role

In [5]: db.session.add(role)

In [6]: db.session.add(user)

In [7]: db.session.commit()

In [8]: print(role.users)
[<User: daliu>]

In [9]: print(user.role.name)
管理员

In [10]:

python manage.py db migrate:当数据库代码有所更改时,执行这个代码,在目录中生成一个新的文件,打开文件,里面记录所更改的内容。def upgrade()是其中一个函数,执行python manage.py db upgrade代码,就是提交到数据库
在这里插入图片描述

提交github:
(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>git add *

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>git commit -m "add user model"
[master d42f058] add user model
 15 files changed, 349 insertions(+), 16 deletions(-)
 create mode 100644 .idea/.gitignore
 create mode 100644 .idea/dataSources.xml
 create mode 100644 app/__pycache__/models.cpython-37.pyc
 create mode 100644 data-dev.sqlite
 create mode 100644 migrations/README
 create mode 100644 migrations/__pycache__/env.cpython-37.pyc
 create mode 100644 migrations/alembic.ini
 create mode 100644 migrations/env.py
 create mode 100644 migrations/script.py.mako
 create mode 100644 migrations/versions/7c280d603bcf_.py
 create mode 100644 migrations/versions/__pycache__/7c280d603bcf_.cpython-37.pyc
 create mode 100644 tests/test_user_model.py

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>git push origin master
fatal: HttpRequestException encountered.
   发送请求时出错。
Username for 'https://github.com': error: failed to execute prompt script (exit code 1)
Username for 'https://github.com': daliu-daliu
Password for 'https://daliu-daliu@github.com':
Enumerating objects: 32, done.
Counting objects: 100% (32/32), done.
Delta compression using up to 4 threads
Compressing objects: 100% (22/22), done.
Writing objects: 100% (24/24), 9.77 KiB | 1.95 MiB/s, done.
Total 24 (delta 6), reused 0 (delta 0)
remote: Resolving deltas: 100% (6/6), completed with 6 local objects.
To https://github.com/daliu-daliu/TodoList.git
   e8345aa..d42f058  master -> master

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>

(3)测试代码

# tests/test_user_model.py

import unittest
from app.models import User

class UserModelTestCase(unittest.TestCase):
    """ 用户数据库模型测试 """
    def test_password_setter(self):
        """测试新建的用户密码是否为空?"""
        u = User(password='cat')  #实例化一个用户对象,我们传进去了一个密码
        self.assertTrue(u.password_hash is not None)  #判断这个密码在加密后是否为空,不为空则OK

    def test_no_password_getter(self):
        """测试获取密码信息是否报错?"""
        u = User(password='cat')
        with self.assertRaises(AttributeError):
            password =u.password # 我们穿进去一个密码cat,如果获取到的密码有属性错误,则测试用例通过。

    def test_password_verification(self):
        """测试加密密码和明文密码是否验证正确?"""
        u = User(password='cat')
        self.assertTrue(u.verify_password('cat')) #我们传进去了一个cat密码,验证成功,则OK
        self.assertFalse(u.verify_password('dog'))#在传进一个错误的密码,False则通过

    def test_password_salts_are_random(self):
        """测试每次密码加密的加密字符是否不一致?"""
        u = User(password='cat')
        u2 = User(password='cat')
        self.assertTrue(u.password_hash != u2.password_hash)

在这里插入图片描述

2.Flask-Login优化数据库模型

(1)技术要点

用户登录程序后,他们的认证状态要被记录下来,这样浏览不同的页面时才能记住这个状态。Flask-Login
是个非常有用的小型扩展,**专门用来管理用户认证系统中的认证状态,**且不依赖特定的认证机制。
Flask-Login 提供了一个 UserMixin 类,包含常用方法的默认实现,且能满足大多数需求。
在这里插入图片描述

(2)安装插件

pip install -i https://pypi.douban.com/simple flask-login

(3)核心代码

  • Flask-Login 在程序的工厂函数中初始化, 修改文件 app/__init__.py :

session_protection 属性提供不同的安全等级防止用户会话遭篡改。可以设为:

  • None
  • ‘basic’
  • ‘strong’ : 记录客户端 IP 地址和浏览器的用户代理信息,如果发现异动就登出用户。
# app/__init__.py

from flask_login import LoginManager

# .......此处省略前面重复的代码

login_manager = LoginManager()
# session_protection 属性提供不同的安全等级防止用户会话遭篡改。
login_manager.session_protection = 'strong'
# login_view 属性设置登录页面的端点。
login_manager.login_view = 'auth.login'

def create_app(config_name='development'):
    # ....... 
    # 用户认证新加扩展
    login_manager.init_app(app)
    # ....... 
  
    return  app

  • app/models.py :修改 User 模型,支持用户登录, 同时Flask-Login 要求程序实现一个回调函数,使用指定的标识符加载用户。
# app/models.py

from flask_login import UserMixin
from . import login_manager

'''
Flask-Login 提供了一个 UserMixin 类,包含常用方法的默认实现,且能满足大多数需求。
1). is_authenticated 用户是否已经登录?
2). is_active 是否允许用户登录?False代表用户禁用
3). is_anonymous 是否匿名用户? 
4). get_id() 返回用户的唯一标识符
'''
# Flask中一个Model子类就是数据库中的一个表。默认表名'User'.lower() ===> user
class User(UserMixin,db.Model):
    """用户""" 
    # .............
    # 电子邮件地址email,相对于用户名而言,用户更不容易忘记自己的电子邮件地址。
    email = db.Column(db.String(64), unique=True, index=True)

# 加载用户的回调函数;如果能找到用户,返回用户对象;否则返回 None 。即把用户信息存储到content_user
@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

3.数据库创建

Flask-Migrate 插件提供 AlembicDatabase migration 数据迁移跟踪记录)提供的数据库升级和降
级的功能。它所能实现的效果有如 Git 管理项目代码一般。

在新数据库中创建数据表。可使用如下Bash命令创建数据表或者升级到最新修订版本:

# 初始化数据库, 创建migrations目录,存放了所有迁移脚本。只需要执行一次。 
python3 manage.py db init 
# 创建迁移脚本 
python3 manage.py db migrate 
# 更新数据库 
python3 manage.py db upgrade

二、用户注册

如果新用户想成为程序的成员,必须在程序中注册,这样程序才能识别并登入用户。程序的登录页面中要
显示一个链接,把用户带到注册页面,让用户输入电子邮件地址、用户名和密码。

1.用户注册表单

注册页面使用的表单要求用户输入电子邮件地址、用户名和密码。

# app/auth/forms.py:用户注册表单

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo, ValidationError
from app.models import User


class RegisterationForm(FlaskForm):
    email=StringField('电子邮箱',validators=[
        DataRequired(),Length(1,64),Email()])
    username=StringField('用户名',validators=[
        DataRequired(), Length(1, 64),
        #^:以什么开头,$:以什么结尾。\w:代表单个字母数字或者下划线。*:代表前一个字符出现0次或者多次
        Regexp('^\w*$', message='用户名只能由字母数字或者下划线组成')])
    password = PasswordField('密码', validators=[
        DataRequired(), EqualTo('repassword', message='密码不一致')])
    repassword = PasswordField('确认密码', validators=[DataRequired()])
    submit = SubmitField('注册')

    # 两个自定义的验证函数, 以validate_ 开头且跟着字段名的方法,这个方法和常规的验证函数一起调 用。
    def validate_email(self, field):
        if User.query.filter_by(email=field.data).first():
            # 自定义的验证函数要想表示验证失败,可以抛出 ValidationError 异常,其参数就是错 误消息。
            raise ValidationError('电子邮箱已经注册.')

    def validate_username(self, field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError('用户名已经占用.')
            

2.用户注册业务逻辑

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

处理这个任务的视图函数如下所示:

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

# 2). 应用蓝图,管理路由
from flask_login import login_user, logout_user, login_required

from app import db
from app.auth import auth
from app.auth.forms import RegisterationForm, LoginForm
from flask import render_template, flash, redirect, url_for, session

from app.models import User, Role
from flask_login import current_user


@auth.route('/')
def index():
    return render_template('auth/index.html')


# 报错解决: Method Not Allowed
@auth.route('/register', methods=['GET', 'POST'])
def register():
    """
    /register:
        - GET: 获取注册页面
        - POST:获取注册页面提交的数据信息
            1). 判断是否为POST方法提交数据, 并且数据是否通过表单验证.
            2). 如果通过验证, 将表单提交的数据存储到数据库中,注册成功,跳转到登录页面.

            获取表单提交的数据, 有两种方式:
                *). form.data   {'email': 'hello@qq.com', 'username': 'hello'}
                *). form.email.data, form.username.data


    :return:
    """
    form = RegisterationForm()
    if form.validate_on_submit():
        user = User()
        print(form.username.data)
        print(form.username)
        user.username = form.username.data
        user.password = form.password.data
        user.email = form.email.data
        user.role = Role.query.filter_by(name="普通会员").first()
        db.session.add(user)
        #我们在配置中设置了自动提交数据库,这里不用进行提交数据库
        flash("用户%s注册成功" % (user.username), category='success')
        # return  redirect('/login')
        # url_for('auth.login')根据视图函数寻找对应的路由地址, /login
        return redirect(url_for('auth.login'))
    return render_template('auth/register.html', form=form)

在这里插入图片描述

3.用户注册前端渲染

注册页面使用的模板保存在 templates/auth/register.html 文件中。这个模板只需使用 Flask-Bootstrap 提供的 wtf.quick_form() 宏渲染表单即可。

{% extends 'bootstrap/base.html' %}
{% block title %}注册{% endblock %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block navbar %}
    <ul class="nav navbar-nav navbar-right">
        {% if current_user.is_authenticated %}
            <li><a href="{{ url_for('auth.logout') }}">注销</a></li>
        {% else %}
            <li><a href="{{ url_for('auth.login') }}">登录</a></li>
        {% endif %}
        <p style="color: red">{{ get_flashed_messages() }}</p>
    </ul>
{% endblock %}
{% block content %}
    <h1 style="color:green">用户注册</h1>
    {{ wtf.quick_form(form) }}
{% endblock %}

4.用户注册页面简易效果展示

因为我在app/__init__.py里卖弄设置了注册蓝图,以auth开头,所以这里的网址要以auth开头
在这里插入图片描述
注册成功跳转登陆界面:
在这里插入图片描述

将注册代码提交github

一般新打开一个终端,执行三步提交

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>git add *

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>git commit -m "add auth register"
[master 78d55f8] add auth register
 15 files changed, 125 insertions(+), 19 deletions(-)
 rewrite app/__pycache__/__init__.cpython-37.pyc (87%)
 rewrite app/__pycache__/models.cpython-37.pyc (83%)
 create mode 100644 app/auth/__pycache__/forms.cpython-37.pyc
 rewrite app/auth/__pycache__/views.cpython-37.pyc (99%)
 create mode 100644 app/templates/auth/register.html
 create mode 100644 tests/__pycache__/test_user_model.cpython-37.pyc

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>git push origin master
Enumerating objects: 49, done.
Counting objects: 100% (47/47), done.
Delta compression using up to 4 threads
Compressing objects: 100% (26/26), done.
Writing objects: 100% (28/28), 9.48 KiB | 746.00 KiB/s, done.
Total 28 (delta 10), reused 0 (delta 0)
remote: Resolving deltas: 100% (10/10), completed with 10 local objects.
To https://github.com/daliu-daliu/TodoList.git
   d42f058..78d55f8  master -> master

(base) F:\ziliao\python_kaifa\my_code\09_\TodoList>

三、用户登录

1.用户登录表单

用户的登录表单中包含一个用于输入电子邮件地址的文本字段、一个密码字段、提交按钮。

# app/auth/forms.py:用户登录表单

class LoginForm(FlaskForm):
    """用户登录表单"""
    email = StringField('电子邮箱', validators=[DataRequired(), Length(1, 64), Email()])
    password = PasswordField('密码', validators=[DataRequired()])
    submit = SubmitField('登录')

2.用户登录业务逻辑

  • 根据用户提交的 email 从数据库中加载用户, 判断用户存在?
  • 如果存在, 调用用户对象的 verify_password() 方法,其参数是表单中填写的密码。判断用户密码是否正确?
  • 如果密码正确,则调用 Flask-Login 中的 login_user() 函数实现用户登录。
    login_user() 函数的参数是要登录的用户,以及可选的“记住我”布尔值,“记住我”也在表单中填写。
  • 如果值为 False ,那么关闭浏览器后用户会话就过期了,所以下次用户访问时要重新登录。
  • 如果值为 True ,那么会在用户浏览器中写入一个长期有效的 cookie,使用这个 cookie 可以复现用户
    会话。
# app/auth/views.py 

# ...........
@auth.route('/login',methods=['GET', 'POST'])  #http://127.0.0.1:5000/auth/register
def login():
    form = LoginForm()
    if form.validate_on_submit():
        #判断用户是否存在并且密码是否正确
        user = User.query.filter_by(email=form.email.data).first()
        if user is not None and user.verify_password(form.password.data):
            # 调用 Flask-Login 中的 login_user() 函数,在用户会话中把用户标记为已登录。
            # login_user() 函数的参数是要登录的用户,以及可选的“记住我”布尔值,“记住我”也在 表单中填写。
            login_user(user)
            flash('用户%s登陆成功'%(user.username))
            #登陆成功去找auth路由的主页
            return redirect(url_for('auth.index'))
        else:
            flash('用户%s登陆失败'%(form.email.data))
            return redirect(url_for('auth.login'))
    return render_template('auth/login.html', form=form)

3.用户登录前端渲染

登录页面使用的模板保存在 templates/auth/login.html 文件中。这个模板只需使用 Flask-Bootstrap 提供的 wtf.quick_form() 宏渲染表单即可。

# templates/auth/login.html

{% extends 'bootstrap/base.html' %}
{% block title %}用户登陆{% endblock %}

{% block content %}
    <h1 >用户登陆</h1>
    {{ wtf.quick_form(form) }}

    <p style="color: red">{{ get_flashed_messages() }}</p>
{% endblock %}

4.用户登录页面简易效果展示

在这里插入图片描述

四、用户注销

为了登出用户,这个视图函数调用 Flask-Login 中的 logout_user() 函数,删除并重设用户会话。随后会显
示一个 Flash 消息,确认这次操作,再重定向到首页,这样登出就完成了。

# app/auth/views.py

@auth.route('/logout')
@login_required
def logout():
    logout_user()
    flash('用户注销成功')
    return register(url_for('auth.index'))

五、前端页面优化

下面的步骤是根据登录界面进行修改, 注册页面修改也是同样的方式。
1). 导入前端页面需要的静态资源, 如下图所示:
在这里插入图片描述
2). 资源文件夹中包含 login.html 和 register.html 两个文件, 拷贝文件到 templates/auth 目录,
如下图所示:
在这里插入图片描述
3). 查找文件中访问静态资源的 html 代码并修改静态资源:

六、Github与用户认证

命令行提交项目代码到 Github , 命令如下:

git add * 
git commit -m "基于Flask的任务清单管理系统(二): 用户认证基本功能实现" 
git push origin master

七、单元测试技术详解

1.web单元测试的分类

  • 测试对象较独立,无需依赖于cookie之类的上下文
  • 依赖于上下文
  • web前端的测试。

2.测试方式

  • 第一种类型只需要使用 unittest 的常规测试即可
  • 第二种类型,例如对于login_required类型的endpoint,可使用 app.test_client() 返回测试客户端,同时附带上合适的数据。推荐使用flask-testing插件。同时,由于这类依赖比较常见,所以推荐将其独立成类。
  • 第三种类型可使用selenium,但是编写selenium工作量比较大,且不够直观,且不够只管,建议使用其他方式或者人工测试

代码组织推荐:

  • 测试时,依赖于某些数据,除非测试数据的增删改,否则建议编写数据导入函数(后续写),可以 减少工作量

八、用户邮箱验证

  • 如何确认注册用户提供的信息是否正确?
    常用方式是验证电子邮件地址。用户注册后,程序会立即发送一封确认邮件。新账户先被标记成待
    确认状态,用户按照邮件中的说明操作后,才能证明自己可以被联系上。账户确认过程中,往往会要求
    用户点击一个包含确认令牌的特殊 URL 链接
    在这里插入图片描述

  • 确认令牌的特殊 URL 链接该如何设计?
    链 接 形 式是 http://www.example.com/auth/confirm/<id> , id 是用户的 id 。代表账户id确认成功。
    出现的问题: id可任意指定, 从而确认任意账户。
    解决方法: id 进行安全加密后得到令牌。

  • 如何生成包含用户 id 的安全令牌?
    itsdangerous 模块中的 TimedJSONWebSignatureSerializer 类生成具有过期时间的 JSON Web 签名( JSON Web Signatures,JWS )

from itsdangerous import TimedJSONWebSignatureSerializer 
# 生成具有过期时间的 `JSON Web` 签名对象, 
# 1). westos是一个密钥,在 Flask 程序中可使用 SECRET_KEY 设置。 
# 2). expires_in 参数设置令牌的过期时间,单位为秒。 
s = TimedJSONWebSignatureSerializer('westos', expires_in = 3600) 
# dumps() 方法为指定的数据生成一个加密签名,然后再对数据和签名进行序列化,生成令牌字符串。 
token = s.dumps({ 'confirm': 23 }) 
# loads() 方法会检验签名和过期时间,如果通过,返回原始数据。否则抛出异常。 
data = s.loads(token) 
# data = { 'confirm': 23 }

1.邮箱验证数据库模型

  • 模型中新加入了一个列confirmed用来保存账户的确认状态。
  • generate_confirmation_token() 方法生成一个令牌,有效期默认为一小时。
  • confirm()方法检验令牌和检查令牌中id和已登录用户id是否匹配?
    如果检验通过,则把新添加的 confirmed 属性设为 True
# app/models.py

class User(UserMixin, db.Model):
    # .................
    
    confirmed = db.Column(db.Boolean, default=False)

    def generate_confirmation_token(self, expiration=3600):
        """生成一个令牌,有效期默认为一小时。"""
        s = TimedJSONWebSignatureSerializer('westos', expiration)
        return s.dumps({'confirm': self.id})

    def confirm(self, token):
        """ 检验令牌和检查令牌中id和已登录用户id是否匹配?如果检验通过,则把新添加的 confirmed 属 性设为 True"""
        s = TimedJSONWebSignatureSerializer('westos')
        try:
            data = s.loads(token)
        except:
            return False
        if data.get('confirm') != self.id:
            return False
        self.confirmed = True
        db.session.add(self)
        db.session.commit()
        return True

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

# 对数据库(db)进行迁移(migrate)。-m选项用来添加迁移备注信息。 
python3 manage.py db migrate -m "添加账户的确认状态" 
# 生成了迁移脚本后,使用upgrade子命令即可更新数据库. 
python3 manage.py db upgrade

在这里插入图片描述

2.注册与确认验证的业务逻辑

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

(1)注册与发送验证邮件的视图函数

当前的 /register 路由把新用户添加到数据库中后,会重定向到 /index。在重定向之前,这个路由需要发送
确认邮件, 确认邮件信息参考验证邮件模板。
此处如果注册成功且验证通过, 应该跳转到网站的首页。

# app/auth/views.py 

@auth.route('/register', methods=['GET', 'POST'])
def register():
        # ....前面吧新用户添加到数据库中
        
        token = user.generate_confirmation_token()  #生成令牌
        # 发送邮件的函数Flask-Mail封装过, 可参考之前讲的内容;
        # user=user, token=token是两个要传给前台confirm.html的值
        send_mail(to=[user.email,], subject='请激活你的任务管理平台帐号', filename='auth/confirm', user=user, token=token)
        flash('平台验证消息已经发送到你的邮箱, 请确认后登录.', category='success')

        # return  redirect('/login')
        # url_for('auth.login')根据视图函数寻找对应的路由地址, /login
        return redirect(url_for('auth.login'))
    return render_template('auth/register.html', form=form)

上述代码中send_mail() 发送的电子邮件文件模板filename=‘auth/confirm’ 如下:

电子邮件模板准备

认证蓝本使用的电子邮件模板保存在 templates/auth/confirm.html 文件中, 参考微信的模板。可在
资料包中下载 email.html 文件。 修改如下: url_for() 函数中的 _external=True 参数要求程序生成完整的 URL

#app/templates/auth/confirm.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<p>您好!</p>
<p>
    感谢您注册任务清单管理平台:<br/>
    你的邮箱注册地址为:{{user.email}}<br/>
    你的注册用户名为:{{user.username}}<br/>
    <!--    当点击激活链接token时,url_for自动寻找蓝图auth下的conform路由-->
    激活链接:<a href="{{url_for('auth.confirm',token=token,_external=True,)}}">{{token}}</a>
</p>

</body>
</html>

上述代码中send_mail() :发送电子邮件的业务逻辑 我们还没有写,如下:

发送电子邮件的业务逻辑

如果发送了多封测试邮件,页面停滞了几秒钟,在这个过程中浏览器就像无响应一样。为了避免处理请求
过程中不必要的延迟,我们可以把发送电子邮件的函数移到后台线程中, 实现多线程发送用户验证邮

#app/auth/send_mail.py

from threading import Thread

from flask import render_template, current_app
from flask_mail import Mail, Message

def thread_task(app, mail, msg):
    with app.app_context():
        mail.send(msg)


def send_mail(to, subject, filename, **kwargs):
    """ 发送邮件的封装
    :param to: 收件人
    :param subject: 邮件主题
    :param filename: 邮件正文对应的html名称
    :param kwargs: 关键字参数, 模版中需要的变量名
    :return:
    """
    #获取app现在的状态
    app = current_app._get_current_object()
    # 初始化mail对象, 一定要先配置邮件信息;
    mail = Mail(app)
    msg = Message(subject=subject,
                  sender=app.config['MAIL_USERNAME'],
                  recipients=to, )
    # msg.body = info
    msg.html = render_template(filename + '.html', **kwargs)
    # with app.app_context(): # mail.send(msg)
    thread = Thread(target=thread_task, args=(app, mail, msg))
    thread.start()
    return thread

在上述代码中,初始化邮件对象前一定要配置邮件相关参数,如下:

电子邮件配置信息准备

我们实在开发环境中配置的信息,如果希望在测试和其他环境中都希望用到这个配置信息的话,可以在Config类中配置。

# config.py

class DevelopmentConfig(Config):
    """
   开发环境的配置信息
   """
    # 启用了调试支持,服务器会在代码修改后自动重新载入,并在发生错误时提供一个相当有用的调试器。
    DEBUG = True
    # MAIL_SERVER = 'smtp.qq.com'
    MAIL_SERVER = 'smtp.163.com'
    # MAIL_PORT = 587/465
    MAIL_PORT = 25
    # MAIL_USE_SSL = True   # 163邮箱不能打开这个参数
    MAIL_USERNAME =  '13679127704@163.com'
    MAIL_PASSWORD =  '13279443585lh'  #此密码为打开POP服务的授权码,如果忘记了可以重新开启获取。
  

我是用13679127704@163.com发送给1104213995@qq.com

运行程序
python manage.py runsrever
打开http://127.0.0.1:5000/register
注册我的真实的qq邮箱来测试

在这里插入图片描述
注册成功之后,打开邮箱收到一封来自13679127704@163.com的邮件
在这里插入图片描述

此时点击确认链接没有效果,下面是确认账户的视图函数编写。

(2)确认账户的视图函数如下面代码所示。

Flask-Login 提供的 login_required 修饰器会保护这个路由,因此,用户点击确认邮件中的链接后,要先登录,然后才能执行这个视图函数。这个函数先检查已登录的用户是否已经确认过,如果确认过,则重定向到首页,因为很显然此时不用做什么操作。这样处理可以避免用户不小心多次点击确认令牌带来的额外工作。

# app/auth/views.py

# http://127.0.0.1:5000/confirm/chdchjefjhreufhrufrfhyre
# /confirm/chdchjefjhreufhrufrfhyry
# /confirm/chdchjefjhreufhrufrfhyrz
@auth.route('/confirm/<token>')
@login_required
def confirm(token):
    """
    1. 判断账户是否验证, 如果已经验证, 跳转到主页
    2. 如果没有验证, 执行验证函数,更新账户的confirmed值。
    """
    if current_user.confirmed:
        return redirect(url_for('todo.index'))
    if current_user.confirm(token):
        flash('验证邮箱通过', category='success')
    else:
        flash('验证连接失效', category='error')
    return redirect(url_for('todo.index'))

这里编写了一个非常简单的todo.route(’/’)路由:

#app/todo/views.py

@todo.route('/')
def index():
    return 'todo index'

(3)过滤未确认的账户

每个程序都可以决定用户确认账户之前可以做哪些操作。比如,允许未确认的用户登录,但只显示一个未确认页面 unconfirmed.html ,这个页面要求用户在获取权限之前先确认账户。这一步可使用 Flask 提供的 before_request 钩子完成。

对蓝图来说, before_request 钩子只能应用到属于蓝图的请求上。若想在蓝图中使用针对程序全局请求的钩子,必须使用 before_app_request 修饰器。

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

  • 用户已登录( current_user.is_authenticated 必须返回 True )。
  • 用户的账户还未确认。
  • 请求的端点(使用 request.endpoint 获取)不在 auth 蓝图中。访问 auth 路由要获取权限,因为这些路由的作用是让用户确认账户或执行其他账户管理操作。
    如果请求满足以上 3 个条件,则会被重定向到 /auth/unconfirmed 路由,显示一个确认账户相关信息的页面。
before_app_request钩子函数处理程序

也就是测试中当我们登陆了但没验证会跳转进入unconfirmed的页面:

# app/auth/views.py

@auth.before_app_request
def before_request():
    """
    钩子函数, 当用户登录且未邮箱确认账户那么进入unconfirmed的页面。
    request.endpoint: /login ==='auth.login'
    auth中的login,register,logout.....或者static的静态文件, 都不会拦截
    :return:
    """
    print('request.endpoint',request.endpoint)  #todo.index
    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('todo.index'))
    token = current_user.generate_confirmation_token()
    return render_template('auth/unconfirmed.html')
未确认页面的 html
# templates/auth/unconfirmed.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<p>尊敬的用户:</p>
你好, 你还没有确认邮箱账户, 请点击下面连接进行确认.
<a href="{{ url_for('auth.resend_confirmation', _external=True ) }}">再次发送确认
邮件</a>

</body>
</html>
重新发送邮件进行确认

显示给未确认用户的页面提供了一个链接,用于请求发送新的确认邮件,以防之前的邮件丢失。重新发送
确认邮件的视图函数代码如下:

# app/auth/views.py

@auth.route('/reconfirm')
@login_required
def resend_confirmation():
    token = current_user.generate_confirmation_token()
    try:
        send_mail([current_user.email], '请激活你的任务管理平台帐号',
                  'auth/confirm', user=current_user, token=token)
    except Exception as e:
        print(e)
        flash(str(e), category='error')
        return redirect(url_for('auth.register'))
    else:
        flash('新的平台验证消息已经发送到你的邮箱, 请确认后登录.', category='success')
        return redirect(url_for('todo.index'))

如果要使用同一个邮箱测试,在测试之前,移除数据库中我们已经注册过的邮箱

运行程序
python manage.py runsrever
打开http://127.0.0.1:5000/register
注册我的真实的qq邮箱来测试
在这里插入图片描述
注册成功之后,打开邮箱收到一封来自13679127704@163.com的邮件
在这里插入图片描述
点击链接,跳到登陆界面
就是我们呢在代码里写的,使用钩子函数拦截请求时要满足的要求:用户登录且未邮箱确认账户那么进入unconfirmed的页面。
在这里插入图片描述
用户登录且未邮箱确认账户将进入unconfirmed的页面。点击
在这里插入图片描述
此时打开邮箱,再次收到一封邮件,点击链接进行激活
在这里插入图片描述
激活之后,跳转todo的首页
在这里插入图片描述

九、提交github

如果本次代码和之前提交github的代码不是同一个目录,
需要在文件夹中删除.git文件,然后重新
git init #生成 .git文件
在这里插入图片描述

本文的完整代码在https://github.com/daliu-daliu/TodoList

Web开发 ------ 基于Flask的 任务清单管理系统(三): 用户资料 显示与编辑 查看ppt和项目github代码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值