Python Web 开发进阶实战:Flask-Login 用户认证与权限管理 —— 构建多用户待办事项系统

第一章:为什么需要用户系统?

在真实场景中,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 猜测攻击

第七章:会话安全深度加固

config.pyConfig 类中添加:

复制代码
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 应用

相关推荐
木土雨成小小测试员9 小时前
Python测试开发之前端二
javascript·python·jquery
两万五千个小时9 小时前
Claude Code 中的子 Agent 派生实现:Task Tool 完全指南
人工智能·python
浩瀚之水_csdn9 小时前
python字符串解析
前端·数据库·python
liu****9 小时前
机器学习-特征降维
人工智能·python·机器学习·python基础·特征降维
全栈小59 小时前
【前端】在JavaScript中,=、==和===是三种不同的操作符,用途和含义完全不同,一起瞧瞧
开发语言·前端·javascript
程序猿阿伟10 小时前
《Python生态事件溯源与CQRS轻量化落地指南》
大数据·python·微服务
如果你好10 小时前
Vue createRenderer 自定义渲染器从入门到实战
前端·javascript·vue.js
王夏奇10 小时前
python在汽车电子行业中应用2—具体包的介绍和使用
网络·python·汽车
Dxy123931021610 小时前
Python的zip用法详解
开发语言·python