第一章:为什么需要用户系统?
在真实场景中,Web 应用几乎都离不开用户身份识别:
- 数据隔离:张三的任务不能被李四看到
- 个性化体验:记住用户偏好、历史记录
- 操作审计:谁在何时做了什么
- 商业闭环:用户是产品运营的基础单元
然而,自行实现用户系统极易出错:
- 明文存储密码 → 数据库泄露即全盘沦陷
- 会话劫持(Session Hijacking)
- 跨站请求伪造(CSRF)
- 暴力破解登录
解决方案 :使用成熟库 Flask-Login + 安全最佳实践。
第二章:设计用户模型(User Model)
2.1 用户字段规划
一个基础但安全的用户模型应包含:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
Integer | 主键 |
username |
String(50) | 用户名(唯一) |
email |
String(120) | 邮箱(唯一,用于找回密码) |
password_hash |
String(255) | 密码哈希值(绝不存明文!) |
created_at |
DateTime | 注册时间 |
为什么不存明文密码?
即使数据库被拖库,攻击者也无法直接获取用户密码(需暴力破解哈希)。
2.2 实现 User 模型
更新 models.py:
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
db = SQLAlchemy()
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 关联任务(一对多)
todos = db.relationship('Todo', backref='author', lazy='dynamic', cascade='all, delete-orphan')
def set_password(self, password):
"""设置密码(自动哈希)"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
关键点解析:
generate_password_hash():使用 PBKDF2 算法(默认)生成强哈希check_password_hash():安全比对哈希值cascade='all, delete-orphan':当用户删除时,自动清理其所有任务backref='author':在Todo对象中可通过todo.author访问用户
安全提示 :Werkzeug 默认使用
pbkdf2:sha256,迭代次数 150,000+,足够抵御彩虹表攻击。
2.3 更新 Todo 模型以关联用户
修改 Todo 类,添加外键:
class Todo(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
done = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# === 新增:用户外键 ===
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
__table_args__ = (db.Index('idx_title', 'title'),)
注意 :
nullable=False确保每条任务必须属于某个用户。
第三章:集成 Flask-Login
3.1 安装与初始化
pip install Flask-Login
更新 requirements.txt:
Flask-Login==0.6.3
创建 extensions.py(若尚未创建):
# extensions.py
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
login_manager = LoginManager()
csrf = CSRFProtect()
在 app.py 中初始化:
# app.py
from flask import Flask
from config import config
from models import db
from extensions import login_manager, csrf # 新增导入
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
db.init_app(app)
csrf.init_opt(app)
# === 初始化 Flask-Login ===
login_manager.init_app(app)
login_manager.login_view = 'auth.login' # 未登录时重定向到此视图
login_manager.login_message = "请先登录以访问该页面"
login_manager.login_message_category = "warning"
# ... 其他初始化 ...
return app
3.2 实现用户加载回调
Flask-Login 需要知道如何从 session 中加载用户对象。
在 models.py 末尾添加:
# models.py (底部)
@login_manager.user_loader
def load_user(user_id):
"""根据用户ID加载用户对象"""
return User.query.get(int(user_id))
原理 :登录成功后,Flask-Login 将
user.id存入 session;后续请求通过此函数还原current_user。
第四章:构建认证路由(Auth Blueprint)
为保持结构清晰,我们将认证相关路由放入独立蓝图。
4.1 创建 auth 蓝图目录
flask-todo-layui/
├── routes/
│ ├── __init__.py
│ ├── main.py # 原待办事项路由
│ └── auth.py # 新增:认证路由
└── ...
4.2 设计认证表单
新建 forms.py(或扩展现有文件):
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
from models import User
class LoginForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(1, 50)])
password = PasswordField('密码', validators=[DataRequired()])
submit = SubmitField('登录')
class RegistrationForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Length(3, 50, message='用户名需3-50字符')
])
email = StringField('邮箱', validators=[
DataRequired(),
Email(message='请输入有效邮箱地址')
])
password = PasswordField('密码', validators=[
DataRequired(),
Length(6, 128, message='密码至少6位')
])
password2 = PasswordField('确认密码', validators=[
DataRequired(),
EqualTo('password', message='两次密码不一致')
])
submit = SubmitField('注册')
def validate_username(self, username):
if User.query.filter_by(username=username.data).first():
raise ValidationError('用户名已存在')
def validate_email(self, email):
if User.query.filter_by(email=email.data).first():
raise ValidationError('邮箱已被注册')
安全增强:
- 用户名/邮箱唯一性校验
- 密码二次确认
- 邮箱格式验证
4.3 实现注册与登录视图
routes/auth.py:
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user
from models import db, User
from forms import LoginForm, RegistrationForm
auth = Blueprint('auth', __name__)
@auth.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('注册成功!请登录。', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@auth.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('用户名或密码错误', 'error')
return redirect(url_for('auth.login'))
login_user(user, remember=True) # 启用"记住我"
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
return redirect(url_for('main.index'))
return render_template('auth/login.html', form=form)
@auth.route('/logout')
def logout():
logout_user()
flash('您已退出登录', 'info')
return redirect(url_for('main.index'))
关键逻辑:
current_user.is_authenticated:判断是否已登录login_user(user, remember=True):启动会话,并设置持久化 cookie(默认 365 天)next参数:登录后跳转回原请求页面(如/add需登录)
第五章:创建认证模板
5.1 基础布局继承
复用 base.html,确保风格统一。
5.2 注册页面 templates/auth/register.html
{% extends "base.html" %}
{% block title %}用户注册{% endblock %}
{% block header %}创建新账户{% endblock %}
{% block content %}
<div style="max-width: 500px; margin: 30px auto;">
<form method="POST">
{{ form.hidden_tag() }}
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
{{ form.username(class="layui-input") }}
{% if form.username.errors %}
<div class="layui-form-mid layui-text" style="color:#FF5722;">{{ form.username.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱</label>
<div class="layui-input-block">
{{ form.email(class="layui-input") }}
{% if form.email.errors %}
<div class="layui-form-mid layui-text" style="color:#FF5722;">{{ form.email.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">密码</label>
<div class="layui-input-block">
{{ form.password(class="layui-input") }}
{% if form.password.errors %}
<div class="layui-form-mid layui-text" style="color:#FF5722;">{{ form.password.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">确认密码</label>
<div class="layui-input-block">
{{ form.password2(class="layui-input") }}
{% if form.password2.errors %}
<div class="layui-form-mid layui-text" style="color:#FF5722;">{{ form.password2.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
{{ form.submit(class="layui-btn") }}
<a href="{{ url_for('auth.login') }}" class="layui-btn layui-btn-primary">已有账户?去登录</a>
</div>
</div>
</form>
</div>
{% endblock %}
5.3 登录页面 templates/auth/login.html
{% extends "base.html" %}
{% block title %}用户登录{% endblock %}
{% block header %}欢迎回来{% endblock %}
{% block content %}
<div style="max-width: 400px; margin: 50px auto;">
<form method="POST">
{{ form.hidden_tag() }}
<div class="layui-form-item">
<div class="layui-input-block">
{{ form.username(placeholder="用户名", class="layui-input") }}
{% if form.username.errors %}
<div class="layui-form-mid layui-text" style="color:#FF5722;">{{ form.username.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
{{ form.password(placeholder="密码", class="layui-input") }}
{% if form.password.errors %}
<div class="layui-form-mid layui-text" style="color:#FF5722;">{{ form.password.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
{{ form.submit(class="layui-btn", value="登录") }}
<a href="{{ url_for('auth.register') }}" class="layui-btn layui-btn-primary">没有账户?去注册</a>
</div>
</div>
</form>
</div>
{% endblock %}
第六章:改造主应用以支持多用户
6.1 在首页显示当前用户
更新 templates/base.html 的导航栏:
<!-- 在 header 区域添加 -->
<div style="float: right; margin-top: 15px; color: #666;">
{% if current_user.is_authenticated %}
欢迎, {{ current_user.username }}!
<a href="{{ url_for('auth.logout') }}" class="layui-btn layui-btn-xs layui-btn-primary">退出</a>
{% else %}
<a href="{{ url_for('auth.login') }}" class="layui-btn layui-btn-xs">登录</a>
<a href="{{ url_for('auth.register') }}" class="layui-btn layui-btn-xs layui-btn-primary">注册</a>
{% endif %}
</div>
注意 :
current_user是 Flask-Login 提供的全局代理对象,可在模板中直接使用。
6.2 限制任务操作仅限本人
修改 routes/main.py:
from flask_login import login_required, current_user # 新增导入
@main.route('/', methods=['GET', 'POST'])
@login_required # 必须登录才能访问
def index():
form = TodoForm()
if form.validate_on_submit():
title = form.title.data.strip()
# === 关键:绑定当前用户 ===
new_todo = Todo(title=title, author=current_user)
db.session.add(new_todo)
db.session.commit()
flash('任务添加成功!', 'success')
return redirect(url_for('main.index'))
# 仅查询当前用户的任务
query_str = request.args.get('q', '').strip()
todos_query = Todo.query.filter_by(author=current_user)
if query_str:
todos_query = todos_query.filter(Todo.title.contains(query_str))
todos_query = todos_query.order_by(Todo.created_at.desc())
page = request.args.get('page', 1, type=int)
pagination = todos_query.paginate(page=page, per_page=10, error_out=False)
todos = pagination.items
return render_template(
'index.html',
form=form,
todos=todos,
search_query=query_str,
pagination=pagination
)
@main.route('/delete/<int:todo_id>', methods=['POST'])
@login_required
def delete_todo(todo_id):
todo = Todo.query.get_or_404(todo_id)
# === 安全检查:只能删除自己的任务 ===
if todo.author != current_user:
flash('无权操作他人任务', 'error')
return redirect(url_for('main.index'))
db.session.delete(todo)
db.session.commit()
flash('任务已删除', 'info')
return redirect(url_for('main.index'))
@main.route('/complete_all', methods=['POST'])
@login_required
def complete_all():
# 仅标记当前用户任务为完成
Todo.query.filter_by(author=current_user).update({Todo.done: True})
db.session.commit()
flash('所有任务已标记为完成', 'success')
return redirect(url_for('main.index'))
安全加固点:
@login_required:强制登录filter_by(author=current_user):数据隔离- 删除前校验
todo.author == current_user:防止 ID 猜测攻击
第七章:会话安全深度加固
7.1 配置安全 Cookie
在 config.py 的 Config 类中添加:
class Config:
# ... 其他配置 ...
REMEMBER_COOKIE_SECURE = True # 仅 HTTPS 传输(生产环境)
REMEMBER_COOKIE_HTTPONLY = True # 禁止 JS 访问
REMEMBER_COOKIE_SAMESITE = 'Lax' # 防 CSRF
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
开发环境注意 :本地 HTTP 测试时需临时设为
False,否则"记住我"失效。
7.2 防止会话固定攻击(Session Fixation)
Flask-Login 默认在登录时 更换 session ID,已内置防护。
7.3 密码强度策略(可选)
在 RegistrationForm 中增加自定义验证:
import re
def validate_password(self, password):
if len(re.findall(r'[A-Z]', password.data)) == 0:
raise ValidationError('密码需包含至少一个大写字母')
if len(re.findall(r'\d', password.data)) == 0:
raise ValidationError('密码需包含至少一个数字')
第八章:测试用户系统
8.1 测试注册流程
tests/test_auth.py:
def test_register(client):
"""测试用户注册"""
response = client.post('/register', data={
'username': 'testuser',
'email': 'test@example.com',
'password': 'SecurePass123',
'password2': 'SecurePass123',
'submit': '注册'
}, follow_redirects=True)
assert response.status_code == 200
assert b'注册成功' in response.data
# 验证用户已存入数据库
with client.application.app_context():
user = User.query.filter_by(username='testuser').first()
assert user is not None
assert user.check_password('SecurePass123')
def test_register_duplicate_username(client):
"""测试重复用户名"""
# 先注册一次
client.post('/register', data={
'username': 'duplicate',
'email': 'dup1@example.com',
'password': 'Pass123',
'password2': 'Pass123'
})
# 再次注册相同用户名
response = client.post('/register', data={
'username': 'duplicate',
'email': 'dup2@example.com',
'password': 'Pass123',
'password2': 'Pass123'
})
assert b'用户名已存在' in response.data
8.2 测试登录与权限
def test_login_logout(client):
"""测试登录登出"""
# 先注册
client.post('/register', data={
'username': 'logintest',
'email': 'login@test.com',
'password': 'LoginPass123',
'password2': 'LoginPass123'
})
# 登录
response = client.post('/login', data={
'username': 'logintest',
'password': 'LoginPass123'
}, follow_redirects=True)
assert b'欢迎, logintest!' in response.data
# 登出
response = client.get('/logout', follow_redirects=True)
assert b'您已退出登录' in response.data
assert b'登录' in response.data
def test_protected_route_requires_login(client):
"""测试未登录访问首页被重定向"""
response = client.get('/')
assert response.status_code == 302
assert '/login' in response.location
8.3 测试数据隔离
def test_todo_isolation(client):
"""测试任务数据隔离"""
# 创建两个用户
client.post('/register', data={'username':'user1', 'email':'u1@test.com', 'password':'Pass123', 'password2':'Pass123'})
client.post('/login', data={'username':'user1', 'password':'Pass123'})
client.post('/', data={'title': 'User1 Task'})
client.get('/logout')
client.post('/register', data={'username':'user2', 'email':'u2@test.com', 'password':'Pass123', 'password2':'Pass123'})
client.post('/login', data={'username':'user2', 'password':'Pass123'})
client.post('/', data={'title': 'User2 Task'})
# user2 的首页不应看到 user1 的任务
response = client.get('/')
assert b'User2 Task' in response.data
assert b'User1 Task' not in response.data
第九章:部署前的最终检查清单
| 项目 | 状态 | 说明 |
|---|---|---|
| ✅ 密码哈希存储 | ✔ | 使用 generate_password_hash |
| ✅ 会话 Cookie 安全 | ✔ | HttpOnly + Secure + SameSite |
| ✅ 数据隔离 | ✔ | 所有查询过滤 author=current_user |
| ✅ 权限校验 | ✔ | 删除前验证任务归属 |
| ✅ CSRF 防护 | ✔ | Flask-WTF 自动启用 |
| ✅ 错误页面不泄露信息 | ✔ | 自定义 404/500 |
| ✅ 自动化测试覆盖 | ✔ | 注册/登录/权限均有测试 |
总结:从单机到多用户的质变
通过本篇,你的待办事项系统完成了关键跃迁:
- 身份认证:安全注册/登录,密码强哈希
- 数据隔离:每个用户拥有独立任务空间
- 权限控制:操作前校验所有权
- 会话安全:防御常见 Web 攻击
- 测试保障:核心流程 100% 覆盖
现在,它已是一个具备生产级安全性的多用户 Web 应用!