用户与权限管理(4):第三方身份、源码与校验
用户与权限管理(4):第三方身份、源码与校验
教程版本:v2.0.0;定稿日期:2026-10-08。
本篇讲解第三方身份、一次性事务、Provider、state、PKCE、nonce、重放保护、绑定/解绑与审计,并交付源码、迁移、测试和确定性 ZIP。Fake 只验证本地编排,不代表三个真实平台已经联调。
| 篇次 | 固定地址 | 内容 |
|---|---|---|
| 第 1 篇 | https://www.cnblogs.com/lizexiong/p/22694083 | 本地登录、自研能力、角色、用户角色与第一次授权 |
| 第 2 篇 | https://www.cnblogs.com/lizexiong/p/22712552 | Policy API、管理后端、并发复检与 CMDB 全局能力 |
| 第 3 篇 | https://www.cnblogs.com/lizexiong/p/22712579 | 完整管理模板与 Automation 对象授权 |
| 第 4 篇 | https://www.cnblogs.com/lizexiong/p/22716152 | 本文:第三方身份、迁移、完整源码与校验 |
1. 第三方身份的威胁模型与状态流
1.1 稳定键只能是 provider + opaque subject
第三方平台返回的 subject 是不透明标识。系统不知道其中字符的业务含义,因此不能转小写、不能做 Unicode 归一化、不能把邮箱当 subject,也不能按邮箱自动合并本地账号。AbC 与 abc 必须被视为两个不同值。MySQL 常见不区分大小写排序规则会让普通字符串唯一约束破坏这一语义,所以最终模型用精确 UTF-8 字节的 SHA-256 摘要建立唯一索引,命中后再用 hmac.compare_digest() 比较原始字节。
摘要不是加密,也不把 subject 变成秘密;它解决的是数据库排序规则与不透明、区分大小写标识之间的冲突。页面和审计仍应限制 subject 的暴露范围。
1.2 外部登录事务的状态机
pending -> consumed -> succeeded
\-> failed
pending -> failed (过期、Provider 启动失败或明确拒绝)
pending 表示等待一次回调;consumed 表示事务已经在数据库锁内被一次性消费,但 Provider 回调校验和本地身份解析尚未完成;最终进入 succeeded 或 failed。先消费再调用 Provider,意味着网络异常也不能重放同一事务。用户只能重新开始流程。
1.3 一次回调必须同时绑定哪些值
- 浏览器 Session:数据库保存 Session key 的 HMAC 摘要;
- 不可预测
state:原值只在 Session,数据库保存 HMAC 摘要; - 事务 UUID 与 Provider 键;
- 服务端生成的固定回调 URI;
- PKCE S256:Session 保存 verifier,数据库保存 challenge;
- OIDC 风格 nonce:Session 保存原值,数据库保存 HMAC 摘要;
- 事务过期时间、pending 状态和一次性消费时间;
- 绑定流程的发起用户。
只验证 state 不够:被复制到另一个 Session 的 state、错误 Provider 的回调、修改过的 redirect URI、旧 PKCE verifier、错误 nonce、已过期或已消费事务都必须拒绝。只验证浏览器参数也不够,所有可信期望值都要来自服务端 Session 或数据库事务记录。
1.4 数据库与审计明确不保存什么
数据库不保存 authorization code、access token、refresh token、原始 state、原始 nonce、PKCE verifier、Session key、Provider 响应正文或任意异常文本。AuthorizationAuditEvent.detail 只接受服务层筛选后的非秘密结构,例如 intent;Provider 错误只能落白名单 reason code。真实 Provider 将来需要 token 换取用户信息时,也应在请求内存中使用后立即丢弃,除非另行设计经过威胁建模的加密 token vault。
2. 最终身份模型、表单与只读 Admin
2.1 accounts/models.py 的身份导入、枚举与最终尾部
保留第 2 篇的 Department、User、Capability、Role、RoleCapability、UserRole。文件顶部导入补充 hashlib、uuid 和 timezone,然后加入下面的枚举;在 UserRole 之后追加完整身份尾部。
# accounts/models.py:顶部最终身份相关导入
# 从标准库导入 hashlib 计算第三方 subject 摘要;导入 uuid 使用 uuid4() 生成不可预测事务编号。
import hashlib
import uuid
# 从 Django 导入 AbstractUser,它提供密码哈希、登录状态等成熟认证字段;项目只在其上增加业务资料与自有角色关系。
from django.contrib.auth.models import AbstractUser
# 导入 ValidationError,把模型级业务错误映射回 ModelForm 字段。
from django.core.exceptions import ValidationError
# 导入 models 使用 ORM 字段/约束/索引基类;导入 timezone 获取时区感知的当前时间。
from django.db import models
from django.utils import timezone
# 导入 ROLE_KEYS 和 ROLE_NAMES,模型保存前据此保护代码目录预留的角色编码与名称。
from .capabilities import ROLE_KEYS, ROLE_NAMES
# IdentityProvider 继承 models.TextChoices:每个成员同时包含数据库稳定值和中文标签,.choices 可交给字段,.values 可做白名单判断。
class IdentityProvider(models.TextChoices):
# fake 只用于本地学习和自动化测试;其余三项是后续真实平台适配器的稳定键。
FAKE = "fake", "本地 Fake Provider"
ENTERPRISE_WECHAT = "enterprise_wechat", "企业微信"
FEISHU = "feishu", "飞书"
WECHAT_OPEN_PLATFORM = "wechat_open_platform", "微信开放平台"
# LoginIntent 继承 models.TextChoices,用稳定值区分一次外部流程是登录还是绑定。
class LoginIntent(models.TextChoices):
LOGIN = "login", "登录"
BIND = "bind", "绑定"
# LoginTransactionStatus 继承 models.TextChoices,限定事务只能处于等待、已消费、成功或失败四种状态。
class LoginTransactionStatus(models.TextChoices):
PENDING = "pending", "等待回调"
CONSUMED = "consumed", "已消费"
SUCCEEDED = "succeeded", "成功"
FAILED = "failed", "失败"
# AuditEventType 继承 models.TextChoices,限定审计 event_type 的数据库值和中文标签。
class AuditEventType(models.TextChoices):
LOGIN_STARTED = "external_login.started", "外部登录开始"
LOGIN_SUCCEEDED = "external_login.succeeded", "外部登录成功"
LOGIN_FAILED = "external_login.failed", "外部登录失败"
IDENTITY_BOUND = "external_identity.bound", "第三方身份绑定"
IDENTITY_UNBOUND = "external_identity.unbound", "第三方身份解绑"
# AuditOutcome 继承 models.TextChoices,限定审计结果为成功、拒绝或错误,避免任意文本污染统计。
class AuditOutcome(models.TextChoices):
SUCCESS = "success", "成功"
DENIED = "denied", "拒绝"
ERROR = "error", "错误"
def external_subject_digest(subject):
# 输入:第三方原始 subject 字符串;输出:64 位 SHA-256 十六进制摘要;调用者:身份保存和查找流程;失败:非字符串输入会抛出编码异常。
return hashlib.sha256(subject.encode("utf-8")).hexdigest()
# ExternalIdentity 继承 models.Model 模型基类,因此字段会映射为数据库列,实例获得 ORM 查询、校验与保存能力。
class ExternalIdentity(models.Model):
# provider 的第一个位置参数是 verbose_name;max_length=50 限制数据库字符串长度;choices 只接受 IdentityProvider 的候选值。
# 它保存项目内部注册表键而非第三方显示名称,不允许 NULL 或表单空值。
provider = models.CharField(
"身份提供方",
max_length=50,
choices=IdentityProvider.choices,
)
# subject 的第一个位置参数是 verbose_name;max_length=255 限制最长字符数;未设置 blank/null,故表单和数据库都要求值。
# 它必须保存平台返回的不透明稳定编号,不能用邮箱替代,也不能擅自改变大小写。
subject = models.CharField("平台身份编号", max_length=255)
# subject_digest 的第一个位置参数是 verbose_name;max_length=64 对应 SHA-256 十六进制长度;editable=False 不让普通模型表单编辑派生值。
subject_digest = models.CharField(
"平台身份编号摘要",
max_length=64,
editable=False,
)
# user 指向本地 User;verbose_name 是页面标签;on_delete=SET_NULL 表示删除用户后保留身份历史并清空外键。
# related_name 允许 user.external_identities 反查;null=True 允许数据库 NULL,blank=True 允许表单留空,表示身份尚未绑定。
user = models.ForeignKey(
User,
verbose_name="本地用户",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="external_identities",
)
# display_name 的第一个位置参数是 verbose_name;max_length=100 限制长度;blank=True 允许平台未提供展示名时保存空字符串。
# 该字段只用于页面展示,不能参与自动合并本地账号或授权。
display_name = models.CharField("平台显示名称", max_length=100, blank=True)
# email 的第一个位置参数是 verbose_name;blank=True 允许平台未提供邮箱时保存空字符串;EmailField 仍会校验非空值格式。
email = models.EmailField("平台邮箱", blank=True)
# first_seen_at 的第一个位置参数是 verbose_name;auto_now_add=True 只在首次插入时写入当前时间。
first_seen_at = models.DateTimeField("首次发现时间", auto_now_add=True)
# last_seen_at 的第一个位置参数是 verbose_name;auto_now=True 在每次模型保存时更新当前时间。
last_seen_at = models.DateTimeField("最近使用时间", auto_now=True)
# Meta 汇总不属于单条身份数据的模型级排序、约束、索引和显示名称配置。
class Meta:
# ordering 指定未显式 order_by() 时按 provider、subject 升序返回。
ordering = ["provider", "subject"]
# constraints 收集数据库约束;这里的复合唯一约束保证同一 provider 与 subject_digest 只能绑定一行。
constraints = [
# 唯一约束是数据库最终并发边界;即使两个进程同时“先查后建”,也只能提交一行。
models.UniqueConstraint(
fields=["provider", "subject_digest"],
name="accounts_unique_identity_digest",
)
]
# indexes 收集查询索引;索引只加速查找,不代替上面的唯一约束。
indexes = [
# 复合索引匹配登录时 provider + digest 的查询;另一索引支持按用户列出某 provider 身份。
models.Index(
fields=["provider", "subject_digest"],
name="accounts_ext_identity_digest",
),
models.Index(
fields=["user", "provider"],
name="accounts_ext_identity_user",
),
]
# verbose_name 是一条记录的单数显示名称。
verbose_name = "第三方身份"
# verbose_name_plural 是多条记录的复数显示名称;中文不需要改变词形。
verbose_name_plural = "第三方身份"
def clean(self):
# 输入:待校验的第三方身份;输出:无返回值并规范化 provider;调用者:ModelForm/full_clean;失败:未知 provider 或非法 subject 时抛出 ValidationError。
super().clean()
self.provider = self.provider.strip().lower()
if self.provider not in IdentityProvider.values:
raise ValidationError({"provider": "不支持该身份提供方。"})
if not self.subject or not self.subject.strip():
raise ValidationError({"subject": "平台身份编号不能为空。"})
if self.subject != self.subject.strip():
raise ValidationError({"subject": "平台身份编号不能包含首尾空格。"})
def save(self, *args, **kwargs):
# 输入:标准 Django save 参数;输出:父类 save 的结果;调用者:身份服务和 ORM;失败:provider/subject 校验、唯一键竞争或数据库错误向上抛出。
# provider 是项目自己的注册表键,可以统一成小写。
# subject 是第三方平台的不透明值,不能改大小写,也不能擅自替换字符。
self.provider = self.provider.strip().lower()
if self.provider not in IdentityProvider.values:
raise ValidationError("不支持该身份提供方。")
if not self.subject or self.subject != self.subject.strip():
raise ValidationError("平台身份编号不能为空,也不能包含首尾空格。")
self.subject_digest = external_subject_digest(self.subject)
# update_fields 表示“只写这些列”;subject 被部分更新时必须把派生摘要一并加入,否则两列会失配。
update_fields = kwargs.get("update_fields")
if update_fields is not None and "subject" in update_fields:
kwargs["update_fields"] = set(update_fields) | {"subject_digest"}
return super().save(*args, **kwargs)
def __str__(self):
# 输入:当前第三方身份;输出:provider 与原始 subject 组合文本;调用者:后台、模板和日志;失败:字段缺失时按 Python 字符串规则显示。
return "%s:%s" % (self.provider, self.subject)
# LoginTransaction 继承 models.Model 模型基类,把一次外部授权往返所需的非秘密校验状态持久化。
class LoginTransaction(models.Model):
# transaction_id 的第一个位置参数是 verbose_name;default=uuid.uuid4 传可调用对象,为每行生成新 UUID。
# unique=True 建立数据库唯一约束;editable=False 不让普通模型表单编辑服务端生成的事务号。
transaction_id = models.UUIDField(
"事务编号",
default=uuid.uuid4,
unique=True,
editable=False,
)
# provider 的第一个位置参数是 verbose_name;max_length=50 限制长度;choices 仅允许已声明的身份提供方键。
provider = models.CharField(
"身份提供方",
max_length=50,
choices=IdentityProvider.choices,
)
# intent 的第一个位置参数是 verbose_name;max_length=20 限制长度;choices 仅允许 login 或 bind。
intent = models.CharField(
"事务目的",
max_length=20,
choices=LoginIntent.choices,
)
# 数据库只保存摘要;原始 state、verifier 和 nonce 只在当前 Session 短暂保存,access/refresh token 从不保存。
# state_digest 的第一个位置参数是 verbose_name;max_length=64 容纳 HMAC 十六进制值;unique=True 防止两个事务复用同一 state。
state_digest = models.CharField("state 摘要", max_length=64, unique=True)
# session_binding 的第一个位置参数是 verbose_name;max_length=64 容纳 Session key 的 HMAC 摘要。
session_binding = models.CharField("Session 绑定摘要", max_length=64)
# redirect_uri 的第一个位置参数是 verbose_name;max_length=500 给绝对回调 URL 留出空间,回调时必须与服务端固定值一致。
redirect_uri = models.CharField("固定回调地址", max_length=500)
# code_challenge 的第一个位置参数是 verbose_name;max_length=128 容纳 PKCE S256 challenge,不保存明文 verifier。
code_challenge = models.CharField("PKCE challenge", max_length=128)
# nonce_digest 的第一个位置参数是 verbose_name;max_length=64 容纳 HMAC 摘要;blank=True 允许非 OIDC 流程使用空字符串。
nonce_digest = models.CharField("nonce 摘要", max_length=64, blank=True)
# initiating_user 指向绑定流程的发起用户;verbose_name 是显示名;on_delete=SET_NULL 在删用户后保留事务审计。
# related_name 提供 user.initiated_login_transactions 反查;null=True 允许数据库 NULL,blank=True 允许普通登录表单留空。
initiating_user = models.ForeignKey(
User,
verbose_name="发起用户",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="initiated_login_transactions",
)
# status 的第一个位置参数是 verbose_name;max_length=20 限制长度;choices 限定状态;default=PENDING 是新事务初始值。
status = models.CharField(
"状态",
max_length=20,
choices=LoginTransactionStatus.choices,
default=LoginTransactionStatus.PENDING,
)
# failure_code 的第一个位置参数是 verbose_name;max_length=100 限制内部安全原因码;blank=True 允许成功或未结束事务为空。
failure_code = models.CharField("内部失败代码", max_length=100, blank=True)
# expires_at 的第一个位置参数是 verbose_name;未设置 auto_now,创建事务的服务必须显式传入过期时间。
expires_at = models.DateTimeField("过期时间")
# consumed_at 的第一个位置参数是 verbose_name;null=True 允许数据库 NULL,blank=True 允许表单留空,表示尚未消费。
consumed_at = models.DateTimeField("消费时间", null=True, blank=True)
# created_at 的第一个位置参数是 verbose_name;auto_now_add=True 在首次插入时自动记录创建时间。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
# Meta 汇总登录事务的默认排序、查询索引和人类可读名称,不创建实例字段。
class Meta:
# ordering 让未显式排序的查询优先返回最新创建的事务;前导减号表示降序。
ordering = ["-created_at"]
# indexes 收集登录事务常用查询索引:一组定位 provider/state,另一组筛选到期且未消费的事务。
indexes = [
models.Index(
fields=["provider", "state_digest"],
name="accounts_login_tx_lookup",
),
models.Index(
fields=["expires_at", "consumed_at"],
name="accounts_login_tx_expiry",
),
]
# verbose_name 是一条事务的单数显示名称。
verbose_name = "外部登录事务"
# verbose_name_plural 是多条事务的复数显示名称;中文保持相同。
verbose_name_plural = "外部登录事务"
def has_expired(self):
# 输入:当前登录事务;输出:当前时间是否达到过期时间的布尔值;调用者:回调消费服务;失败:时间字段非法时抛出比较异常。
return timezone.now() >= self.expires_at
def __str__(self):
# 输入:当前登录事务;输出:provider 与事务 UUID 组合文本;调用者:后台和日志;失败:字段缺失时按 Python 字符串规则显示。
return "%s:%s" % (self.provider, self.transaction_id)
# AuthorizationAuditEvent 继承 models.Model 模型基类,把身份流程的固定事件、结果和安全详情保存为可查询审计记录。
class AuthorizationAuditEvent(models.Model):
# event_type 的第一个位置参数是 verbose_name;max_length=80 限制长度;choices 只允许 AuditEventType 声明的固定事件。
event_type = models.CharField(
"事件类型",
max_length=80,
choices=AuditEventType.choices,
)
# outcome 的第一个位置参数是 verbose_name;max_length=20 限制长度;choices 只允许成功、拒绝或错误。
outcome = models.CharField(
"结果",
max_length=20,
choices=AuditOutcome.choices,
)
# actor 指向执行操作的用户;verbose_name 是显示名;on_delete=SET_NULL 使删用户后仍保留审计记录。
# related_name 提供 user.authorization_audit_events 反查;null=True 允许数据库 NULL,blank=True 允许匿名流程留空。
actor = models.ForeignKey(
User,
verbose_name="操作用户",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="authorization_audit_events",
)
# target_user 指向被操作或登录的用户;verbose_name 是显示名;on_delete=SET_NULL 保留审计并清空外键。
# related_name 提供反向 targeted_authorization_audit_events;null=True 和 blank=True 允许尚未识别目标用户。
target_user = models.ForeignKey(
User,
verbose_name="目标用户",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="targeted_authorization_audit_events",
)
# external_identity 指向事件涉及的身份;verbose_name 是显示名;on_delete=SET_NULL 在身份删除后保留审计。
# related_name 提供 identity.authorization_audit_events 反查;null=True 与 blank=True 允许没有已解析身份。
external_identity = models.ForeignKey(
ExternalIdentity,
verbose_name="第三方身份",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="authorization_audit_events",
)
# login_transaction 指向对应事务;verbose_name 是显示名;on_delete=SET_NULL 在事务清理后保留审计。
# related_name 提供 transaction.authorization_audit_events 反查;null=True 与 blank=True 允许无事务事件。
login_transaction = models.ForeignKey(
LoginTransaction,
verbose_name="登录事务",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="authorization_audit_events",
)
# provider 的第一个位置参数是 verbose_name;max_length=50 限制提供方键长度;blank=True 允许不涉及第三方的事件为空。
provider = models.CharField("身份提供方", max_length=50, blank=True)
# reason_code 的第一个位置参数是 verbose_name;max_length=100 限制安全原因码;blank=True 允许成功事件为空。
reason_code = models.CharField("原因代码", max_length=100, blank=True)
# detail 的第一个位置参数是 verbose_name;default=dict 为每行创建独立空字典;blank=True 允许表单不填安全详情。
# 这里只允许服务层写入筛选后的非秘密结构化信息,禁止 token、code、state 和原始第三方错误文本。
detail = models.JSONField("安全详情", default=dict, blank=True)
# created_at 的第一个位置参数是 verbose_name;auto_now_add=True 在首次插入时记录事件时间。
created_at = models.DateTimeField("发生时间", auto_now_add=True)
# Meta 汇总审计事件默认排序、常用查询索引和显示名称,不保存业务字段值。
class Meta:
# ordering 先按发生时间、再按主键倒序,确保同一时间戳下仍有稳定的最新优先顺序。
ordering = ["-created_at", "-pk"]
# indexes 收集审计检索索引,分别支持按事件时间和按操作人时间查询。
indexes = [
models.Index(
fields=["event_type", "created_at"],
name="accounts_audit_event_time",
),
models.Index(
fields=["actor", "created_at"],
name="accounts_audit_actor_time",
),
]
# verbose_name 是一条审计记录的单数显示名称。
verbose_name = "授权审计事件"
# verbose_name_plural 是多条审计记录的复数显示名称;中文保持相同。
verbose_name_plural = "授权审计事件"
def __str__(self):
# 输入:当前授权审计事件;输出:事件类型与结果组合文本;调用者:后台和日志;失败:字段缺失时按 Python 字符串规则显示。
return "%s:%s" % (self.event_type, self.outcome)
2.3 accounts/forms.py 身份增量完整代码
# accounts/forms.py:第三方身份表单增量
# ExternalIdentityBindForm 继承 forms.Form 表单基类,获得字段收集、绑定 POST、is_valid() 和 cleaned_data;它不绑定模型也不自动保存。
class ExternalIdentityBindForm(forms.Form):
# label 是页面显示的“Fake 测试身份”;choices=() 先建立空候选,__init__ 再按当前环境注入允许选择的身份。
identity = forms.ChoiceField(label="Fake 测试身份", choices=())
# label 是密码框的人类可读名称;strip=False 禁止删除密码首尾空白,因为空白也可能是密码的一部分。
# widget 指定 PasswordInput 遮蔽浏览器显示;attrs 把 autocomplete="current-password" 写入 HTML,提示浏览器选择当前密码。
password = forms.CharField(
label="当前密码",
strip=False,
widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}),
)
def __init__(self, *args, user, identity_choices, **kwargs):
# 输入:标准表单参数、当前用户和允许的 Fake 身份选项;输出:初始化绑定表单;调用者:绑定起点视图;失败:参数或父类初始化异常向上抛出。
self.user = user
super().__init__(*args, **kwargs)
self.fields["identity"].choices = identity_choices
def clean_password(self):
# 输入:表单中的当前密码;输出:校验通过的原密码;调用者:Django 表单校验;失败:无可用密码或密码不匹配时抛 ValidationError。
password = self.cleaned_data["password"]
# 绑定新的外部身份属于敏感操作,不能只依赖一个长期未退出的浏览器 Session。
if not self.user.has_usable_password() or not self.user.check_password(password):
raise forms.ValidationError("当前密码不正确,不能绑定第三方身份。")
return password
# FakeAuthorizeForm 继承 forms.Form 表单基类,负责转换本地 Fake 授权页字段;HiddenInput 只隐藏显示,不能提供可信性。
# 视图仍必须把这些值与 Session/数据库权威值逐项比较,回调还会验证 PKCE 和 nonce。
class FakeAuthorizeForm(forms.Form):
# widget=HiddenInput 把 identity 渲染为隐藏输入;仍须与 Session/数据库权威值核对。
identity = forms.CharField(widget=forms.HiddenInput)
# widget=HiddenInput 隐藏 redirect_uri;隐藏不等于可信,视图必须核对服务端固定回调地址。
redirect_uri = forms.CharField(max_length=500, widget=forms.HiddenInput)
# widget=HiddenInput 隐藏 state;回调仍须验证其 HMAC 摘要、Session 绑定和单次消费。
state = forms.CharField(max_length=200, widget=forms.HiddenInput)
# widget=HiddenInput 隐藏 code_challenge;服务端仍须用 verifier 重算 PKCE S256 值。
code_challenge = forms.CharField(max_length=128, widget=forms.HiddenInput)
# widget=HiddenInput 隐藏 nonce;OIDC 回调仍须与服务端事务和 Session 中的值核对。
nonce = forms.CharField(max_length=200, widget=forms.HiddenInput)
# widget=HiddenInput 隐藏 transaction_id;服务端仍须据此重查事务并验证其归属和状态。
transaction_id = forms.UUIDField(widget=forms.HiddenInput)
# choices 把提交值限制为 approve/deny 两个候选;widget=RadioSelect 把它们渲染为单选按钮,不代替后端校验。
decision = forms.ChoiceField(
choices=(("approve", "同意"), ("deny", "拒绝")),
widget=forms.RadioSelect,
)
2.4 身份模型在 Admin 中只读
三个身份模型沿用只允许激活超级用户查看的只读 Admin,禁止手工伪造或改写。
# accounts/admin.py:第三方身份只读管理增量
# ExternalIdentityAdmin 继承 ReadOnlyAssignmentAdmin,复用激活超级用户限制以及禁止新增、修改、删除的只读审计边界。
@admin.register(ExternalIdentity)
class ExternalIdentityAdmin(ReadOnlyAssignmentAdmin):
list_display = ("provider", "subject", "user", "display_name", "last_seen_at")
list_filter = ("provider",)
search_fields = ("subject", "user__username", "display_name", "email")
ordering = ("provider", "subject")
# LoginTransactionAdmin 继承 ReadOnlyAssignmentAdmin,只允许激活超级用户查看事务,不允许从后台伪造或改写流程状态。
@admin.register(LoginTransaction)
class LoginTransactionAdmin(ReadOnlyAssignmentAdmin):
list_display = (
"transaction_id",
"provider",
"intent",
"status",
"initiating_user",
"created_at",
"consumed_at",
)
list_filter = ("provider", "intent", "status")
search_fields = ("transaction_id", "failure_code", "initiating_user__username")
ordering = ("-created_at",)
# AuthorizationAuditEventAdmin 继承 ReadOnlyAssignmentAdmin,审计事件只能由受控服务写入,后台仅提供超级用户只读检索。
@admin.register(AuthorizationAuditEvent)
class AuthorizationAuditEventAdmin(ReadOnlyAssignmentAdmin):
list_display = (
"event_type",
"outcome",
"provider",
"actor",
"target_user",
"created_at",
)
list_filter = ("event_type", "outcome", "provider")
search_fields = (
"reason_code",
"actor__username",
"target_user__username",
)
ordering = ("-created_at",)
3. Provider 协议、白名单错误与四个适配器
3.1 accounts/providers/base.py 最终完整文件
# accounts/providers/base.py
# dataclass 自动生成初始化/比较等样板代码;Protocol 描述“只要方法形状匹配即可”的静态接口。
from dataclasses import dataclass
from typing import Protocol
# frozenset 是创建后不可修改的集合,集中限定允许落库的 provider 原因码,防止原始响应文本混入审计。
SAFE_PROVIDER_REASON_CODES = frozenset(
{
"provider_error",
"provider_unavailable",
"provider_callback_failed",
"provider_not_registered",
"fake_provider_disabled",
"enterprise_wechat_not_configured",
"feishu_not_configured",
"wechat_open_platform_not_configured",
"fake_code_invalid",
"fake_provider_mismatch",
"fake_redirect_mismatch",
"fake_pkce_mismatch",
"fake_nonce_mismatch",
"fake_subject_invalid",
}
)
# 继承 Exception 的自定义异常让调用者只捕获 provider 边界错误,不会把任意内部异常误当成可展示消息。
class ProviderError(Exception):
"""第三方身份提供方返回了不能继续处理的结果。"""
fallback_reason_code = "provider_error"
def __init__(self, reason_code=""):
# 输入:provider 内部原因码;输出:初始化仅携带白名单原因的异常;调用者:所有适配器;失败:未知原因降级为通用码以免泄露细节。
if reason_code not in SAFE_PROVIDER_REASON_CODES:
reason_code = self.fallback_reason_code
self.reason_code = reason_code
super().__init__("第三方身份提供方返回了不能继续处理的结果。")
# ProviderUnavailable 继承 ProviderError,复用原因码白名单与固定安全异常文本,只把默认原因改为 provider_unavailable。
class ProviderUnavailable(ProviderError):
"""提供方尚未配置,必须拒绝继续而不是退回 Fake 实现。"""
fallback_reason_code = "provider_unavailable"
# ProviderCallbackError 继承 ProviderError,继续复用安全原因码白名单,只把默认码改为 provider_callback_failed。
class ProviderCallbackError(ProviderError):
"""回调参数、一次性 code、PKCE 或 nonce 校验失败。"""
fallback_reason_code = "provider_callback_failed"
# frozen=True 使身份值对象创建后不能改字段,减少“验证后又被修改”的风险;它不是数据库模型。
@dataclass(frozen=True)
class VerifiedExternalIdentity:
# provider 和 subject 是登录查找使用的稳定键。
provider: str
subject: str
# display_name 与 email 只用于展示,不能用于自动合并本地用户。
display_name: str = ""
email: str = ""
# Protocol 不要求适配器显式继承本类;类型检查器会按 key、is_available 和两个方法的结构判断兼容性。
class ExternalIdentityProvider(Protocol):
key: str
display_name: str
def is_available(self):
# 输入:适配器实例;输出:配置可用性的布尔值;调用者:注册表和解绑保护;失败:实现应失败关闭为不可用。
"""返回当前配置是否允许这个身份源完成真实登录。"""
def begin_authorization(
self,
request,
*,
redirect_uri,
state,
code_challenge,
nonce,
transaction_id,
identity_hint,
):
# 输入:请求、固定回调、state、PKCE challenge、nonce、事务号和提示;输出:授权地址;调用者:流程启动服务;失败:不可用或参数异常时抛 ProviderError。
"""返回跳转到第三方授权页面的完整地址。"""
def complete_callback(
self,
request,
*,
code,
redirect_uri,
code_verifier,
expected_nonce,
):
# 输入:请求、一次性 code、固定回调、PKCE verifier 和预期 nonce;输出:VerifiedExternalIdentity;调用者:回调服务;失败:任一验证失败时抛 ProviderError 并拒绝身份。
"""完成 code、PKCE、OIDC nonce 校验并返回已验证身份。"""
3.2 accounts/providers/__init__.py 最终完整文件
# accounts/providers/__init__.py
# settings 是 Django 启动后唯一配置入口;注册表不会读取浏览器指定的模块路径。
from django.conf import settings
# 每个适配器都应保持无请求状态;请求相关秘密只能放在参数、Session 或数据库事务中。
from .base import ProviderUnavailable
from .enterprise_wechat import EnterpriseWechatProvider
from .fake import FakeProvider
from .feishu import FeishuProvider
from .wechat_open_platform import WechatOpenPlatformProvider
# 模块导入时创建一组共享实例,避免每次请求重复构造;共享安全的前提是实例本身不保存用户、code 或 token。
_PROVIDERS = {
FakeProvider.key: FakeProvider(),
EnterpriseWechatProvider.key: EnterpriseWechatProvider(),
FeishuProvider.key: FeishuProvider(),
WechatOpenPlatformProvider.key: WechatOpenPlatformProvider(),
}
def get_provider(provider_key):
# 输入:服务端 provider 稳定键;输出:已注册适配器实例;调用者:身份服务和授权视图;失败:未知或禁用 Fake 时抛出 ProviderUnavailable。
# 注册表只接受服务端已知键,浏览器不能通过模块名动态导入任意代码。
provider = _PROVIDERS.get(provider_key)
if provider is None:
raise ProviderUnavailable("provider_not_registered")
if provider_key == FakeProvider.key and (
not settings.ACCOUNTS_FAKE_PROVIDER_ALLOWED
or not settings.ACCOUNTS_ENABLE_FAKE_PROVIDER
):
raise ProviderUnavailable("fake_provider_disabled")
return provider
def provider_is_available(provider_key):
# 输入:provider 稳定键;输出:当前是否可完成登录的布尔值;调用者:解绑最后登录方式检查;失败:未注册、禁用或适配器意外异常时安全返回 False。
try:
provider = get_provider(provider_key)
except ProviderUnavailable:
return False
try:
# 只在调用适配器的窄边界收敛未知异常;注册表自身的程序错误仍会向上暴露,便于发现配置或代码损坏。
return bool(provider.is_available())
except Exception:
return False
3.3 accounts/providers/fake.py 最终完整文件
# accounts/providers/fake.py
# base64 生成 URL 安全的 PKCE 文本;hashlib 做 SHA-256;secrets 生成不可预测的一次性 code。
import base64
import hashlib
import secrets
# urlencode 负责查询参数转义,避免手工拼接特殊字符。
from urllib.parse import urlencode
# reverse 按路由名生成本地授权页路径,不把固定路径散落在适配器中。
from django.urls import reverse
# 导入 ProviderCallbackError 表示可预期的回调验证失败;VerifiedExternalIdentity 是验证成功后返回的不可变身份值对象。
from .base import ProviderCallbackError, VerifiedExternalIdentity
_FAKE_CODE_SESSION_KEY = "accounts_fake_provider_codes"
def clear_fake_provider_codes(request):
# 输入:当前请求;输出:无返回值并删除全部 Fake 一次性 code;调用者:外部流程统一收尾;失败:Session 后端异常向上抛出。
request.session.pop(_FAKE_CODE_SESSION_KEY, None)
request.session.modified = True
# Fake 身份是教程夹具,不是账号目录;subject 才是被验证后用于查找的稳定键,邮箱只展示。
FAKE_IDENTITIES = {
"demo-viewer": {
"subject": "fake-subject-viewer",
"display_name": "Fake 查看用户",
"email": "fake-viewer@example.invalid",
},
"demo-admin": {
"subject": "fake-subject-admin",
"display_name": "Fake 管理用户",
"email": "fake-admin@example.invalid",
},
}
def create_s256_code_challenge(code_verifier):
# 输入:ASCII PKCE verifier;输出:S256 base64url challenge;调用者:流程启动与 Fake 回调校验;失败:非 ASCII 或非字符串输入时抛出编码异常。
# OAuth 2.0 PKCE 的 S256 规则是 SHA-256 后再做无填充 base64url。
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
class FakeProvider:
key = "fake"
display_name = "本地 Fake Provider"
def is_available(self):
# 输入:Fake 适配器实例;输出:True;调用者:provider 可用性检查;失败:全局启用开关由注册表在调用前统一拒绝。
return True
def begin_authorization(
self,
request,
*,
redirect_uri,
state,
code_challenge,
nonce,
transaction_id,
identity_hint,
):
# 输入:请求及服务端生成的回调、state、PKCE challenge、nonce、事务号和 Fake 提示;输出:本地授权页 URL;调用者:流程启动服务;失败:URL 反向解析异常向上抛出。
# Fake 授权页仍通过浏览器跳转,便于真实验收完整开始和回调流程。
query = urlencode(
{
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": code_challenge,
"nonce": nonce,
"transaction_id": transaction_id,
"identity": identity_hint,
}
)
# 返回:只返回本地授权页地址;真正的一次性 code 必须等用户明确批准后才签发。
return "%s?%s" % (reverse("accounts:fake_authorize"), query)
def issue_code(
self,
request,
*,
subject,
display_name,
email,
redirect_uri,
state,
code_challenge,
nonce,
):
# 输入:当前 Session、已选身份、固定回调、state、PKCE challenge 和 nonce;输出:一次性授权 code;调用者:Fake 授权视图;失败:Session 保存异常向上抛出。
# code 使用密码学随机数;身份资料只临时放入当前 Django Session,因而授权码也绑定同一浏览器会话。
# database/cache/file 后端把值保存在服务端;signed_cookies 后端只是签名而不加密,会把这些值放在客户端,不能作同样承诺。
# 教程从不在这里保存 access token 或 refresh token。
code = secrets.token_urlsafe(32)
# request.session 取出的字典应复制后再改,避免原地变更被某些 Session 后端漏掉;重新赋值并标 modified 才明确持久化。
codes = dict(request.session.get(_FAKE_CODE_SESSION_KEY, {}))
codes[code] = {
"provider": self.key,
"subject": subject,
"display_name": display_name,
"email": email,
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": code_challenge,
"nonce": nonce,
}
request.session[_FAKE_CODE_SESSION_KEY] = codes
request.session.modified = True
# 返回:只把高熵一次性 code 交给授权页拼入回调,不返回 subject、nonce 等完整载荷。
return code
def complete_callback(
self,
request,
*,
code,
redirect_uri,
code_verifier,
expected_nonce,
):
# 输入:当前 Session、一次性 code、固定回调、PKCE verifier 和预期 nonce;输出:已验证身份;调用者:回调完成服务;失败:任一绑定不符即抛 ProviderCallbackError。
# 重放防护:先 pop 一次性 code,即使后续 redirect_uri、PKCE 或 nonce 校验失败也不能再次使用。
# request.session 取出的字典应复制后再改,避免原地变更被某些 Session 后端漏掉;重新赋值并标 modified 才明确持久化。
codes = dict(request.session.get(_FAKE_CODE_SESSION_KEY, {}))
result = codes.pop(code, None)
request.session[_FAKE_CODE_SESSION_KEY] = codes
request.session.modified = True
if result is None:
raise ProviderCallbackError("fake_code_invalid")
if result.get("provider") != self.key:
raise ProviderCallbackError("fake_provider_mismatch")
# compare_digest 以常量时间比较本流程中的秘密/绑定值,减少普通 == 的时序差异。
if not secrets.compare_digest(
result.get("redirect_uri", "").encode("utf-8"),
redirect_uri.encode("utf-8"),
):
raise ProviderCallbackError("fake_redirect_mismatch")
expected_challenge = create_s256_code_challenge(code_verifier)
if not secrets.compare_digest(
result.get("code_challenge", "").encode("ascii"),
expected_challenge.encode("ascii"),
):
raise ProviderCallbackError("fake_pkce_mismatch")
if not secrets.compare_digest(
result.get("nonce", "").encode("utf-8"),
expected_nonce.encode("utf-8"),
):
raise ProviderCallbackError("fake_nonce_mismatch")
subject = result.get("subject", "")
if not subject or subject != subject.strip() or len(subject) > 255:
raise ProviderCallbackError("fake_subject_invalid")
# 返回:所有绑定校验通过后才构造标准身份值对象;一次性 code 已在上方 pop,不能再次使用。
return VerifiedExternalIdentity(
provider=self.key,
subject=subject,
display_name=result.get("display_name", "")[:100],
email=result.get("email", "")[:254],
)
3.5 三个真实平台适配器保持 fail closed
三个真实平台适配器均失败关闭,不会降级到 Fake。
# accounts/providers/enterprise_wechat.py
# ProviderUnavailable 是“功能未配置”的安全终态,不允许调用者悄悄降级到 Fake。
from .base import ProviderUnavailable
# 适配器按 Protocol 结构实现而无需显式继承;当前桩只声明稳定键并始终失败关闭。
class EnterpriseWechatProvider:
key = "enterprise_wechat"
display_name = "企业微信"
def is_available(self):
# 输入:企业微信适配器实例;输出:False;调用者:注册表和解绑保护;失败:未配置时始终失败关闭。
return False
def begin_authorization(self, request, **kwargs):
# 输入:请求及授权参数;输出:无;调用者:流程启动服务;失败:尚未配置时明确抛 ProviderUnavailable,绝不回退 Fake。
raise ProviderUnavailable("enterprise_wechat_not_configured")
def complete_callback(self, request, **kwargs):
# 输入:请求及回调参数;输出:无;调用者:回调完成服务;失败:尚未配置时明确抛 ProviderUnavailable,绝不接受未验证身份。
raise ProviderUnavailable("enterprise_wechat_not_configured")
完整文件:accounts/providers/feishu.py
# accounts/providers/feishu.py
# ProviderUnavailable 是“功能未配置”的安全终态,不允许调用者悄悄降级到 Fake。
from .base import ProviderUnavailable
# 适配器按 Protocol 结构实现而无需显式继承;当前桩只声明稳定键并始终失败关闭。
class FeishuProvider:
key = "feishu"
display_name = "飞书"
def is_available(self):
# 输入:飞书适配器实例;输出:False;调用者:注册表和解绑保护;失败:未配置时始终失败关闭。
return False
def begin_authorization(self, request, **kwargs):
# 输入:请求及授权参数;输出:无;调用者:流程启动服务;失败:尚未配置时明确抛 ProviderUnavailable,绝不回退 Fake。
raise ProviderUnavailable("feishu_not_configured")
def complete_callback(self, request, **kwargs):
# 输入:请求及回调参数;输出:无;调用者:回调完成服务;失败:尚未配置时明确抛 ProviderUnavailable,绝不接受未验证身份。
raise ProviderUnavailable("feishu_not_configured")
完整文件:accounts/providers/wechat_open_platform.py
# accounts/providers/wechat_open_platform.py
# ProviderUnavailable 是“功能未配置”的安全终态,不允许调用者悄悄降级到 Fake。
from .base import ProviderUnavailable
# 适配器按 Protocol 结构实现而无需显式继承;当前桩只声明稳定键并始终失败关闭。
class WechatOpenPlatformProvider:
key = "wechat_open_platform"
display_name = "微信开放平台"
def is_available(self):
# 输入:微信开放平台适配器实例;输出:False;调用者:注册表和解绑保护;失败:未配置时始终失败关闭。
return False
def begin_authorization(self, request, **kwargs):
# 输入:请求及授权参数;输出:无;调用者:流程启动服务;失败:尚未配置时明确抛 ProviderUnavailable,绝不回退 Fake。
raise ProviderUnavailable("wechat_open_platform_not_configured")
def complete_callback(self, request, **kwargs):
# 输入:请求及回调参数;输出:无;调用者:回调完成服务;失败:尚未配置时明确抛 ProviderUnavailable,绝不接受未验证身份。
raise ProviderUnavailable("wechat_open_platform_not_configured")
每个 is_available() 无输入并返回假;解绑保护用它判断身份是否真能登录。两个流程方法接受协议参数但总是抛 ProviderUnavailable,不读写数据库。真实接入前必须分别实现平台文档要求,并增加真实沙箱/测试租户验收;本地 Fake 通过不能替代这些工作。
4. accounts/identity.py:事务、锁与身份解析
4.1 最终完整文件
# accounts/identity.py
# hashlib 提供 SHA-256;hmac 用带密钥摘要绑定 state/nonce/Session;secrets 生成不可预测随机值。
import hashlib
import hmac
import secrets
# timedelta 表示相对时长,用来从当前时间计算事务过期点。
from datetime import timedelta
# settings 是 Django 已加载的配置对象;login 写入认证 Session 并轮换 Session key,logout 可撤销未完成的本地登录。
from django.conf import settings
from django.contrib.auth import login, logout
# IntegrityError 表示数据库约束竞争;transaction.atomic() 提供事务和嵌套保存点。
from django.db import IntegrityError, transaction
# reverse 按路由名生成路径,timezone.now() 返回符合 USE_TZ 设置的当前时间。
from django.urls import reverse
from django.utils import timezone
# 导入身份枚举、三个持久化模型、本地 User 与 subject 摘要函数,服务层据此校验、锁定、绑定和记录审计。
from .models import (
AuditEventType,
AuditOutcome,
AuthorizationAuditEvent,
ExternalIdentity,
IdentityProvider,
LoginIntent,
LoginTransaction,
LoginTransactionStatus,
User,
external_subject_digest,
)
# 导入 provider 注册表查询函数:get_provider 返回适配器,provider_is_available 用于解绑最后登录方式前的可用性判断。
from .providers import get_provider, provider_is_available
# 导入 ProviderError 捕获适配器边界失败;VerifiedExternalIdentity 承载已经验证且不可变的身份结果。
from .providers.base import ProviderError, VerifiedExternalIdentity
# 导入 Fake 教学身份、一次性 code 清理函数和 PKCE S256 challenge 计算函数。
from .providers.fake import (
FAKE_IDENTITIES,
clear_fake_provider_codes,
create_s256_code_challenge,
)
# 这些键只保存一次外部流程的临时材料;统一常量可避免不同视图拼错 Session 键名。
SESSION_TRANSACTION_KEY = "accounts_external_transaction_id"
SESSION_STATE_KEY = "accounts_external_state"
SESSION_VERIFIER_KEY = "accounts_external_code_verifier"
SESSION_NONCE_KEY = "accounts_external_nonce"
SESSION_INTENT_KEY = "accounts_external_intent"
SESSION_IDENTITY_HINT_KEY = "accounts_external_identity_hint"
# ExternalFlowError 继承 Python 的 Exception,把安全原因码、可选身份和 HTTP 状态统一传给视图,固定异常文本避免泄密。
class ExternalFlowError(Exception):
def __init__(self, reason_code, identity=None, status_code=400):
# 输入:稳定原因码、可选身份和 HTTP 状态;输出:初始化可安全展示的流程异常;调用者:身份服务;失败:不包装底层秘密信息。
self.reason_code = reason_code
self.identity = identity
self.status_code = status_code
super().__init__("外部身份流程无法继续。")
def _digest(value, purpose):
# 输入:待保护字符串与用途域;输出:基于 SECRET_KEY 的 SHA-256 HMAC;调用者:state、nonce、Session 绑定函数;失败:配置或编码异常向上抛出。
# 用 SECRET_KEY 做 HMAC,不在数据库保存原始 state、nonce 或 Session key。
message = (purpose + ":" + value).encode("utf-8")
secret = settings.SECRET_KEY.encode("utf-8")
return hmac.new(secret, message, hashlib.sha256).hexdigest()
def session_binding(session_key):
# 输入:Django Session key;输出:不可逆 Session 绑定摘要;调用者:流程创建与回调校验;失败:Session 不存在时失败关闭并抛出 ExternalFlowError。
if not session_key:
raise ExternalFlowError("session_not_available")
return _digest(session_key, "session")
def state_digest(state):
# 输入:浏览器往返的随机 state;输出:用途隔离的 HMAC 摘要;调用者:事务创建和回调校验;失败:摘要计算异常向上抛出。
return _digest(state, "state")
def nonce_digest(nonce):
# 输入:OIDC 随机 nonce;输出:用途隔离的 HMAC 摘要;调用者:事务创建和回调校验;失败:摘要计算异常向上抛出。
return _digest(nonce, "nonce")
def new_pkce_verifier():
# 输入:无;输出:高熵 PKCE verifier;调用者:外部流程启动服务;失败:系统随机源异常向上抛出。
return secrets.token_urlsafe(48)
def new_state():
# 输入:无;输出:高熵 OAuth state;调用者:外部流程启动服务;失败:系统随机源异常向上抛出。
return secrets.token_urlsafe(32)
def new_nonce():
# 输入:无;输出:高熵 OIDC nonce;调用者:外部流程启动服务;失败:系统随机源异常向上抛出。
return secrets.token_urlsafe(32)
def expected_redirect_uri(request, provider_key):
# 输入:当前请求与已注册 provider 键;输出:服务端生成的绝对回调地址;调用者:流程开始和回调消费;失败:URL 反向解析或请求主机校验异常向上抛出。
# 回调路径只由服务端 reverse() 生成,不接收浏览器提交的 redirect_uri。
return request.build_absolute_uri(
reverse("accounts:external_callback", kwargs={"provider": provider_key})
)
def _audit(
*,
event_type,
outcome,
actor=None,
target_user=None,
external_identity=None,
login_transaction=None,
provider="",
reason_code="",
detail=None,
):
# 输入:固定事件字段及已筛选详情;输出:新建审计事件;调用者:登录、绑定、解绑服务;失败:数据库写入异常向上抛出并使所在事务回滚。
# 调用方只能传入 intent 等非秘密字段;这里永远不接收 token、code 或 state。
# 返回:数据库创建成功后返回审计事件对象,供需要关联该事件的调用者继续使用。
return AuthorizationAuditEvent.objects.create(
event_type=event_type,
outcome=outcome,
actor=actor,
target_user=target_user,
external_identity=external_identity,
login_transaction=login_transaction,
provider=provider,
reason_code=reason_code,
detail=detail or {},
)
def _clear_flow_session(request):
# 输入:当前请求;输出:无返回值并删除全部外部流程 Session 临时值;调用者:成功或失败收尾;失败:Session 后端异常向上抛出。
for key in (
SESSION_TRANSACTION_KEY,
SESSION_STATE_KEY,
SESSION_VERIFIER_KEY,
SESSION_NONCE_KEY,
SESSION_INTENT_KEY,
SESSION_IDENTITY_HINT_KEY,
):
request.session.pop(key, None)
# Fake 授权码同样是本次流程临时材料;成功、拒绝或异常收尾都删除,避免旧 code 在 Session 中滞留。
clear_fake_provider_codes(request)
request.session.modified = True
# finally 路径可能重新抛出内部异常而没有正常视图响应;显式保存让 database/cache/file 后端也真正删除旧秘密。
request.session.save()
def _clear_flow_session_if_current(request, transaction_id, state):
# 输入:请求、回调事务号和 state;输出:无返回值,仅在二者仍匹配当前 Session 时清理;调用者:回调预校验失败路径;失败:不匹配时保留其他并发流程数据。
session_transaction_id = request.session.get(SESSION_TRANSACTION_KEY, "")
session_state = request.session.get(SESSION_STATE_KEY, "")
if session_transaction_id == str(transaction_id) and session_state == state:
_clear_flow_session(request)
def start_external_flow(request, provider_key, intent, identity_hint=""):
# 输入:请求、提供方、登录/绑定意图和可选 Fake 身份提示;输出:第三方授权跳转地址;调用者:登录或绑定起点视图;失败:配置、会话、参数或提供方错误时失败关闭。
# 业务判断:只允许已注册 provider、已知意图;绑定必须属于当前激活用户,真实 provider 不接收客户端身份提示。
provider = get_provider(provider_key)
if intent not in LoginIntent.values:
raise ExternalFlowError("intent_invalid")
if intent == LoginIntent.BIND:
if not request.user.is_authenticated or not request.user.is_active:
raise ExternalFlowError("bind_login_required", status_code=403)
if provider_key == IdentityProvider.FAKE:
if identity_hint not in FAKE_IDENTITIES:
raise ExternalFlowError("fake_identity_invalid")
elif identity_hint:
raise ExternalFlowError("identity_hint_not_allowed")
# 业务准备:state 防跨站请求伪造,PKCE 把授权码绑定到 verifier,nonce 把身份响应绑定到本次请求。
state = new_state()
verifier = new_pkce_verifier()
nonce = new_nonce()
challenge = create_s256_code_challenge(verifier)
# 先保存 Session,确保后面绑定的是已经存在的 Session key。
# 默认 database/cache/file 后端把值留在服务端;signed_cookies 后端会把值签名但不加密地交给客户端,因此不适合本教程的秘密材料。
request.session.save()
current_session_key = request.session.session_key
if not current_session_key:
raise ExternalFlowError("session_not_available")
redirect_uri = expected_redirect_uri(request, provider_key)
ttl_seconds = settings.ACCOUNTS_EXTERNAL_LOGIN_TTL_SECONDS
expires_at = timezone.now() + timedelta(seconds=ttl_seconds)
# 数据库保存:原子创建事务和开始审计,只落 state/nonce/Session 摘要;任一写入失败都整体回滚。
with transaction.atomic():
login_transaction = LoginTransaction.objects.create(
provider=provider_key,
intent=intent,
state_digest=state_digest(state),
session_binding=session_binding(current_session_key),
redirect_uri=redirect_uri,
code_challenge=challenge,
nonce_digest=nonce_digest(nonce),
initiating_user=(request.user if intent == LoginIntent.BIND else None),
expires_at=expires_at,
)
_audit(
event_type=AuditEventType.LOGIN_STARTED,
outcome=AuditOutcome.SUCCESS,
actor=request.user if request.user.is_authenticated else None,
target_user=request.user if intent == LoginIntent.BIND else None,
login_transaction=login_transaction,
provider=provider_key,
detail={"intent": intent},
)
# Session 保存:原始 state、verifier、nonce 与事务号成组绑定;“仅服务端”只适用于 database/cache/file 等后端。
# 本项目绝不把 access token 或 refresh token 写进模型或 Session。
request.session[SESSION_TRANSACTION_KEY] = str(login_transaction.transaction_id)
request.session[SESSION_STATE_KEY] = state
request.session[SESSION_VERIFIER_KEY] = verifier
request.session[SESSION_NONCE_KEY] = nonce
request.session[SESSION_INTENT_KEY] = intent
request.session[SESSION_IDENTITY_HINT_KEY] = identity_hint
request.session.modified = True
# Provider 边界:已知 ProviderError 使用白名单原因码;任何意外异常都收敛为通用码,绝不把原异常文本或 token 带给页面/审计。
failure_reason = ""
authorization_url = ""
try:
authorization_url = provider.begin_authorization(
request,
redirect_uri=redirect_uri,
state=state,
code_challenge=challenge,
nonce=nonce,
transaction_id=str(login_transaction.transaction_id),
identity_hint=identity_hint,
)
if not isinstance(authorization_url, str) or not authorization_url:
failure_reason = "provider_error"
except ProviderError as error:
failure_reason = error.reason_code
except Exception:
# 只在调用外部适配器的窄边界转换未知异常;项目内部错误不会在这里被误报成 provider 失败。
failure_reason = "provider_error"
finally:
if failure_reason:
# 失败事务和审计使用固定原因码;finally 保证随后总会尝试清除本次流程的 Session 临时值。
_mark_transaction_failed_safely(
request,
login_transaction,
failure_reason,
AuditOutcome.ERROR,
)
_clear_flow_session(request)
if failure_reason:
raise ExternalFlowError("provider_unavailable") from None
# 返回:只有 provider 成功给出授权地址才保留流程 Session;数据库事务此时仍等待一次回调消费。
return authorization_url
def _load_and_consume_transaction(request, provider_key, transaction_id, state):
# 输入:当前请求、provider、事务 UUID 和回传 state;输出:已消费事务、verifier、nonce、回调地址;调用者:成功或拒绝回调;失败:任一绑定校验不符即失败关闭。
# Session 读取:原始 verifier/nonce 只能来自发起浏览器;缺少任一关键值都不尝试恢复或降级。
current_session_key = request.session.session_key
verifier = request.session.get(SESSION_VERIFIER_KEY, "")
nonce = request.session.get(SESSION_NONCE_KEY, "")
session_state = request.session.get(SESSION_STATE_KEY, "")
session_transaction_id = request.session.get(SESSION_TRANSACTION_KEY, "")
if not current_session_key or not verifier or not nonce:
raise ExternalFlowError("flow_session_missing")
if session_transaction_id != str(transaction_id) or session_state != state:
raise ExternalFlowError("state_session_mismatch")
callback_redirect_uri = expected_redirect_uri(request, provider_key)
expired = False
# 数据库读取与锁定:先锁唯一 LoginTransaction 行,再检查状态;同一事务的并发回调只能有一个继续。
with transaction.atomic():
try:
login_transaction = LoginTransaction.objects.select_for_update().get(
transaction_id=transaction_id
)
except LoginTransaction.DoesNotExist:
raise ExternalFlowError("transaction_missing")
if login_transaction.provider != provider_key:
raise ExternalFlowError("provider_mismatch")
# 重放判断:只有 PENDING 可继续;CONSUMED/SUCCEEDED/FAILED 均永久拒绝旧回调。
if login_transaction.status != LoginTransactionStatus.PENDING:
raise ExternalFlowError("transaction_replayed")
if login_transaction.has_expired():
login_transaction.status = LoginTransactionStatus.FAILED
login_transaction.failure_code = "transaction_expired"
login_transaction.consumed_at = timezone.now()
login_transaction.save(
update_fields=["status", "failure_code", "consumed_at"]
)
_audit(
event_type=AuditEventType.LOGIN_FAILED,
outcome=AuditOutcome.DENIED,
actor=request.user if request.user.is_authenticated else None,
login_transaction=login_transaction,
provider=provider_key,
reason_code="transaction_expired",
)
expired = True
else:
# 业务判断:依次核对 Session 绑定、state、固定 redirect_uri、PKCE challenge 和 nonce;任何不符都立即拒绝。
if not hmac.compare_digest(
login_transaction.session_binding,
session_binding(current_session_key),
):
raise ExternalFlowError("session_binding_mismatch")
if not hmac.compare_digest(
login_transaction.state_digest,
state_digest(state),
):
raise ExternalFlowError("state_mismatch")
if login_transaction.redirect_uri != callback_redirect_uri:
raise ExternalFlowError("redirect_uri_mismatch")
if login_transaction.code_challenge != create_s256_code_challenge(verifier):
raise ExternalFlowError("pkce_mismatch")
if not hmac.compare_digest(
login_transaction.nonce_digest,
nonce_digest(nonce),
):
raise ExternalFlowError("nonce_mismatch")
# 数据库保存:在调用第三方网络接口前先消费事务,防止两个并发回调同时成功。
login_transaction.status = LoginTransactionStatus.CONSUMED
login_transaction.consumed_at = timezone.now()
login_transaction.save(update_fields=["status", "consumed_at"])
if expired:
raise ExternalFlowError("transaction_expired")
# 返回:事务已经提交为 CONSUMED,后续提供方网络失败也不会把同一回调恢复成可重放状态。
return login_transaction, verifier, nonce, callback_redirect_uri
def _mark_transaction_failed(request, login_transaction, reason_code, outcome):
# 输入:请求、事务、稳定原因码和审计结果;输出:无返回值并尽力持久化失败;调用者:provider 或身份解析失败路径;失败:已删除事务时不再制造第二个异常。
# 数据库读取:在事务内按主键锁定最新 LoginTransaction,避免根据调用者持有的旧对象覆盖并发终态。
# 业务判断:事务不存在、已经成功或已经失败时立即返回;成功终态绝不能被迟到的异常路径改写。
# 数据库保存:仅对仍可失败的事务写 FAILED、原因码和一条固定结构审计事件,任何异常由 atomic() 回滚。
with transaction.atomic():
try:
locked = LoginTransaction.objects.select_for_update().get(
pk=login_transaction.pk
)
except LoginTransaction.DoesNotExist:
# “where possible”原则:并发清理已删除事务时已无记录可标记,调用者仍按安全错误失败关闭。
return
if locked.status in (
LoginTransactionStatus.SUCCEEDED,
LoginTransactionStatus.FAILED,
):
# 终态不可被迟到的异常路径覆盖;也避免为同一次失败重复写审计。
return
locked.status = LoginTransactionStatus.FAILED
locked.failure_code = reason_code
locked.save(update_fields=["status", "failure_code"])
_audit(
event_type=AuditEventType.LOGIN_FAILED,
outcome=outcome,
actor=request.user if request.user.is_authenticated else None,
login_transaction=locked,
provider=locked.provider,
reason_code=reason_code,
)
def _mark_transaction_failed_safely(
request,
login_transaction,
reason_code,
outcome,
):
# 输入同 _mark_transaction_failed;输出:是否成功记录;调用者:已经位于异常路径的边界;失败:吞掉二次数据库异常以保留统一安全错误。
try:
_mark_transaction_failed(
request,
login_transaction,
reason_code,
outcome,
)
except Exception:
return False
return True
def fail_current_external_flow(request, transaction_id, state, reason_code):
# 输入:当前 Session 对应的事务号/state 和固定安全原因码;输出:无返回值;调用者:授权页的 provider 失败边界;失败:找不到或不匹配时只清理当前流程,不猜测其他事务。
# 业务判断:只有 Session 事务号与 state 都等于当前请求,且数据库 HMAC 也匹配,才允许标记这条事务失败。
# 数据库读取:先按公开 transaction_id 查询权威 LoginTransaction;不存在、数据库异常或任一绑定不匹配都失败关闭。
login_transaction = None
try:
if (
request.session.get(SESSION_TRANSACTION_KEY, "")
!= str(transaction_id)
or request.session.get(SESSION_STATE_KEY, "") != state
):
return
try:
login_transaction = LoginTransaction.objects.get(
transaction_id=transaction_id
)
except LoginTransaction.DoesNotExist:
return
# compare_digest 校验数据库 HMAC,避免仅凭可改写的隐藏字段把别人的事务标为失败。
if not hmac.compare_digest(
login_transaction.state_digest,
state_digest(state),
):
return
_mark_transaction_failed(
request,
login_transaction,
reason_code,
AuditOutcome.ERROR,
)
except Exception:
# 这是失败路径中的尽力审计;数据库不可用时不能让其覆盖原本应返回的安全 provider 错误。
return
finally:
# 授权页已经无法继续时,始终尝试删除事务号、state、verifier、nonce 和身份提示。
_clear_flow_session_if_current(request, transaction_id, state)
def reject_external_callback(request, provider_key, transaction_id, state):
# 输入:第三方拒绝回调的请求、provider、事务号和 state;输出:不正常返回;调用者:回调视图;失败:消费事务、记拒绝审计、清 Session 后抛出 ExternalFlowError。
# 业务判断:拒绝回调仍必须先完整校验并消费事务;不能因没有 code 就跳过 state、Session 和重放检查。
try:
login_transaction, unused_verifier, unused_nonce, unused_redirect_uri = (
_load_and_consume_transaction(
request,
provider_key,
transaction_id,
state,
)
)
except ExternalFlowError:
_clear_flow_session_if_current(request, transaction_id, state)
raise
try:
_mark_transaction_failed_safely(
request,
login_transaction,
"provider_access_denied",
AuditOutcome.DENIED,
)
finally:
# 拒绝也是终态;无论审计写入是否成功,都不能把 verifier/nonce 留给旧回调重试。
_clear_flow_session(request)
raise ExternalFlowError("provider_access_denied")
def finish_external_callback(request, provider_key, transaction_id, state, code):
# 输入:回调请求、provider、事务号、state 和一次性 code;输出:登录或绑定结果字典;调用者:回调视图;失败:校验、换码或身份归属失败时关闭流程并清理 Session。
try:
login_transaction, verifier, nonce, redirect_uri = _load_and_consume_transaction(
request,
provider_key,
transaction_id,
state,
)
except ExternalFlowError:
_clear_flow_session_if_current(request, transaction_id, state)
raise
# 第三方回调:事务已先消费;provider 必须用 code + verifier 换取并校验身份,并核对 expected_nonce,失败不允许重试旧 code。
# 外层 finally 无条件清理 state/verifier/nonce;所有意外边界异常都转换为固定原因码,不泄露底层文本。
try:
failure_reason = ""
verified_identity = None
try:
provider = get_provider(provider_key)
except ProviderError as error:
failure_reason = error.reason_code
except Exception:
# 注册表本身的程序错误不是第三方失败:先终止已消费事务,再保留原异常供日志和调试发现。
_mark_transaction_failed_safely(
request,
login_transaction,
"provider_registry_failed",
AuditOutcome.ERROR,
)
raise
if not failure_reason:
try:
verified_identity = provider.complete_callback(
request,
code=code,
redirect_uri=redirect_uri,
code_verifier=verifier,
expected_nonce=nonce,
)
except ProviderError as error:
failure_reason = error.reason_code
except Exception:
# 仅在调用外部适配器的窄边界转换未知异常,固定为不含响应正文或 token 的安全码。
failure_reason = "provider_callback_failed"
if failure_reason:
_mark_transaction_failed_safely(
request,
login_transaction,
failure_reason,
AuditOutcome.ERROR,
)
raise ExternalFlowError("provider_callback_failed") from None
# 业务判断与保存:已验证身份只按 provider + subject 解析;访问/刷新 token 从不写入模型、审计或 Session。
try:
return_value = _resolve_identity(
request,
login_transaction,
verified_identity,
)
except ExternalFlowError as error:
# 某些业务分支已原子写成 FAILED;尚处于 CONSUMED 的分支(如摘要碰撞)在这里补记终态。
_mark_transaction_failed_safely(
request,
login_transaction,
error.reason_code,
AuditOutcome.DENIED,
)
raise
except Exception:
# 内部异常仍尽力把已消费事务标成失败,但保留原异常给日志/调试,不能伪装成 provider 返回错误。
_mark_transaction_failed_safely(
request,
login_transaction,
"identity_resolution_failed",
AuditOutcome.ERROR,
)
raise
if return_value["kind"] == "login":
# login() 可能因 Session 后端故障失败,所以数据库成功状态只能在认证 Session 真正写入之后提交。
try:
login(
request,
return_value["user"],
backend=settings.AUTHENTICATION_BACKENDS[0],
)
except Exception:
# 认证 Session 写入失败时先尽力撤销并标记事务,但保留原异常,避免把内部程序错误伪装成普通流程拒绝。
try:
logout(request)
except Exception:
pass
_mark_transaction_failed_safely(
request,
login_transaction,
"login_session_failed",
AuditOutcome.ERROR,
)
raise
try:
_complete_verified_login(
login_transaction,
return_value["user"],
return_value["identity"],
)
except Exception:
# 数据库完成失败时撤销刚写入的认证 Session、标记事务并重新抛出原异常,避免掩盖代码或数据库缺陷。
try:
logout(request)
except Exception:
pass
_mark_transaction_failed_safely(
request,
login_transaction,
"login_completion_failed",
AuditOutcome.ERROR,
)
raise
return return_value
finally:
# 登录成功时 login() 已轮换 Session key;这里只移除外部流程临时材料,不触碰认证键。
_clear_flow_session(request)
def _resolve_identity(request, login_transaction, verified_identity):
# 输入:请求、已消费事务和 provider 验证结果;输出:登录或绑定结果;调用者:回调完成服务;失败:类型/provider/意图不一致时记失败并拒绝。
# 业务判断:只接受标准值对象,且其 provider 必须与事务完全一致,避免跨 provider 身份替换。
if not isinstance(verified_identity, VerifiedExternalIdentity):
_mark_transaction_failed(
request,
login_transaction,
"provider_result_invalid",
AuditOutcome.ERROR,
)
raise ExternalFlowError("provider_callback_failed")
if verified_identity.provider != login_transaction.provider:
_mark_transaction_failed(
request,
login_transaction,
"verified_provider_mismatch",
AuditOutcome.DENIED,
)
raise ExternalFlowError("provider_callback_failed")
# 返回:只按事务中服务端保存的意图分派,浏览器不能在回调时把登录改成绑定或反向修改。
if login_transaction.intent == LoginIntent.BIND:
return _bind_verified_identity(request, login_transaction, verified_identity)
return _login_verified_identity(request, login_transaction, verified_identity)
def _find_or_create_identity(verified_identity):
# 输入:已验证第三方身份;输出:在当前外层事务内加锁的 ExternalIdentity;调用者:登录和绑定服务;失败:摘要碰撞或数据库异常时拒绝。
# 数据库读取:先按 provider + subject 摘要锁定现有行,再用常量时间比较原文以防摘要碰撞被误认。
digest = external_subject_digest(verified_identity.subject)
identity = (
ExternalIdentity.objects.select_for_update()
.filter(
provider=verified_identity.provider,
subject_digest=digest,
)
.first()
)
# 业务判断:命中摘要后必须继续常量时间比较原始 UTF-8 字节,不能把极小概率摘要碰撞当成同一身份。
if identity is not None:
if not hmac.compare_digest(
identity.subject.encode("utf-8"),
verified_identity.subject.encode("utf-8"),
):
raise ExternalFlowError(
"identity_subject_digest_collision",
status_code=409,
)
# 返回:已有身份已锁定且原始 subject 完全一致,可以安全交给登录或绑定事务。
return identity
try:
# 保存:嵌套 atomic 创建保存点;唯一键竞争失败时,外层事务仍可继续读取胜者。
with transaction.atomic():
return ExternalIdentity.objects.create(
provider=verified_identity.provider,
subject=verified_identity.subject,
display_name=verified_identity.display_name,
email=verified_identity.email,
)
except IntegrityError:
# 唯一键竞争:另一事务先创建时锁住并读取胜者;若原始 subject 不同则按摘要碰撞失败关闭。
identity = ExternalIdentity.objects.select_for_update().get(
provider=verified_identity.provider,
subject_digest=digest,
)
if not hmac.compare_digest(
identity.subject.encode("utf-8"),
verified_identity.subject.encode("utf-8"),
):
raise ExternalFlowError(
"identity_subject_digest_collision",
status_code=409,
)
# 返回:并发创建的胜者已锁定且原始 subject 一致,调用者继续使用这一行。
return identity
def _set_transaction_result(login_transaction, status, reason_code=""):
# 输入:已锁定事务、最终状态和可选原因码;输出:无返回值并保存指定字段;调用者:登录和绑定事务;失败:数据库错误使外层事务回滚。
login_transaction.status = status
login_transaction.failure_code = reason_code
login_transaction.save(update_fields=["status", "failure_code"])
def _bind_verified_identity(request, login_transaction, verified_identity):
# 输入:当前登录请求、已消费绑定事务和已验证身份;输出:绑定结果;调用者:身份解析服务;失败:Session 换人、停用或身份已占用时原子失败。
failure_code = ""
identity = None
actor = None
# 数据库读取与锁顺序:先锁登录事务,再锁发起用户,最后按 provider + subject 锁身份,所有判断与保存位于同一事务。
with transaction.atomic():
locked_transaction = LoginTransaction.objects.select_for_update().get(
pk=login_transaction.pk
)
if request.user.is_authenticated:
actor = User.objects.select_for_update().get(pk=request.user.pk)
if (
actor is None
or not actor.is_active
or locked_transaction.initiating_user_id != actor.pk
):
# 业务判断:绑定必须仍由原发起 Session 中同一激活用户完成,Session 换人时失败关闭。
failure_code = "bind_session_changed"
else:
identity = _find_or_create_identity(verified_identity)
if identity.user_id not in (None, actor.pk):
failure_code = "identity_already_bound"
# 保存:失败状态和审计、或身份归属与成功审计必须一起提交,避免出现绑定成功但事务仍未完成。
if failure_code:
_set_transaction_result(
locked_transaction,
LoginTransactionStatus.FAILED,
failure_code,
)
_audit(
event_type=AuditEventType.IDENTITY_BOUND,
outcome=AuditOutcome.DENIED,
actor=actor,
target_user=actor,
external_identity=identity,
login_transaction=locked_transaction,
provider=verified_identity.provider,
reason_code=failure_code,
)
else:
identity.user = actor
identity.display_name = verified_identity.display_name
identity.email = verified_identity.email
identity.save(
update_fields=["user", "display_name", "email", "last_seen_at"]
)
_set_transaction_result(
locked_transaction,
LoginTransactionStatus.SUCCEEDED,
)
_audit(
event_type=AuditEventType.IDENTITY_BOUND,
outcome=AuditOutcome.SUCCESS,
actor=actor,
target_user=actor,
external_identity=identity,
login_transaction=locked_transaction,
provider=verified_identity.provider,
)
if failure_code:
status_code = 409 if failure_code == "identity_already_bound" else 403
raise ExternalFlowError("identity_conflict", status_code=status_code)
# 返回:只有数据库事务完整提交后才向视图返回已绑定身份。
return {"kind": "bound", "identity": identity}
def _login_verified_identity(request, login_transaction, verified_identity):
# 输入:请求、已消费登录事务和已验证身份;输出:本地登录结果;调用者:身份解析服务;失败:解绑、归属变化、用户缺失或停用时原子拒绝。
failure_code = ""
locked_user = None
digest = external_subject_digest(verified_identity.subject)
# 数据库读取与锁顺序:先锁事务,再按“用户 -> 身份”顺序加锁;解绑使用同序,避免并发死锁。
with transaction.atomic():
locked_transaction = LoginTransaction.objects.select_for_update().get(
pk=login_transaction.pk
)
# 先无锁读取当前归属,随后统一按“用户 -> 身份”顺序加锁。
# 解绑流程也使用这个顺序,避免两个并发事务互相等待。
candidate = (
ExternalIdentity.objects.filter(
provider=verified_identity.provider,
subject_digest=digest,
)
.only("pk", "user_id")
.first()
)
candidate_user_id = candidate.user_id if candidate is not None else None
if candidate_user_id is not None:
try:
locked_user = User.objects.select_for_update().get(
pk=candidate_user_id
)
except User.DoesNotExist:
locked_user = None
identity = _find_or_create_identity(verified_identity)
# 业务判断:锁定后的归属必须与候选读取一致,并且必须指向仍存在且激活的本地用户。
if identity.user_id != candidate_user_id:
failure_code = "identity_binding_changed"
elif identity.user_id is None:
failure_code = "identity_unbound"
elif locked_user is None:
failure_code = "identity_binding_changed"
elif not locked_user.is_active:
failure_code = "identity_user_inactive"
else:
identity.user = locked_user
# 保存:身份归属必须与无锁候选读取一致;失败状态或展示资料更新和审计在同一事务提交。
if failure_code:
_set_transaction_result(
locked_transaction,
LoginTransactionStatus.FAILED,
failure_code,
)
_audit(
event_type=AuditEventType.LOGIN_FAILED,
outcome=AuditOutcome.DENIED,
target_user=locked_user,
external_identity=identity,
login_transaction=locked_transaction,
provider=verified_identity.provider,
reason_code=failure_code,
)
else:
# 此处只更新展示资料;LoginTransaction 仍保持 CONSUMED,直到上层 login() 成功写入认证 Session。
identity.display_name = verified_identity.display_name
identity.email = verified_identity.email
identity.save(update_fields=["display_name", "email", "last_seen_at"])
if failure_code:
raise ExternalFlowError(failure_code, identity=identity)
# 返回:仍被锁定并确认激活的用户;上层先调用 login(),再原子写入 SUCCEEDED 与成功审计。
return {"kind": "login", "user": locked_user, "identity": identity}
def _complete_verified_login(login_transaction, user, identity):
# 输入:已消费事务、已经写入认证 Session 的用户和身份;输出:无返回值;调用者:回调完成服务;失败:状态变化或数据库异常时拒绝宣称成功。
# 数据库读取:在原子事务内重新按主键锁定 LoginTransaction,不能信任调用者持有的旧状态。
# 业务判断:只有仍为 CONSUMED 的事务可完成;FAILED/SUCCEEDED 或其他状态都按重放拒绝。
with transaction.atomic():
locked_transaction = LoginTransaction.objects.select_for_update().get(
pk=login_transaction.pk
)
# 只有仍处于 CONSUMED 的事务能完成;任何 FAILED/SUCCEEDED 状态都不能被旧执行路径覆盖。
if locked_transaction.status != LoginTransactionStatus.CONSUMED:
raise ExternalFlowError("transaction_replayed")
_set_transaction_result(
locked_transaction,
LoginTransactionStatus.SUCCEEDED,
)
_audit(
event_type=AuditEventType.LOGIN_SUCCEEDED,
outcome=AuditOutcome.SUCCESS,
actor=user,
target_user=user,
external_identity=identity,
login_transaction=locked_transaction,
provider=locked_transaction.provider,
)
def unbind_external_identity(request, identity_id):
# 输入:当前登录请求和待解绑身份主键;输出:已解绑身份;调用者:解绑视图;失败:停用、非本人或最后登录方式时失败关闭。
# 业务判断:入口先拒绝匿名或停用账号,事务内还会在锁后再次检查。
if not request.user.is_authenticated or not request.user.is_active:
raise ExternalFlowError("account_inactive", status_code=403)
failure_code = ""
# 数据库读取与锁顺序:先锁用户,再按主键锁该用户全部身份;与登录流程的“用户 -> 身份”顺序一致。
with transaction.atomic():
user = User.objects.select_for_update().get(pk=request.user.pk)
if not user.is_active:
raise ExternalFlowError("account_inactive", status_code=403)
identities = list(
ExternalIdentity.objects.select_for_update()
.filter(user=user)
.order_by("pk")
)
identity = next((item for item in identities if item.pk == identity_id), None)
if identity is None:
raise ExternalFlowError("identity_not_owned", status_code=404)
other_usable_identity_exists = any(
item.pk != identity.pk and provider_is_available(item.provider)
for item in identities
)
# 业务判断:没有可用密码时必须至少保留另一个当前可用 provider 身份,避免把用户永久锁在账号外。
if not user.has_usable_password() and not other_usable_identity_exists:
failure_code = "last_login_method"
_audit(
event_type=AuditEventType.IDENTITY_UNBOUND,
outcome=AuditOutcome.DENIED,
actor=user,
target_user=user,
external_identity=identity,
provider=identity.provider,
reason_code=failure_code,
)
else:
# 保存:解绑只清空本地用户关系;保留 provider + subject 防止历史身份被误合并或被另一账号重新认领。
identity.user = None
identity.save(update_fields=["user", "last_seen_at"])
_audit(
event_type=AuditEventType.IDENTITY_UNBOUND,
outcome=AuditOutcome.SUCCESS,
actor=user,
target_user=user,
external_identity=identity,
provider=identity.provider,
)
if failure_code:
raise ExternalFlowError(failure_code)
# 返回:身份历史行仍存在,仅 user 外键已原子清空并写入成功审计。
return identity
锁顺序是并发合同:登录与解绑都先锁用户再锁身份。若一个流程先锁身份再等用户,而另一个先锁用户再等身份,会形成死锁。登录先做无锁候选读取只是为了知道应锁哪个用户;真正锁定身份后必须检查 identity.user_id 是否变化,变化就 fail closed,不能继续用旧候选登录。
5. 视图、路由、模板与设置
5.1 accounts/views.py 身份相关导入
# accounts/views.py
# uuid 严格解析公开事务号;urlencode 对回调查询参数做百分号转义。
import uuid
from urllib.parse import urlencode
# messages 保存一次性页面提示;login_required 在匿名请求进入视图前先重定向到登录页。
from django.contrib import messages
from django.contrib.auth.decorators import login_required
# Paginator 负责切页;PermissionDenied 交给 Django 返回 403,而不是伪装成表单错误。
from django.core.paginator import Paginator
from django.core.exceptions import PermissionDenied
# atomic/select_for_update 组合保证权限重验与写入处于同一事务;Q/Count 构造查询表达式。
from django.db import transaction
from django.db.models import Count, Q
from django.db.models.deletion import ProtectedError
# HttpResponseRedirect 返回 302;render/redirect/get_object_or_404 分别负责模板、命名路由跳转与安全 404。
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
# HTTP 方法装饰器在函数运行前返回 405,敏感写操作不会意外接受 GET。
from django.views.decorators.http import require_http_methods, require_POST
# 导入账号、部门、角色和能力管理所需的稳定 Capability 常量,视图不手写易漂移的授权字符串。
from .capabilities import (
CAPABILITY_VIEW,
DASHBOARD_VIEW,
DEPARTMENT_MANAGE,
DEPARTMENT_VIEW,
ROLE_MANAGE,
ROLE_VIEW,
USER_ASSIGN_ROLE,
USER_CHANGE,
USER_CREATE,
USER_DELETE,
USER_STATUS,
USER_VIEW,
)
# 导入 capability_required,用项目 Policy 在进入视图前检查单项全局业务能力。
from .decorators import capability_required
# 导入账号/身份管理表单及两个角色授权边界 helper;视图只编排 HTTP,表单负责输入转换和提交校验。
from .forms import (
DepartmentForm,
ExternalIdentityBindForm,
FakeAuthorizeForm,
RoleForm,
UserAccessForm,
UserCreateForm,
UserUpdateForm,
assignable_role_queryset,
role_capabilities_within_limit,
)
# 导入外部身份流程异常、临时 Session 键和开始/完成/拒绝/失败/解绑服务;敏感校验集中留在 identity 服务。
from .identity import (
ExternalFlowError,
SESSION_IDENTITY_HINT_KEY,
SESSION_NONCE_KEY,
SESSION_STATE_KEY,
SESSION_TRANSACTION_KEY,
SESSION_VERIFIER_KEY,
expected_redirect_uri,
fail_current_external_flow,
finish_external_callback,
reject_external_callback,
start_external_flow,
unbind_external_identity,
)
# 导入管理页和外部身份流程需要查询、锁定或关联的账号领域模型与枚举。
from .models import (
Capability,
Department,
ExternalIdentity,
IdentityProvider,
LoginIntent,
Role,
RoleCapability,
User,
UserRole,
)
# 导入 require_capability,在事务锁后重新检查关键能力,防止等待锁期间授权被撤销。
from .policy import require_capability
# 导入 get_provider,按稳定 provider 键取得已注册适配器而不是在视图中写平台分支。
from .providers import get_provider
# 导入 ProviderError/ProviderUnavailable,分别处理适配器安全失败和未配置的失败关闭状态。
from .providers.base import ProviderError, ProviderUnavailable
# 导入 Fake 教学身份目录和 PKCE challenge helper,只用于可确定验证的本地完整流程。
from .providers.fake import FAKE_IDENTITIES, create_s256_code_challenge
5.2 accounts/views.py 身份视图最终完整增量
# accounts/views.py:第三方身份视图最终完整增量
def _external_flow_error_response(request, error):
# 输入:当前请求和安全 ExternalFlowError;输出:统一失败响应;调用者:登录、绑定和回调视图;失败:匿名用户不回显身份,避免泄露绑定状态。
# 业务判断:仅向已登录用户显示相关身份,并只为已知 Fake 身份生成重新绑定提示。
visible_identity = error.identity if request.user.is_authenticated else None
identity_hint = ""
if visible_identity is not None and visible_identity.provider == IdentityProvider.FAKE:
for hint, data in FAKE_IDENTITIES.items():
if data["subject"] == visible_identity.subject:
identity_hint = hint
break
# 返回:无论失败原因来自哪一层,都使用同一模板和安全文案,只保留允许当前用户看到的字段。
return render(
request,
"accounts/external_identity_result.html",
{
"success": False,
"message": "外部身份流程未完成。请重新开始登录或绑定,不要重复提交旧回调。",
"identity": visible_identity,
"identity_hint": identity_hint,
"can_bind": bool(
visible_identity is not None
and visible_identity.user_id is None
and request.user.is_authenticated
and request.user.is_active
and identity_hint
),
},
status=error.status_code,
)
@require_POST
def external_start(request, provider):
# 输入:未登录用户的 POST 和 provider 路由键;输出:授权跳转或统一失败页;调用者:第三方登录起点路由;失败:已登录、未配置或流程参数错误时失败关闭。
# Session 绑定、state、PKCE 和 nonce 的生成与持久化由 start_external_flow 统一完成,视图不自行接受回调地址。
if request.user.is_authenticated:
messages.info(request, "当前账号已经登录,如需切换账号请先退出。")
return redirect("accounts:home")
try:
location = start_external_flow(
request,
provider,
LoginIntent.LOGIN,
identity_hint=request.POST.get("identity", ""),
)
except (ExternalFlowError, ProviderUnavailable) as error:
if isinstance(error, ProviderUnavailable):
error = ExternalFlowError("provider_unavailable")
return _external_flow_error_response(request, error)
return HttpResponseRedirect(location)
@login_required
def external_bind_start(request, provider):
# 输入:当前登录用户的 GET/POST 和 provider 键;输出:密码确认表单、授权跳转或失败页;调用者:第三方绑定路由;失败:停用、密码错误或 provider 不可用时拒绝。
# 业务判断:绑定在创建事务前要求重新验证当前密码;事务随后再绑定发起用户与当前 Session。
if not request.user.is_active:
raise PermissionDenied("停用账号不能管理第三方身份。")
identity_choices = tuple(
(hint, data["display_name"]) for hint, data in FAKE_IDENTITIES.items()
)
form = ExternalIdentityBindForm(
request.POST if request.method == "POST" else None,
user=request.user,
identity_choices=identity_choices,
)
if request.method == "POST":
if not form.is_valid():
return render(request, "accounts/external_identity_bind.html", {"form": form})
try:
location = start_external_flow(
request,
provider,
LoginIntent.BIND,
identity_hint=form.cleaned_data["identity"],
)
except (ExternalFlowError, ProviderUnavailable) as error:
if isinstance(error, ProviderUnavailable):
error = ExternalFlowError("provider_unavailable")
return _external_flow_error_response(request, error)
# 返回:密码确认和流程创建都成功后,浏览器才进入 provider 授权页。
return HttpResponseRedirect(location)
# 返回:GET 仅展示绑定表单,不提前创建登录事务或生成 state。
return render(request, "accounts/external_identity_bind.html", {"form": form})
@require_http_methods(["GET", "POST"])
def fake_authorize(request):
# 输入:Fake 授权页 GET/POST;输出:授权确认页或带一次性 code/拒绝错误的回调重定向;调用者:Fake provider;失败:配置、Session 或参数不匹配时拒绝。
# require_http_methods 是装饰器:不在白名单的方法由 Django 直接返回 405,函数体不会执行。
# 业务判断:Fake 也必须经过完整浏览器往返,且未启用时失败关闭,绝不作为真实 provider 的隐式降级。
try:
fake_provider = get_provider(IdentityProvider.FAKE)
except ProviderUnavailable:
return render(
request,
"accounts/external_identity_result.html",
{"success": False, "message": "Fake Provider 当前未启用。"},
status=404,
)
source = request.POST if request.method == "POST" else request.GET
initial = {
"identity": source.get("identity", ""),
"redirect_uri": source.get("redirect_uri", ""),
"state": source.get("state", ""),
"code_challenge": source.get("code_challenge", ""),
"nonce": source.get("nonce", ""),
"transaction_id": source.get("transaction_id", ""),
"decision": "approve",
}
# 隐藏字段只是把教程授权页所需值带回浏览器,不能作为权威来源;攻击者可以任意改写 POST。
# 权威检查使用当前 Session 中的事务/state/身份提示/verifier/nonce,并由回调服务继续核对数据库事务。
session_verifier = request.session.get(SESSION_VERIFIER_KEY, "")
session_nonce = request.session.get(SESSION_NONCE_KEY, "")
identity_data = FAKE_IDENTITIES.get(
request.session.get(SESSION_IDENTITY_HINT_KEY, "")
)
session_challenge = ""
if session_verifier:
try:
session_challenge = create_s256_code_challenge(session_verifier)
except (TypeError, UnicodeEncodeError):
session_challenge = ""
request_is_current = (
identity_data is not None
and initial["identity"] == request.session.get(SESSION_IDENTITY_HINT_KEY, "")
and initial["redirect_uri"]
== expected_redirect_uri(request, IdentityProvider.FAKE)
and request.session.get(SESSION_TRANSACTION_KEY)
== initial["transaction_id"]
and request.session.get(SESSION_STATE_KEY) == initial["state"]
and session_challenge == initial["code_challenge"]
and session_nonce == initial["nonce"]
)
if not request_is_current:
return render(
request,
"accounts/external_identity_result.html",
{"success": False, "message": "Fake 授权请求已经失效,请重新开始流程。"},
status=400,
)
form = FakeAuthorizeForm(request.POST if request.method == "POST" else None, initial=initial)
if request.method == "GET":
return render(
request,
"accounts/fake_authorize.html",
{"form": form, "identity": identity_data},
)
if not form.is_valid():
return render(
request,
"accounts/fake_authorize.html",
{"form": form, "identity": identity_data},
status=400,
)
values = form.cleaned_data
# clean 后再次比较全部隐藏字段;它们只有“经 Session 权威值核对后才能使用”,绝不是因为 HiddenInput 就可信。
if (
values["identity"] != initial["identity"]
or values["redirect_uri"] != initial["redirect_uri"]
or str(values["transaction_id"]) != initial["transaction_id"]
or values["state"] != initial["state"]
or values["code_challenge"] != session_challenge
or values["nonce"] != session_nonce
):
return render(
request,
"accounts/external_identity_result.html",
{"success": False, "message": "Fake 授权参数被修改,请重新开始流程。"},
status=400,
)
# 返回拒绝:仍带原 state 和事务号回服务端回调,由身份服务原子消费事务并记录拒绝,防止重放。
if values["decision"] == "deny":
callback_query = urlencode(
{
"transaction_id": str(values["transaction_id"]),
"state": values["state"],
"error": "access_denied",
}
)
return HttpResponseRedirect("%s?%s" % (values["redirect_uri"], callback_query))
# 保存:一次性 code 连同固定 redirect_uri、PKCE challenge 和 nonce 写入当前 Session;complete_callback 会先 pop 防重放。
# Provider 边界把任何意外异常转换为安全通用码;能定位当前事务时标失败,并在 helper 的 finally 中清理流程 Session。
try:
code = fake_provider.issue_code(
request,
subject=identity_data["subject"],
display_name=identity_data["display_name"],
email=identity_data["email"],
redirect_uri=values["redirect_uri"],
state=values["state"],
code_challenge=values["code_challenge"],
nonce=values["nonce"],
)
if not isinstance(code, str) or not code:
raise ProviderError("provider_error")
except ProviderError as error:
fail_current_external_flow(
request,
values["transaction_id"],
values["state"],
error.reason_code,
)
return _external_flow_error_response(
request,
ExternalFlowError("provider_unavailable"),
)
except Exception:
fail_current_external_flow(
request,
values["transaction_id"],
values["state"],
"provider_error",
)
return _external_flow_error_response(
request,
ExternalFlowError("provider_unavailable"),
)
callback_query = urlencode(
{
"transaction_id": str(values["transaction_id"]),
"state": values["state"],
"code": code,
}
)
# 返回批准:浏览器只携带一次性 code、事务号和 state;verifier/nonce 留在当前 Session。默认数据库后端在服务端保存,signed-cookie 后端则只签名、不加密地放在客户端。
return HttpResponseRedirect("%s?%s" % (values["redirect_uri"], callback_query))
@require_http_methods(["GET"])
def external_callback(request, provider):
# 输入:provider 回调 GET,含事务号、state 以及 code 或错误;输出:结果页或成功重定向;调用者:第三方授权服务器;失败:缺参、重放、绑定校验或 provider 验证失败时统一拒绝。
# 输入判断:只接受完整的服务端事务号与 state;code 和 provider_error 必须至少有一个。
transaction_id = request.GET.get("transaction_id", "")
state = request.GET.get("state", "")
code = request.GET.get("code", "")
provider_error = request.GET.get("error", "")
if not transaction_id or not state or (not code and not provider_error):
return render(
request,
"accounts/external_identity_result.html",
{"success": False, "message": "外部身份回调无效,请重新开始流程。"},
status=400,
)
try:
transaction_id = uuid.UUID(transaction_id)
except (ValueError, AttributeError):
return render(
request,
"accounts/external_identity_result.html",
{"success": False, "message": "外部身份回调无效,请重新开始流程。"},
status=400,
)
# 业务判断:有 provider_error 时走拒绝消费;否则才允许携带 code 完成身份验证,二者都不能绕过事务校验。
try:
if provider_error:
reject_external_callback(request, provider, transaction_id, state)
result = finish_external_callback(
request,
provider,
transaction_id,
state,
code,
)
except (ExternalFlowError, ProviderUnavailable) as error:
if isinstance(error, ProviderUnavailable):
error = ExternalFlowError("provider_unavailable")
return _external_flow_error_response(request, error)
# 返回:绑定保持当前登录用户并回身份列表;登录已由服务层轮换 Session key 后回首页。
if result["kind"] == "bound":
messages.success(request, "第三方身份已绑定到当前账号。")
return redirect("accounts:external_identities")
messages.success(request, "第三方身份登录成功。")
return redirect("accounts:home")
@login_required
def external_identities(request):
# 输入:当前登录请求;输出:本人第三方身份列表响应;调用者:身份列表路由;失败:停用账号 403 或数据库读取异常。
if not request.user.is_active:
raise PermissionDenied("停用账号不能管理第三方身份。")
# 数据库读取:仅按当前用户外键过滤,不允许通过请求参数查看其他用户身份。
identities = ExternalIdentity.objects.filter(user=request.user).order_by(
"provider", "subject"
)
return render(
request,
"accounts/external_identity_list.html",
{"identities": identities},
)
@login_required
@require_POST
def external_identity_unbind(request, pk):
# 输入:当前用户 POST 和身份主键;输出:身份列表重定向;调用者:解绑路由;失败:停用、非本人或最后登录方式时拒绝并显示安全消息。
# 业务判断、锁顺序和保存由 unbind_external_identity 在同一事务内完成,视图不直接修改归属。
if not request.user.is_active:
raise PermissionDenied("停用账号不能管理第三方身份。")
try:
unbind_external_identity(request, pk)
except ExternalFlowError as error:
if error.reason_code == "last_login_method":
messages.error(request, "至少保留一个可用的本地密码或第三方登录方式。")
elif error.status_code == 404:
messages.error(request, "第三方身份不存在或不属于当前账号。")
else:
messages.error(request, "第三方身份解绑失败。")
else:
messages.success(request, "第三方身份已解绑,历史身份记录仍被保留。")
return redirect("accounts:external_identities")
5.4 accounts/urls.py 路由增量完整代码
# accounts/urls.py:第三方身份路由增量
# start 只接受 POST;provider 仅作为注册表键,state、PKCE、nonce 与 Session 绑定均由服务层生成。
# <str:provider> 接受不含斜杠的一段文本;注册表随后只允许固定 provider 键,不能动态导入模块。
path(
"external/<str:provider>/start/",
views.external_start,
name="external_start",
),
path(
"external/<str:provider>/bind/",
views.external_bind_start,
name="external_bind_start",
),
# callback 只接受 GET,并通过数据库行锁先消费事务;旧回调、跨 Session 回调和校验不符均失败关闭。
path(
"external/<str:provider>/callback/",
views.external_callback,
name="external_callback",
),
# Fake 授权页仅用于本地教程;注册表和 settings 会在非 DEBUG 环境双重禁用。
path("external/fake/authorize/", views.fake_authorize, name="fake_authorize"),
# 身份列表只显示当前用户;解绑只接受 POST,并在服务层锁定用户和全部身份后保护最后登录方式。
path(
"external/identities/",
views.external_identities,
name="external_identities",
),
path(
"external/identities/<int:pk>/unbind/",
views.external_identity_unbind,
name="external_identity_unbind",
),
顺序上动态 Provider 路由不会吞掉 external/fake/authorize/,因为前两条动态路由都还有固定 start/ 或 bind/ 后缀。回调 URI 始终通过路由名反向生成。
5.5 templates/accounts/login.html 最终完整文件
{# templates/accounts/login.html #}
{# extends 让登录页继承 base.html 的整体骨架,本文件只覆盖父模板预留的 block。 #}
{% extends "base.html" %}
{# block 标记要覆盖的父模板区块;title 只改变浏览器标签标题。 #}
{% block title %}登录{% endblock %}
{% block content %}
<section class="panel narrow login-panel">
<h1>登录 devopsX</h1>
{# if 判断是否存在整张表单级错误;form.non_field_errors 会把这类错误渲染为可读 HTML。 #}
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
<form method="post">
{# csrf_token 输出与当前 Session 绑定的防伪字段,登录 POST 缺少或伪造该值会被 CSRF 中间件拒绝。 #}
{% csrf_token %}
{# for 逐项遍历表单字段;每一轮把当前 BoundField 命名为 field。 #}
{% for field in form %}
<div class="field">
{{ field.label_tag }}
{{ field }}
{{ field.errors }}
</div>
{% endfor %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button class="button" type="submit">登录</button>
</form>
<div class="top-space">
<h2>本地 Fake Provider(仅学习/测试)</h2>
<p>未绑定的第三方身份不会自动创建本地用户,也不会按邮箱合并账号。</p>
{# url 按路由名及 fake 参数反向生成地址,避免硬编码第三方登录启动路径。 #}
<form method="post" action="{% url 'accounts:external_start' 'fake' %}">
{% csrf_token %}
<input type="hidden" name="identity" value="demo-viewer">
<button class="button secondary" type="submit">使用 Fake 查看身份登录</button>
</form>
</div>
</section>
{% endblock %}
5.6 四个身份模板最终完整文件
{# templates/accounts/external_identity_bind.html #}
{% extends "base.html" %}
{% block title %}绑定第三方身份{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>绑定第三方身份</h1>
<p>绑定前需要再次输入当前密码。第三方邮箱不会自动合并到本地账号,也不会自动获得业务角色。</p>
<form method="post">
{% csrf_token %}
{% for field in form %}
<div class="field">
{{ field.label_tag }}
{{ field }}
{{ field.errors }}
</div>
{% endfor %}
<div class="form-actions">
<button class="button" type="submit">开始绑定</button>
<a class="button secondary" href="{% url 'accounts:external_identities' %}">返回身份列表</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/fake_authorize.html
{# templates/accounts/fake_authorize.html #}
{% extends "base.html" %}
{% block title %}Fake Provider 授权{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>Fake Provider 授权</h1>
{% if identity %}
<p>这是本地学习用的模拟授权页,不代表企业微信、飞书或微信开放平台已经完成真实联调。</p>
<div class="detail-grid">
<div><span>测试身份</span><strong>{{ identity.display_name }}</strong></div>
<div><span>模拟邮箱</span><strong>{{ identity.email }}</strong></div>
</div>
<form method="post" class="top-space">
{% csrf_token %}
{{ form.identity }}
{{ form.redirect_uri }}
{{ form.state }}
{{ form.code_challenge }}
{{ form.nonce }}
{{ form.transaction_id }}
{{ form.non_field_errors }}
<div class="field">
{{ form.decision.label_tag }}
{{ form.decision }}
{{ form.decision.errors }}
</div>
<button class="button" type="submit">提交授权结果</button>
</form>
{% else %}
<p>授权参数无效,请返回登录页重新开始。</p>
{{ form.errors }}
{% endif %}
</section>
{% endblock %}
完整文件:templates/accounts/external_identity_result.html
{# templates/accounts/external_identity_result.html #}
{% extends "base.html" %}
{% block title %}第三方身份结果{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>{% if success %}操作完成{% else %}外部身份流程未完成{% endif %}</h1>
<p>{{ message }}</p>
{% if identity %}
<div class="detail-grid">
<div><span>身份提供方</span><strong>{{ identity.provider }}</strong></div>
<div><span>平台身份编号</span><strong>{{ identity.subject }}</strong></div>
<div><span>平台显示名称</span><strong>{{ identity.display_name }}</strong></div>
</div>
{% endif %}
<div class="button-row top-space">
{% if can_bind %}
<a class="button" href="{% url 'accounts:external_bind_start' 'fake' %}">绑定到当前账号</a>
{% endif %}
{% if user.is_authenticated %}
<a class="button secondary" href="{% url 'accounts:external_identities' %}">身份管理</a>
<a class="button secondary" href="{% url 'accounts:home' %}">返回首页</a>
{% else %}
<a class="button secondary" href="{% url 'accounts:login' %}">返回登录</a>
{% endif %}
</div>
</section>
{% endblock %}
完整文件:templates/accounts/external_identity_list.html
{# templates/accounts/external_identity_list.html #}
{% extends "base.html" %}
{% block title %}登录方式{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>登录方式</h1>
<p>第三方身份只负责确认“你是谁”,业务角色和能力仍由项目自己的 RBAC 管理。</p>
</div>
<a class="button" href="{% url 'accounts:external_bind_start' 'fake' %}">绑定 Fake 身份</a>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>提供方</th><th>平台身份编号</th><th>显示名称</th><th>最近使用</th><th>操作</th></tr>
</thead>
<tbody>
{% for identity in identities %}
<tr>
<td>{{ identity.get_provider_display }}</td>
<td><code>{{ identity.subject }}</code></td>
{# default 过滤器在 display_name 为空时显示短横线,避免表格出现难以辨认的空单元格。 #}
<td>{{ identity.display_name|default:"-" }}</td>
<td>{{ identity.last_seen_at }}</td>
<td>
<form method="post" action="{% url 'accounts:external_identity_unbind' identity.pk %}" class="inline-form">
{% csrf_token %}
<button class="link-button danger-text" type="submit">解绑</button>
</form>
</td>
</tr>
{% empty %}
<tr><td class="empty" colspan="5">当前账号尚未绑定第三方身份。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<section class="panel top-space">
<h2>真实平台状态</h2>
<p>企业微信、飞书和微信开放平台目前只保留适配接口;没有真实租户、回调地址和凭据,因此不能声称真实平台登录已经通过。</p>
</section>
{% endblock %}
5.7 templates/base.html 最终完整文件
{# templates/base.html #}
{# load 会加载 static 与 policy_tags 标签库,之后才能使用静态文件标签和项目自定义授权标签。 #}
{% load static policy_tags %}
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}devopsX 用户与访问控制{% endblock %}</title>
<link rel="stylesheet" href="{% static 'accounts/style.css' %}">
</head>
<body>
<header class="site-header">
<div class="container header-row">
<a class="brand" href="{% url 'accounts:home' %}">devopsX</a>
{% if user.is_authenticated %}
{% policy_can "accounts.user.view" as can_view_users %}
{% policy_can "accounts.department.view" as can_view_departments %}
{% policy_can "accounts.role.view" as can_view_roles %}
{% policy_can "accounts.capability.view" as can_view_capabilities %}
<nav aria-label="主导航">
<a href="{% url 'accounts:home' %}">首页</a>
{% if can_view_users %}<a href="{% url 'accounts:user_list' %}">用户</a>{% endif %}
{% if can_view_departments %}<a href="{% url 'accounts:department_list' %}">部门</a>{% endif %}
{% if can_view_roles %}<a href="{% url 'accounts:role_list' %}">角色</a>{% endif %}
{% if can_view_capabilities %}<a href="{% url 'accounts:permission_list' %}">能力目录</a>{% endif %}
</nav>
<div class="account-actions">
<span>当前用户:{{ user }}</span>
<a href="{% url 'accounts:external_identities' %}">登录方式</a>
<a href="{% url 'accounts:password_change' %}">修改密码</a>
<form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="link-button">退出</button>
</form>
</div>
{% endif %}
</div>
</header>
<main class="container">
{% if messages %}
<div class="messages" aria-live="polite">
{% for message in messages %}
{# default 过滤器在 message.tags 为空时改用 info,保证消息元素始终有可用样式类。 #}
<div class="message {{ message.tags|default:'info' }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
模板只负责可用性和提示,不是安全边界。即使删除全部按钮,后端方法限制、CSRF、Session/事务校验、服务层锁和身份归属检查仍必须独立成立。现有 accounts/static/accounts/style.css 已包含这些模板使用的通用类,本次不要求新增 CSS。
5.8 devopsX/settings.py 与 .env.example 增量
# devopsX/settings.py:第三方身份配置增量
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "accounts:home"
LOGOUT_REDIRECT_URL = "accounts:login"
# Fake Provider 必须同时通过两个启动门:运维显式开启,且当前确实是 DEBUG 开发环境。
# 默认 False 不能写成 DEBUG;否则开发环境只要忘记配置开关就会意外暴露测试身份源。
ACCOUNTS_ENABLE_FAKE_PROVIDER = _env_bool(
"DEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER",
False,
)
# ALLOWED 是代码根据 DEBUG 推导的硬边界,ENABLE 是环境变量表达的显式意图;生产环境即使误配 ENABLE 也在启动时失败。
ACCOUNTS_FAKE_PROVIDER_ALLOWED = DEBUG
if ACCOUNTS_ENABLE_FAKE_PROVIDER and not ACCOUNTS_FAKE_PROVIDER_ALLOWED:
raise ImproperlyConfigured("生产模式禁止启用本地 Fake Provider。")
# 登录事务五分钟后失效;回调还必须同时通过 state、Session、PKCE、nonce 和一次消费状态检查。
ACCOUNTS_EXTERNAL_LOGIN_TTL_SECONDS = 300
# .env.example;占位值不是实际凭据
DEVOPSX_SECRET_KEY=REPLACE_WITH_A_LONG_RANDOM_DJANGO_SECRET_KEY
DEVOPSX_DEBUG=true
DEVOPSX_ALLOWED_HOSTS=127.0.0.1,localhost
DEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER=true
Fake 的运行时双门是 ACCOUNTS_FAKE_PROVIDER_ALLOWED 与 ACCOUNTS_ENABLE_FAKE_PROVIDER 同时为真;设置加载阶段还明确拒绝 DEBUG=False 且 Fake 开启。不要把 ACCOUNTS_FAKE_PROVIDER_ALLOWED 改成可由普通环境变量独立打开,否则生产误配置会绕过代码门。真实平台仍没有任何 client secret、token 或租户配置,状态就是“未配置”。
6. 迁移 0004/0005、执行命令与验收
6.1 为什么迁移边界必须保留两步
0004 创建三个模型,初始使用 (provider, subject) 唯一约束;0005 再增加摘要、回填历史行、移除原约束与索引、把摘要改为非空并建立最终唯一约束。已经发布的项目不要把这两个历史迁移随意改成一份:数据库可能正停在 0004,0005 的数据迁移是升级路径的一部分。
在大小写不敏感排序规则中,0004 的字符串唯一键可能无法容纳仅大小写不同的 subject,这正是 0005 修正的原因。本文当前自动化记录来自 SQLite;它验证迁移逻辑和应用行为,但不能写成 MySQL 并发或生产排序规则已经实际验收。
6.2 0004_externalidentity_logintransaction_and_more.py 最终完整文件
# accounts/migrations/0004_externalidentity_logintransaction_and_more.py
# Generated by Django 5.2.17 on 2026-09-22 07:25
# 此文件是历史迁移:运行时必须使用这里冻结的字段定义,不能导入当前 models.py 后“追随未来代码”。
import django.db.models.deletion
# uuid.uuid4 作为迁移状态中 UUIDField 的默认可调用对象;settings.AUTH_USER_MODEL 保留可替换用户模型依赖。
import uuid
from django.conf import settings
# migrations 描述架构操作,models 描述当时的字段/索引/约束状态。
from django.db import migrations, models
# Migration 继承 migrations.Migration 迁移基类;Django 读取 dependencies 和 operations 来确定顺序并执行历史架构操作。
class Migration(migrations.Migration):
# dependencies 保证 0003 先完成;operations 按列表顺序创建表,再添加索引和唯一约束。
dependencies = [
('accounts', '0003_migrate_legacy_access'),
]
operations = [
# CreateModel 的 options 是当时模型 Meta 的历史快照;以后 models.py 改名/排序也不会改变本迁移。
migrations.CreateModel(
name='ExternalIdentity',
fields=[
# 类型:BigAutoField;用途:第三方身份表自增主键;空值:不允许 NULL;删除:随身份记录删除。
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 类型:CharField;用途:保存提供方稳定键;空值:不允许 NULL 或空值;删除:随身份记录删除。
('provider', models.CharField(choices=[('fake', '本地 Fake Provider'), ('enterprise_wechat', '企业微信'), ('feishu', '飞书'), ('wechat_open_platform', '微信开放平台')], max_length=50, verbose_name='身份提供方')),
# 类型:CharField;用途:保存平台稳定 subject;空值:不允许 NULL 或空值;删除:随身份记录删除。
('subject', models.CharField(max_length=255, verbose_name='平台身份编号')),
# 类型:CharField;用途:缓存平台展示名称;空值:数据库不存 NULL,但允许空字符串;删除:随身份记录删除。
('display_name', models.CharField(blank=True, max_length=100, verbose_name='平台显示名称')),
# 类型:EmailField;用途:缓存平台邮箱;空值:数据库不存 NULL,但允许空字符串;删除:随身份记录删除。
('email', models.EmailField(blank=True, max_length=254, verbose_name='平台邮箱')),
# 类型:DateTimeField;用途:记录身份首次出现时间;空值:不允许 NULL,创建时自动生成;删除:随身份记录删除。
('first_seen_at', models.DateTimeField(auto_now_add=True, verbose_name='首次发现时间')),
# 类型:DateTimeField;用途:记录身份最近使用时间;空值:不允许 NULL,保存时自动更新;删除:随身份记录删除。
('last_seen_at', models.DateTimeField(auto_now=True, verbose_name='最近使用时间')),
# 类型:ForeignKey;用途:关联当前绑定的本地用户;空值:允许 NULL 和表单空值;删除:SET_NULL 保留历史身份并清空用户关系。
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='external_identities', to=settings.AUTH_USER_MODEL, verbose_name='本地用户')),
],
options={
'verbose_name': '第三方身份',
'verbose_name_plural': '第三方身份',
'ordering': ['provider', 'subject'],
},
),
migrations.CreateModel(
name='LoginTransaction',
fields=[
# 类型:BigAutoField;用途:登录事务表自增主键;空值:不允许 NULL;删除:随事务记录删除。
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 类型:UUIDField;用途:公开定位一次外部流程;空值:不允许 NULL,由 uuid4 生成;删除:随事务记录删除。
('transaction_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='事务编号')),
# 类型:CharField;用途:保存事务 provider 键;空值:不允许 NULL 或空值;删除:随事务记录删除。
('provider', models.CharField(choices=[('fake', '本地 Fake Provider'), ('enterprise_wechat', '企业微信'), ('feishu', '飞书'), ('wechat_open_platform', '微信开放平台')], max_length=50, verbose_name='身份提供方')),
# 类型:CharField;用途:区分登录或绑定意图;空值:不允许 NULL 或空值;删除:随事务记录删除。
('intent', models.CharField(choices=[('login', '登录'), ('bind', '绑定')], max_length=20, verbose_name='事务目的')),
# 类型:CharField;用途:保存 state 的 HMAC 摘要并唯一定位请求;空值:不允许 NULL 或空值;删除:随事务记录删除。
('state_digest', models.CharField(max_length=64, unique=True, verbose_name='state 摘要')),
# 类型:CharField;用途:保存发起 Session key 摘要;空值:不允许 NULL 或空值;删除:随事务记录删除。
('session_binding', models.CharField(max_length=64, verbose_name='Session 绑定摘要')),
# 类型:CharField;用途:固定服务端生成的回调地址;空值:不允许 NULL 或空值;删除:随事务记录删除。
('redirect_uri', models.CharField(max_length=500, verbose_name='固定回调地址')),
# 类型:CharField;用途:保存 PKCE S256 challenge;空值:不允许 NULL 或空值;删除:随事务记录删除。
('code_challenge', models.CharField(max_length=128, verbose_name='PKCE challenge')),
# 类型:CharField;用途:保存 nonce 的 HMAC 摘要;空值:数据库不存 NULL,但允许空字符串;删除:随事务记录删除。
('nonce_digest', models.CharField(blank=True, max_length=64, verbose_name='nonce 摘要')),
# 类型:CharField;用途:记录待回调、已消费、成功或失败状态;空值:不允许 NULL,默认 pending;删除:随事务记录删除。
('status', models.CharField(choices=[('pending', '等待回调'), ('consumed', '已消费'), ('succeeded', '成功'), ('failed', '失败')], default='pending', max_length=20, verbose_name='状态')),
# 类型:CharField;用途:保存安全内部失败码;空值:数据库不存 NULL,但允许空字符串;删除:随事务记录删除。
('failure_code', models.CharField(blank=True, max_length=100, verbose_name='内部失败代码')),
# 类型:DateTimeField;用途:定义事务失效时间;空值:不允许 NULL;删除:随事务记录删除。
('expires_at', models.DateTimeField(verbose_name='过期时间')),
# 类型:DateTimeField;用途:记录首次消费时间以识别重放;空值:允许 NULL 和表单空值;删除:随事务记录删除。
('consumed_at', models.DateTimeField(blank=True, null=True, verbose_name='消费时间')),
# 类型:DateTimeField;用途:记录事务创建时间;空值:不允许 NULL,创建时自动生成;删除:随事务记录删除。
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
# 类型:ForeignKey;用途:绑定发起用户用于绑定流程防换人;空值:允许 NULL 和表单空值;删除:SET_NULL 保留事务并清空发起用户。
('initiating_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='initiated_login_transactions', to=settings.AUTH_USER_MODEL, verbose_name='发起用户')),
],
options={
'verbose_name': '外部登录事务',
'verbose_name_plural': '外部登录事务',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AuthorizationAuditEvent',
fields=[
# 类型:BigAutoField;用途:审计事件表自增主键;空值:不允许 NULL;删除:随审计记录删除。
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 类型:CharField;用途:保存固定审计事件类型;空值:不允许 NULL 或空值;删除:随审计记录删除。
('event_type', models.CharField(choices=[('external_login.started', '外部登录开始'), ('external_login.succeeded', '外部登录成功'), ('external_login.failed', '外部登录失败'), ('external_identity.bound', '第三方身份绑定'), ('external_identity.unbound', '第三方身份解绑')], max_length=80, verbose_name='事件类型')),
# 类型:CharField;用途:保存成功、拒绝或错误结果;空值:不允许 NULL 或空值;删除:随审计记录删除。
('outcome', models.CharField(choices=[('success', '成功'), ('denied', '拒绝'), ('error', '错误')], max_length=20, verbose_name='结果')),
# 类型:CharField;用途:冗余保存 provider 键便于检索;空值:数据库不存 NULL,但允许空字符串;删除:随审计记录删除。
('provider', models.CharField(blank=True, max_length=50, verbose_name='身份提供方')),
# 类型:CharField;用途:保存固定且不含秘密的原因码;空值:数据库不存 NULL,但允许空字符串;删除:随审计记录删除。
('reason_code', models.CharField(blank=True, max_length=100, verbose_name='原因代码')),
# 类型:JSONField;用途:保存筛选后的非秘密详情;空值:不使用 NULL,允许空字典;删除:随审计记录删除。
('detail', models.JSONField(blank=True, default=dict, verbose_name='安全详情')),
# 类型:DateTimeField;用途:记录事件发生时间;空值:不允许 NULL,创建时自动生成;删除:随审计记录删除。
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发生时间')),
# 类型:ForeignKey;用途:关联操作用户;空值:允许 NULL 和表单空值;删除:SET_NULL 保留审计并清空操作用户。
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorization_audit_events', to=settings.AUTH_USER_MODEL, verbose_name='操作用户')),
# 类型:ForeignKey;用途:关联目标用户;空值:允许 NULL 和表单空值;删除:SET_NULL 保留审计并清空目标用户。
('target_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeted_authorization_audit_events', to=settings.AUTH_USER_MODEL, verbose_name='目标用户')),
# 类型:ForeignKey;用途:关联第三方身份;空值:允许 NULL 和表单空值;删除:SET_NULL 保留审计并清空身份引用。
('external_identity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorization_audit_events', to='accounts.externalidentity', verbose_name='第三方身份')),
# 类型:ForeignKey;用途:关联登录事务;空值:允许 NULL 和表单空值;删除:SET_NULL 保留审计并清空事务引用。
('login_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorization_audit_events', to='accounts.logintransaction', verbose_name='登录事务')),
],
options={
'verbose_name': '授权审计事件',
'verbose_name_plural': '授权审计事件',
'ordering': ['-created_at', '-pk'],
},
),
# 显式命名索引/约束便于后续迁移稳定 Remove;唯一约束负责并发正确性,普通索引只负责查询性能。
migrations.AddIndex(
model_name='externalidentity',
index=models.Index(fields=['provider', 'subject'], name='accounts_ext_identity_lookup'),
),
migrations.AddIndex(
model_name='externalidentity',
index=models.Index(fields=['user', 'provider'], name='accounts_ext_identity_user'),
),
migrations.AddConstraint(
model_name='externalidentity',
constraint=models.UniqueConstraint(fields=('provider', 'subject'), name='accounts_unique_external_identity'),
),
migrations.AddIndex(
model_name='logintransaction',
index=models.Index(fields=['provider', 'state_digest'], name='accounts_login_tx_lookup'),
),
migrations.AddIndex(
model_name='logintransaction',
index=models.Index(fields=['expires_at', 'consumed_at'], name='accounts_login_tx_expiry'),
),
migrations.AddIndex(
model_name='authorizationauditevent',
index=models.Index(fields=['event_type', 'created_at'], name='accounts_audit_event_time'),
),
migrations.AddIndex(
model_name='authorizationauditevent',
index=models.Index(fields=['actor', 'created_at'], name='accounts_audit_actor_time'),
),
]
6.3 0005_external_identity_subject_digest.py 最终完整文件
# accounts/migrations/0005_external_identity_subject_digest.py
# hashlib 在数据迁移中直接计算摘要;不能导入当前模型 helper,否则多年后重放迁移会随未来代码改变。
import hashlib
# migrations 提供 RunPython/架构操作,models 仅描述这一历史状态中的新字段与约束。
from django.db import migrations, models
def populate_subject_digests(apps, schema_editor):
# 输入:历史应用注册表和 schema_editor;输出:无返回值并回填摘要;调用者:Django RunPython;失败:编码或数据库异常使迁移失败。
# apps.get_model() 返回迁移当时的历史模型,不含当前 models.py 的自定义 save()/方法;所以摘要必须在这里显式计算。
# 数据库读取:使用历史 ExternalIdentity 模型迭代全部行,避免加载到内存或依赖未来模型方法。
ExternalIdentity = apps.get_model("accounts", "ExternalIdentity")
for identity in ExternalIdentity.objects.all().iterator():
# 保存:逐行按原始 subject 计算 SHA-256 并只更新新增列,不规范化或改写第三方不透明值。
identity.subject_digest = hashlib.sha256(
identity.subject.encode("utf-8")
).hexdigest()
identity.save(update_fields=["subject_digest"])
# Migration 继承 migrations.Migration 迁移基类,按 operations 顺序执行扩列、回填、收紧和约束切换。
class Migration(migrations.Migration):
# 这是 expand -> backfill -> contract:先加可空列,再回填,最后收紧非空并切换索引/唯一约束。
# Django 会按数据库能力包事务;MySQL 等后端常不能回滚 DDL,失败后需检查实际 schema,不能假设所有步骤自动撤销。
dependencies = [
("accounts", "0004_externalidentity_logintransaction_and_more"),
]
operations = [
# 阶段一:先增加可空列,避免历史行因没有摘要而立即违反非空约束。
migrations.AddField(
model_name="externalidentity",
name="subject_digest",
# 类型:CharField;用途:暂存 subject 的 64 位 SHA-256 摘要;空值:迁移回填阶段临时允许 NULL;删除:随身份记录删除。
field=models.CharField(
editable=False,
max_length=64,
null=True,
verbose_name="平台身份编号摘要",
),
),
# 阶段二:在改变唯一键前为所有历史身份回填摘要;noop 表示反向迁移不执行“反回填”。
# 反向继续回滚后面的 schema 操作并最终删除该列,但不能靠 RunPython 恢复一个从未保存的旧摘要状态。
migrations.RunPython(
populate_subject_digests,
migrations.RunPython.noop,
),
# 阶段三:移除 provider + 原始 subject 的旧唯一约束与索引,准备切换到固定长度摘要。
migrations.RemoveConstraint(
model_name="externalidentity",
name="accounts_unique_external_identity",
),
migrations.RemoveIndex(
model_name="externalidentity",
name="accounts_ext_identity_lookup",
),
# 阶段四:摘要全部存在后收紧为非空,避免运行期产生不可索引身份。
migrations.AlterField(
model_name="externalidentity",
name="subject_digest",
# 类型:CharField;用途:回填后作为非空身份查找摘要;空值:不再允许 NULL;删除:随身份记录删除。
field=models.CharField(
editable=False,
max_length=64,
verbose_name="平台身份编号摘要",
),
),
# 阶段五:新增 provider + digest 索引,并以同一组合唯一约束收敛并发创建竞争。
migrations.AddIndex(
model_name="externalidentity",
index=models.Index(
fields=["provider", "subject_digest"],
name="accounts_ext_identity_digest",
),
),
migrations.AddConstraint(
model_name="externalidentity",
constraint=models.UniqueConstraint(
fields=("provider", "subject_digest"),
name="accounts_unique_identity_digest",
),
),
]
populate_subject_digests(apps, schema_editor) 输入历史 App 注册表和 schema editor,无返回值;迁移执行器调用。它必须用 apps.get_model() 取得 0005 时刻的历史模型,迭代读取每个 subject,按精确 UTF-8 字节写摘要。不能从当前 accounts.models 导入模型,否则未来模型变化会破坏旧迁移重放。先允许 null、回填、再改非空,避免已有行立即违反约束。
6.4 目录、命令、目的、预期输出和常见错误
目录:项目根目录,即包含 manage.py 的目录。不要在 accounts/ 子目录执行。
# 目的:确认模型与配置可以加载;预期:System check identified no issues
python manage.py check
# 目的:确认 0004、0005 已被发现;预期:列表中显示两个未执行或已执行标记
python manage.py showmigrations accounts
# 目的:应用身份表与摘要数据迁移;预期:Applying accounts.0004... OK 与 0005... OK
python manage.py migrate
# 目的:确认模型没有未提交的新迁移;预期:No changes detected
python manage.py makemigrations --check --dry-run
# 目的:只跑身份流程;当前记录:Ran 24 tests,OK
python manage.py test accounts.test_identity -v 2
# 目的:跑迁移测试;当前记录:身份相关迁移包含在 3 项测试中并通过
python manage.py test accounts.test_migrations -v 2
# 目的:跑 accounts 全套;当前记录:Ran 90 tests,OK (skipped=2)
python manage.py test accounts -v 1
| 错误 | 原因 | 处理 |
|---|---|---|
No module named accounts.providers | Provider 包或 __init__.py 未创建 | 按本文完整目录创建六个文件,确认包名与导入一致。 |
no such column: subject_digest | 模型已更新但 0005 未执行 | 运行 showmigrations 和 migrate,不要手工改表。 |
| Fake 返回 404 | DEBUG 关闭或 Fake 开关关闭 | 只在本地学习环境启用;生产不能为通过测试而打开。 |
| 绑定总提示密码错误 | 用户没有可用本地密码或输入不是当前密码 | 先通过受信任的本地账号恢复流程设置密码;不能跳过复核。 |
| 回调提示流程失效 | Session 变化、state/UUID 不匹配、过期或重放 | 从登录/绑定入口重新开始,不复用旧回调 URL。 |
| 第三方身份已验证但不能登录 | 身份未绑定、绑定用户停用或绑定在并发中变化 | 这是预期 fail-closed;不要按邮箱自动找用户。 |
| 两个 MySQL 测试 skipped | 当前测试数据库是 SQLite | 记录为环境跳过,不得改写成 MySQL 并发已通过。 |
6.5 浏览器 Fake 验收步骤
- 使用本地密码登录一个激活测试用户,打开“登录方式”。
- 点击“绑定 Fake 身份”,选择预置身份并再次输入当前密码。
- 在 Fake 授权页选择同意,确认返回身份列表并出现绑定记录。
- POST 退出;在登录页使用相同 Fake 身份登录,确认进入首页。
- 确认未绑定 Fake 身份不会创建用户,也不会因相同邮箱合并用户。
- 复制旧回调 URL 再访问,确认重放失败。
- 尝试解绑最后一种可用登录方式,确认被拒绝;为用户保留本地密码或另一可用身份后再解绑。
- 检查审计只含固定事件、结果、关联主键、Provider、reason code 和安全 detail,不含 code、token、state、nonce、verifier 或 Session key。
- 验收后删除临时测试用户和可删除业务数据;审计与身份保留规则按项目合规要求执行。当前记录中的浏览器 Fake 绑定/登录已通过,临时数据已清理。
这套验收只覆盖 Fake 编排。企业微信、飞书、微信开放平台仍处于 is_available() == False,真实平台保持未配置和 fail closed。
7. 第三方身份安全矩阵
| 场景 | 通过条件 | 拒绝边界与禁止捷径 |
|---|---|---|
| 外部流程 | Provider、Session、state、redirect、TTL、PKCE、nonce、单次消费全部匹配 | 统一失败;不能只校验 state |
| subject | provider + UTF-8 摘要唯一,命中后比较原始字节 | 冲突 409;不 lower/trim,不依赖数据库排序规则 |
| 绑定 | 当前激活用户重新提交正确本地密码,身份尚未被其他用户绑定 | 失败不写绑定;禁止按邮箱自动合并 |
| 登录 | 身份已绑定激活本地用户,事务归属和回调校验全部通过 | 未绑定身份不能自动创建本地用户 |
| 重放/解绑 | pending 事务在行锁内一次消费;解绑后仍保留一种当前可用登录方式 | 旧回调、旧 code 失败;未配置 Provider 不算可用 |
| 审计 | 固定事件、结果、原因码和安全 detail | 不存 token、code、state、nonce、verifier 或响应正文 |
| Fake | DEBUG 且显式启用 | 生产加载即拒绝;Fake 通过不等于真实平台联调 |
8. 本篇交付物与事实边界
8.1 三个文件名分别代表什么
| 文件 | 用途 | 是否进入 ZIP |
|---|---|---|
devopsX-用户与权限管理源码-v2.0.0.zip | 学习者实际解压、安装和运行的确定性源码包。 | 它本身就是交付物。 |
SOURCE_SHA256SUMS.txt | 记录版本、运行时、测试边界、68 个载荷文件各自的字节数和 SHA-256。 | 是,位于 ZIP 顶级目录内。 |
.cnblogs_accounts_rbac_manifest_v2.json | 本地构建审计报告,记录 allowlist、规范化、ZIP 参数、扫描和逐成员校验结果。 | 否。它包含构建机的本地输出路径,只用于本地审计,不发布进教学源码。 |
压缩包名称和顶级目录名称都带版本号。这样同时保存两个版本时不会互相覆盖,也能在提问时明确说明正在使用哪一版。不要把 ZIP 直接解压到已有的同名目录后继续覆盖;应先核对哈希,再解压到新的空目录。
8.2 68、69 与 69 为什么不是三个互相矛盾的数字
- 68 个 payload files:从已经通过测试的账号项目按精确 allowlist 复制出的源码、模板、迁移、测试和配置示例。
- 69 个 bundle files:68 个载荷文件,加上构建器生成的
SOURCE_SHA256SUMS.txt。 - 69 个 ZIP members:ZIP 中没有目录占位成员,每一个成员都是真实普通文件,因此成员数与 bundle 文件数相同。
载荷总字节数是 396817;加入校验文件后的暂存目录总字节数是 404567;最终 ZIP 是 123958 字节。字节数用于发现截断或意外改写,SHA-256 用于确认内容是否相同。
8.3 已验证与未验证必须分开写
| 范围 | 准确结果 | 不能据此声称什么 |
|---|---|---|
| 账号项目 | 90 tests OK,另有 2 MySQL-only skipped。 | 不能写成所有 MySQL 并发行为均已通过。 |
| Automation/CMDB 集成 | 262 tests OK。 | 这些测试通过不表示源码已装进本账号 ZIP。 |
| Fake Provider | 绑定和再次登录已做真实浏览器验收;确定性测试覆盖 state、PKCE、nonce、过期、重放、冲突、解绑和审计。 | 不能代替企业微信、飞书或微信开放平台的真实联调。 |
| 真实 Provider | 三个适配器保持 fail closed:不可用时拒绝开始授权和处理回调。 | 没有真实租户、真实回调地址和真实客户端凭据,因此没有网络 smoke test。 |
| MySQL | 包内有两项角色删除/分配锁顺序测试,SQLite 运行时按设计跳过。 | 外部登录回调与解绑同一身份、回调重放竞争、同一 subject 并发创建这三组身份并发场景尚未做真实 MySQL 验证。 |
另外,manage.py check --deploy 中与 HTTPS、HSTS、Secure Cookie 和反向代理有关的项目依赖真实域名与部署拓扑。本地源码不能替生产环境做这些决定,因此本篇不会把本地开发配置描述成生产部署已经完成。
9. 下载后先验证 ZIP,再解压
9.1 第一道校验:ZIP 整体 SHA-256
执行目录:保存 ZIP 的目录。目的:在解压前确认收到的字节与本篇定稿时完全一致。预期:输出下面的固定摘要;大小写不影响十六进制含义。常见错误:文件名写错、下载未完成,或者拿到了别的版本。
Get-FileHash -Algorithm SHA256 ".\devopsX-用户与权限管理源码-v2.0.0.zip"
预期 SHA-256:
c4d2f063d9dec8011137a82139c10c1e811b149bfb36e2be8fed532425b3af11
只要输出不同,就先停止。不要通过改文章里的摘要来“让它一致”,因为摘要的作用正是暴露下载损坏、文件被替换或版本拿错。
9.2 第二道校验:ZIP 自身 CRC 与可读取性
执行目录:保存 ZIP 的目录。目的:让 Python 逐个读取压缩成员并检查 ZIP 记录的 CRC。预期:命令正常结束且没有报错。常见错误:BadZipFile 通常表示压缩包损坏;它不能靠重新命名修复。
python -m zipfile -t ".\devopsX-用户与权限管理源码-v2.0.0.zip"
CRC 能发现常见传输损坏,但它不是内容身份凭证,所以必须先比较 SHA-256。两项检查解决的问题不同,不能只做其中一项。
9.3 解压时必须得到单一顶级目录
执行目录:保存 ZIP 的目录。目的:解压到新的空目录。预期:得到唯一顶级目录 devopsX-用户与权限管理源码-v2.0.0。常见错误:目标目录已存在时,PowerShell 可能把新旧文件混在一起;请换一个空目录,而不是覆盖。
Expand-Archive -LiteralPath ".\devopsX-用户与权限管理源码-v2.0.0.zip" -DestinationPath ".\accounts-rbac-release"
构建时已经验证所有成员使用 POSIX 相对路径,不含绝对路径、.. 穿越、反斜杠别名、重复名字、符号链接、设备文件或 FIFO。所有成员都位于同一个顶级目录内,且 ZIP 不含空目录成员。
9.4 可选的完整离线校验脚本
下面脚本不访问网络,也不运行项目代码。它先核对 ZIP 整体摘要,再检查成员路径、固定时间、普通文件权限、单一顶级目录,最后读取包内校验表并逐字节核对 68 个载荷文件。脚本的输入是 ZIP 路径,输出是校验统计;任何失败都会立即抛出异常。
# verify_release.py
# 从标准库导入 hashlib 计算 ZIP/成员摘要,stat 解析 Unix 模式,sys 读取命令行,zipfile 安全读取 ZIP 目录与成员。
import hashlib
import stat
import sys
import zipfile
# 导入 PurePosixPath 按 ZIP 规范解析正斜杠路径,不访问本地文件系统。
from pathlib import PurePosixPath
# ZIP 整体摘要用于确认拿到的是本篇登记的同一个发布物;构建后会由发布流程更新。
EXPECTED_ZIP_SHA256 = "c4d2f063d9dec8011137a82139c10c1e811b149bfb36e2be8fed532425b3af11"
# 所有 ZIP 成员必须位于这个唯一顶级目录,防止文件散落到解压目录之外。
EXPECTED_TOP_LEVEL = "devopsX-用户与权限管理源码-v2.0.0"
# payload 是权威源码 allowlist 中的文件,不包含构建器额外生成的校验表。
EXPECTED_PAYLOAD_FILES = 68
# bundle 比 payload 多一个 SOURCE_SHA256SUMS.txt,所以成员数必须是 69。
EXPECTED_BUNDLE_FILES = 69
# 每个成员使用同一时间戳,避免构建时间变化导致相同源码生成不同 ZIP。
EXPECTED_TIMESTAMP = (2026, 9, 24, 0, 0, 0)
# 0100644 表示“普通文件 + 所有者可写、其他人只读”,不允许链接或设备文件。
EXPECTED_MODE = 0o100644
def sha256_bytes(data):
# 输入:任意 bytes;输出:64 位小写十六进制 SHA-256;调用者:main 的 ZIP/成员校验;失败:传入非 bytes 时由 hashlib 抛 TypeError。
# 返回:只计算调用者已经读取的字节,不在这里打开文件,使计算结果容易单元测试。
return hashlib.sha256(data).hexdigest()
def parse_checksum_file(data):
# 输入:ZIP 内 SOURCE_SHA256SUMS.txt 的 UTF-8 字节;输出:路径到“摘要、字节数”的字典;调用者:main;失败:编码、格式、数量声明或整数无效时抛异常。
# 业务判断:校验文件必须是 UTF-8,并且头部与逐文件记录之间恰好由空行分隔。
text = data.decode("utf-8")
header, rows_text = text.split("\n\n", 1)
if "Payload-File-Count: 68" not in header:
raise ValueError("校验文件声明的载荷数量不正确。")
# 数据整理:逐行拆出摘要、十进制字节数和 POSIX 相对路径。
records = {}
for line in rows_text.splitlines():
if not line:
continue
digest, size_text, path = line.split("\t", 2)
# 业务判断:重复路径会让“后写覆盖前写”,因此必须拒绝,不能直接赋值后继续。
if path in records:
raise ValueError("校验文件出现重复路径:%s" % path)
records[path] = (digest, int(size_text))
# 返回:调用者随后用该字典逐个读取并核对 ZIP 成员。
return records
def validate_member_path(name):
# 输入:ZIP 中的成员名;输出:安全时无返回值;调用者:main 的成员循环;失败:反斜杠、绝对路径、父目录跳转或错误顶级目录时抛 ValueError。
# 业务判断:ZIP 规范使用正斜杠;拒绝反斜杠可避免不同平台把同一路径解释成不同文件。
if "\\" in name:
raise ValueError("ZIP 成员使用了反斜杠:%s" % name)
# 业务判断:PurePosixPath 只解析路径,不访问磁盘;解压前就能发现绝对路径和 .. 穿越。
path = PurePosixPath(name)
if path.is_absolute() or ".." in path.parts:
raise ValueError("ZIP 成员路径不安全:%s" % name)
if not path.parts or path.parts[0] != EXPECTED_TOP_LEVEL:
raise ValueError("ZIP 成员不在固定顶级目录:%s" % name)
def main():
# 输入:命令行中的一个 ZIP 路径;输出:全部通过时打印统计并正常退出;调用者:学习者直接运行脚本;失败:参数、摘要、CRC、结构、元数据或成员内容不符时立即抛异常。
# 输入检查:必须只给一个 ZIP,避免脚本默默校验错误文件或忽略多余参数。
if len(sys.argv) != 2:
raise SystemExit("用法:python verify_release.py ZIP文件")
# 文件读取:先按二进制读取整个 ZIP,摘要必须覆盖压缩包的每一个字节。
zip_path = sys.argv[1]
with open(zip_path, "rb") as source:
zip_bytes = source.read()
actual_zip_digest = sha256_bytes(zip_bytes)
if actual_zip_digest != EXPECTED_ZIP_SHA256:
raise ValueError("ZIP SHA-256 不一致:%s" % actual_zip_digest)
# 结构检查:只读取 ZIP 目录和成员字节,不调用 extract(),因此校验阶段不会向磁盘释放不可信路径。
with zipfile.ZipFile(zip_path, "r") as archive:
# CRC 用于发现常见传输损坏;它不能替代上面的 SHA-256 身份校验。
if archive.testzip() is not None:
raise ValueError("ZIP CRC 校验失败。")
infos = archive.infolist()
if len(infos) != EXPECTED_BUNDLE_FILES:
raise ValueError("ZIP 成员数不是 %d。" % EXPECTED_BUNDLE_FILES)
# 业务判断:重复成员可能让不同解压工具选择不同版本,必须在读取载荷前拒绝。
names = [info.filename for info in infos]
if len(names) != len(set(names)):
raise ValueError("ZIP 出现重复成员名。")
# 元数据检查:每个成员都必须有安全路径、固定时间和普通文件权限。
for info in infos:
validate_member_path(info.filename)
if info.is_dir():
raise ValueError("ZIP 不应包含目录成员:%s" % info.filename)
if info.date_time != EXPECTED_TIMESTAMP:
raise ValueError("ZIP 成员时间不固定:%s" % info.filename)
mode = (info.external_attr >> 16) & 0o177777
if mode != EXPECTED_MODE or not stat.S_ISREG(mode):
raise ValueError("ZIP 成员不是固定权限的普通文件:%s" % info.filename)
# 数据读取:校验表本身位于固定顶级目录,先解析它,再确认记录数等于 payload 数量。
checksum_name = "%s/SOURCE_SHA256SUMS.txt" % EXPECTED_TOP_LEVEL
records = parse_checksum_file(archive.read(checksum_name))
if len(records) != EXPECTED_PAYLOAD_FILES:
raise ValueError("载荷记录数不是 %d。" % EXPECTED_PAYLOAD_FILES)
# 内容检查:每个 allowlist 文件同时比较字节数和 SHA-256,任一不同都停止。
for relative_path, expected in records.items():
member_name = "%s/%s" % (EXPECTED_TOP_LEVEL, relative_path)
payload = archive.read(member_name)
expected_digest, expected_size = expected
if len(payload) != expected_size:
raise ValueError("文件字节数不一致:%s" % relative_path)
if sha256_bytes(payload) != expected_digest:
raise ValueError("文件 SHA-256 不一致:%s" % relative_path)
# 返回:只有所有检查都通过后才打印成功;函数自然返回 None,进程退出码为 0。
print("ZIP、结构和 %d 个载荷文件全部通过校验。" % EXPECTED_PAYLOAD_FILES)
if __name__ == "__main__":
# 只有直接执行脚本才启动校验;被测试导入时不会读取命令行或文件。
main()
执行目录:verify_release.py 与 ZIP 所在目录。目的:执行比普通解压更严格的离线核对。预期:ZIP、结构和 68 个载荷文件全部通过校验。 常见错误:若脚本报告成员时间、权限或成员数不一致,说明拿到的不是本篇登记的确定性 ZIP,即使里面的 Python 文件看起来能运行也不能视为同一发布物。
python ".\verify_release.py" ".\devopsX-用户与权限管理源码-v2.0.0.zip"
10. 源码目录与每一组文件的职责
10.1 顶层文件
| 路径 | 职责 | 不能放入什么 |
|---|---|---|
manage.py | Django 命令入口;把命令交给项目设置和管理框架。 | 不放业务授权判断。 |
requirements.txt | 精确固定 Django 5.2.17,并把 python-dotenv 限制在 1.x。 | 版本范围不校验下载到的 wheel 身份。 |
requirements-mysql.txt | 在基础依赖上把 mysqlclient 限制在 2.2~3.0 之前,只服务 MySQL 专项测试。 | 不代表默认数据库已改为 MySQL,也不校验 wheel 身份。 |
.env.example | 列出变量名和明确占位值,供本地复制。 | 不放真实密钥、密码、租户 ID 或回调凭据。 |
.gitignore | 阻止本地数据库、.env、缓存和虚拟环境进入版本控制。 | 不能代替源码包的 allowlist。 |
SOURCE_SHA256SUMS.txt | 记录发布元数据和 68 个载荷文件的内容身份。 | 不记录构建机绝对路径。 |
10.2 devopsX 项目配置
| 路径 | 职责 |
|---|---|
devopsX/settings.py | 读取环境变量,配置 SQLite、模板、中间件、自定义 User、登录跳转和 Fake Provider 双门禁。 |
devopsX/settings_test.py | 在导入正式设置前注入确定性的测试环境变量,避免测试依赖开发者电脑的 .env。 |
devopsX/settings_mysql_test.py | 只为显式 MySQL 测试读取测试数据库连接变量。 |
devopsX/urls.py | 挂载 Admin 和 accounts 路由。 |
devopsX/asgi.py、devopsX/wsgi.py | 分别提供 ASGI 和 WSGI 入口;不包含业务逻辑。 |
devopsX/__init__.py | 把目录标识为 Python 包。 |
10.3 accounts 领域与授权核心
| 路径 | 职责 |
|---|---|
accounts/models.py | 定义 User、Department、Capability、Role、RoleCapability、UserRole、ExternalIdentity、LoginTransaction 和 AuthorizationAuditEvent。 |
accounts/capabilities.py | 集中登记账号教学项目的 12 个能力和 4 个系统角色;机器键不散落到页面。 |
accounts/policy.py | 从 UserRole → Role → RoleCapability → Capability 生成请求级快照,并提供 has/require 的 any/all 判断。 |
accounts/decorators.py | 把全局能力检查包装为登录保护后的视图装饰器。 |
accounts/templatetags/policy_tags.py | 为模板提供 policy_can,复用当前请求快照,避免模板循环反复查询数据库。 |
accounts/forms.py | 处理登录、用户、部门、角色、角色绑定和第三方身份表单,并限制操作员不能委派自己没有的能力。 |
accounts/views.py | 实现页面入口、事务内撤权复检、POST-only 状态变更、最后一个超级用户保护和身份流程。 |
accounts/urls.py | 为登录、退出、密码修改、管理页面和身份回调提供稳定命名路由。 |
accounts/admin.py | 把 Admin 限制为激活超级用户的内部控制面,并把身份事务与审计对象设为只读。 |
accounts/apps.py、accounts/__init__.py | 声明 App 配置和 Python 包边界。 |
10.4 第三方身份与 Provider
| 路径 | 职责 | 当前状态 |
|---|---|---|
accounts/identity.py | 生成 state、PKCE 和 nonce,创建/消费事务,绑定、登录、解绑并写受控审计原因码。 | 确定性测试和 Fake 浏览器流程通过。 |
accounts/providers/base.py | 定义 Provider 合同、规范化身份对象和可持久化错误码边界。 | 可用。 |
accounts/providers/__init__.py | 静态注册白名单 Provider;Fake 必须同时通过启动时允许标志和显式启用标志。 | 可用。 |
accounts/providers/fake.py | 提供本地、可重复、无网络的完整授权和回调流程。 | 仅限 DEBUG 学习与测试。 |
accounts/providers/enterprise_wechat.py | 企业微信适配器边界。 | 未配置时固定拒绝,不声称真实联调。 |
accounts/providers/feishu.py | 飞书适配器边界。 | 未配置时固定拒绝,不声称真实联调。 |
accounts/providers/wechat_open_platform.py | 微信开放平台适配器边界。 | 未配置时固定拒绝,不声称真实联调。 |
10.5 迁移、命令与测试
| 路径组 | 职责 |
|---|---|
accounts/migrations/0001_initial.py 至 0005_external_identity_subject_digest.py | 按可审计顺序建立用户基础、自定义 RBAC、旧数据兼容、第三方身份和大小写安全 subject 摘要。 |
accounts/management/commands/bootstrap_rbac.py | 校验目录并幂等同步能力、角色和角色能力关系;重复执行必须零漂移。 |
accounts/tests.py | 覆盖模型、Policy、装饰器、表单、Admin、本地认证、管理视图、bootstrap、URL 和模板。 |
accounts/test_identity.py | 覆盖身份事务、Fake Provider、回调失败、重放、绑定、登录、解绑和审计。 |
accounts/test_migrations.py | 覆盖旧访问数据迁移、身份表结构和 subject digest 回填。 |
accounts/test_mysql_concurrency.py | 包含两项只在 MySQL 测试数据库执行的角色分配/删除并发锁测试。 |
accounts/mysql_concurrency_tests.py | 提供显式 MySQL 测试标签/入口所需的测试模块边界。 |
accounts/management/__init__.py、accounts/management/commands/__init__.py、accounts/migrations/__init__.py、accounts/templatetags/__init__.py | 使 Django 和 Python 能发现对应包;即使文件很短或为空也不能随意删除。 |
10.6 模板与静态文件
templates/base.html 提供统一导航、消息和 POST 退出;templates/403.html 展示业务能力不足。templates/accounts/ 中共有 20 个模板,覆盖登录、密码修改、首页、用户、部门、角色、能力目录和四个身份页面。accounts/static/accounts/style.css 提供桌面与窄屏布局。模板只消费 Policy 结果,不自己拼数据库查询。
20 个账号模板是:
department_confirm_delete.html
department_form.html
department_list.html
external_identity_bind.html
external_identity_list.html
external_identity_result.html
fake_authorize.html
home.html
login.html
password_change_done.html
password_change_form.html
permission_list.html
role_confirm_delete.html
role_form.html
role_list.html
user_access_form.html
user_confirm_delete.html
user_detail.html
user_form.html
user_list.html
11. 五个迁移分别改变了什么
11.1 0001:身份基础与部门
0001_initial.py 建立 Department 和自定义 User。必须在第一次 migrate 前就让 AUTH_USER_MODEL 指向 accounts.User;不能先用默认 User 建库,再通过复制表来“切换”。密码字段仍由 Django 的认证基类管理,业务代码只调用经过验证的密码 API。
11.2 0002:自定义 RBAC
0002_custom_rbac.py 建立 Capability、Role、RoleCapability 和 UserRole,并建立唯一约束。两个显式关系模型不仅表达“多对多”,还保存 assigned_by 和 created_at。唯一约束阻止同一用户重复绑定同一角色、同一角色重复绑定同一能力。
11.3 0003:只迁移历史访问事实,不继续建设旧模型
0003_migrate_legacy_access.py 把可识别的历史组和历史权限映射为项目自己的角色与能力。迁移不会静默删除旧表,也不会让新业务代码继续依赖旧授权对象。数据迁移使用迁移时模型状态,而不是直接导入今天的 accounts.models,否则几年后模型变化可能让旧迁移无法重放。
11.4 0004:第三方身份、事务与审计
0004_externalidentity_logintransaction_and_more.py 建立 ExternalIdentity、LoginTransaction 和 AuthorizationAuditEvent。登录事务只保存 state 摘要、nonce 摘要、Session 绑定摘要和 PKCE challenge,不保存回调 code、PKCE verifier、access token 或 refresh token。未绑定外部身份不能凭相同邮箱自动创建或合并本地用户。
11.5 0005:MySQL 下仍保持 subject 大小写敏感
很多 MySQL 字符串排序规则默认不区分大小写,直接把 (provider, subject) 当唯一键可能让 Alice 与 alice 错误碰撞。0005_external_identity_subject_digest.py 回填 SHA-256 摘要并把唯一键改为 (provider, subject_digest)。业务查询拿到候选记录后还会比较原始 UTF-8 字节,因此摘要不是用来放宽匹配规则。
11.6 新库与已有库的迁移步骤
新库可以直接运行全部迁移。已有库必须先备份数据库,并在与生产相同数据库引擎的预发布环境重放;不要修改已经发布的 0001—0005 文件,因为 Django 用迁移历史标识执行状态,修改旧文件会让“数据库显示已执行”和“当前文件内容”产生分叉。
执行目录:解压后的顶级源码目录。目的:先查看将要执行的迁移。预期:列出 accounts 的 0001—0005。常见错误:缺少 DEVOPSX_SECRET_KEY 时设置加载会立即失败,这是有意的 fail-closed 行为。
python manage.py showmigrations accounts
执行目录:同上。目的:应用迁移。预期:首次显示各迁移 OK;再次运行显示没有需要应用的迁移。常见错误:如果在旧项目已经创建默认 auth 表后才替换 User 模型,应回到正确备份和迁移方案,不能删除迁移记录硬闯。
python manage.py migrate --noinput
12. 从空目录安装并启动账号教学项目
12.1 创建虚拟环境和安装依赖
执行目录:解压后的顶级源码目录。目的:为本项目创建隔离 Python 环境。预期:出现 .venv 目录。常见错误:若 python 指向错误版本,先执行 python --version;本发布物验证版本是 Python 3.10.8。
python -m venv .venv
执行目录:同上。目的:激活当前 PowerShell 会话的虚拟环境。预期:提示符通常出现 (.venv)。常见错误:执行策略阻止脚本时,可以直接使用 .venv\Scripts\python.exe 运行后续命令,不要关闭系统安全策略来图省事。
.\.venv\Scripts\Activate.ps1
执行目录:同上。目的:安装 Django 5.2.17 和 1.x 范围内的 dotenv。预期:安装成功。常见错误:不要为绕过网络或代理错误而删除版本约束。这里没有带哈希的 lock 文件:版本约束只限制解析结果,不能验证 wheel 发布者或下载字节;生产构建还应使用可信镜像和带哈希依赖锁。
python -m pip install -r requirements.txt
12.2 创建本地 .env
复制示例后,只修改副本 .env。settings.py 在项目根目录加载该文件,但源码包只允许 .env.example 进入 ZIP。
执行目录:源码顶级目录。目的:建立不进入版本控制的本地配置。预期:出现 .env。常见错误:直接修改 .env.example 会污染可复制模板;把真实密钥提交到 Git 更危险。
Copy-Item ".env.example" ".env"
学习环境至少要设置:
DEVOPSX_SECRET_KEY=请替换为足够长且随机的本地开发密钥
DEVOPSX_DEBUG=true
DEVOPSX_ALLOWED_HOSTS=127.0.0.1,localhost
DEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER=true
DEVOPSX_SECRET_KEY 不能为空;项目不会回退到仓库内固定密钥。Fake Provider 只有在 DEBUG 启用且显式开关也启用时才可用;若 DEBUG 关闭而 Fake 仍为 true,设置加载会直接拒绝启动。
12.3 系统检查、迁移和 RBAC 初始化
执行目录:源码顶级目录。目的:在改数据库前验证设置、App 和模型能加载。预期:System check identified no issues (0 silenced). 常见错误:环境变量布尔值只能使用 1/0、true/false、yes/no 或 on/off;拼写错误会被拒绝。
python manage.py check
随后执行迁移:
python manage.py migrate --noinput
执行目录:仍为源码顶级目录。目的:校验并精确同步 12 个能力、4 个系统角色和 34 条系统角色能力关系。预期:新建、更新、停用和删除计数与当前数据库状态一致;紧接着第二次运行必须全部为零。常见错误:能力键不在已登记命名空间、角色引用不存在能力或目录重复时,命令会拒绝继续,而不是写入半套目录。
python manage.py bootstrap_rbac
立即再执行一次:
python manage.py bootstrap_rbac
幂等性的含义不是“命令没有输出”,而是相同输入再次执行不改变数据库。新数据库在数据迁移阶段已经带入系统目录,所以首次手动 bootstrap 也可能显示零新建;应检查最终数量和第二次零漂移,而不是只盯首次新建数字。
12.4 创建第一个本地超级用户
执行目录:源码顶级目录。目的:创建本地 break-glass 管理身份。预期:命令要求输入用户名和密码并成功保存。常见错误:超级用户不是日常业务角色;正式学习授权差异时,还应创建普通用户并通过 UserRole 绑定角色。
python manage.py createsuperuser
Django 会用已配置的密码哈希器保存密码。教程和源码都不实现自己的哈希算法,也不会把明文密码写入数据库。
12.5 启动并完成最小浏览器验收
执行目录:源码顶级目录。目的:启动本地开发服务器。预期:监听 http://127.0.0.1:8000/。常见错误:端口占用时可显式换端口;开发服务器不用于公网生产部署。
python manage.py runserver 127.0.0.1:8000
- 用超级用户登录,确认首页、用户、部门、角色、能力目录和第三方身份入口可以打开。
- 创建普通用户,创建或选择角色,把角色绑定给用户。
- 用普通用户重新登录,确认导航和页面由 Capability 决定;
is_staff本身不授予业务能力。 - 在另一个管理员会话撤销角色,再让普通用户发起新请求,确认权限立即失效。
- 在 DEBUG 学习环境绑定 Fake 身份,退出后用相同 Fake subject 再次登录。
- 确认 GET 退出和 GET 状态变更不会成功;这些动作必须使用带 CSRF 的 POST。
13. 测试矩阵与准确执行方式
13.1 SQLite 确定性测试
执行目录:源码顶级目录。目的:使用独立测试设置执行账号项目全部测试。预期:Ran 90 tests、OK (skipped=2)。常见错误:不要从另一个含同名 accounts 包的工作目录调用本项目绝对 manage.py;应先进入本项目顶级目录,避免 Python 导入错误工程。
python manage.py test accounts --settings=devopsX.settings_test --verbosity=2
| 测试模块 | 主要覆盖 |
|---|---|
accounts.tests | 模型约束、匿名/inactive/superuser 规则、any/all、请求快照、装饰器、表单委派边界、Admin、登录、用户/部门/角色管理、事务复检、系统角色和最后一个超级用户保护。 |
accounts.test_identity | state、Session 绑定、PKCE、nonce、过期、一次消费、重放、错误净化、未绑定拒绝、邮箱不自动合并、绑定冲突、inactive 拒绝和最后登录方式保护。 |
accounts.test_migrations | 0003 历史访问迁移、0004 身份结构和 0005 大小写敏感 digest 回填。 |
accounts.test_mysql_concurrency | 两项角色分配/删除锁顺序测试;连接不是 MySQL 时按测试声明跳过。 |
13.2 迁移漂移检查
执行目录:源码顶级目录。目的:确认模型状态没有遗漏的新迁移。预期:No changes detected。常见错误:若生成了 0006,先检查是否误改模型;不要在不理解差异时直接提交自动生成迁移。
python manage.py makemigrations --check --dry-run
13.3 fresh database 重放
最可靠的迁移验证不是只在已经反复修改过的本地库上执行,而是在新目录或临时数据库中从 0001 重放到 0005。发布物已完成 fresh migrate,并验证 bootstrap 前后目录稳定为 12 个能力、4 个系统角色和 34 条关系。
若自己复现,请先停止开发服务器,把旧 db.sqlite3 移到项目外的备份位置,再在明确的新副本中运行;不要在含有学习数据的唯一数据库上直接删除文件。
13.4 MySQL 专用测试怎样运行
只有准备好专用测试数据库时才安装额外驱动。测试数据库账号不得指向生产库,也不要给它超出创建/删除测试数据库所需的权限。
执行目录:源码顶级目录。目的:安装基础依赖和 MySQL 驱动。预期:mysqlclient 安装成功。常见错误:Windows 缺少匹配编译环境或 wheel 时会安装失败;不能因此把测试改回 SQLite 后声称 MySQL 并发通过。
python -m pip install -r requirements-mysql.txt
在当前会话配置测试数据库变量后执行:
$env:DEVOPSX_DB_USER="专用测试用户"
$env:DEVOPSX_DB_PASSWORD="专用测试密码"
$env:DEVOPSX_DB_HOST="127.0.0.1"
$env:DEVOPSX_DB_PORT="3306"
python manage.py test accounts.test_mysql_concurrency --settings=devopsX.settings_mysql_test --verbosity=2
本版发布记录没有真实 MySQL 通过结果,所以以上命令是可复现入口,不是验收声明。尤其是第三方身份的三组并发场景仍需要单独补充真实 MySQL 测试设计与执行证据。
13.5 Automation 集成测试为何单列
Automation/CMDB 验证项目运行了 262 项测试,覆盖全局 Capability 与对象 AccessGrant 的交集、creator grant、列表/详情 404、已知对象动作 403、启动时清单与凭据复检、禁止申请人自批、取消状态机、HTML/JSON/SSE 输出一致性和 Worker 查看/管理分离。它证明第 3 篇集成没有破坏既有安全语义,但不改变本 ZIP 只交付账号教学项目的边界。
14. SOURCE_SHA256SUMS.txt:包内清单与三个摘要
完整 SOURCE_SHA256SUMS.txt 已放在 ZIP 根目录,解压后可直接逐文件校验。正文不再重复粘贴整份清单;否则同一批路径和摘要在包内与文章中各维护一份,反而容易在源码更新后产生漂移。清单覆盖 68 个载荷文件,ZIP 共 69 个普通文件成员。
| 对象 | SHA-256 | 用途 |
|---|---|---|
| 规范化源码树 fingerprint | 3537102a7945e743b3a71caa039a6f94718acf9678c9d996cae3965fc56214e5 | 绑定载荷文件的路径、大小与内容。 |
SOURCE_SHA256SUMS.txt | a5ee2c26203372245d16a2f7d6eaaf7e904076431ac10367d344d49ed1100c70 | 确认包内校验清单本身未变化。 |
| 最终 ZIP | c4d2f063d9dec8011137a82139c10c1e811b149bfb36e2be8fed532425b3af11 | 下载后先核对整个交付物。 |
SHA-256 只能回答字节是否与本文发布值相同,不能独立证明发布者身份;发布来源仍必须是本文固定地址。若任一摘要不一致,停止安装并重新取得交付物,不要修改摘要来迁就文件。
15. 确定性构建规则与秘密扫描
15.1 allowlist 而不是排除列表
构建器只复制精确列出的 68 个路径;任何未登记路径默认拒绝。这样即使源码目录里后来出现数据库、日志、截图、编辑器配置或临时凭据,也不会因为“忘了补一个排除规则”进入 ZIP。明确排除范围包括 .env、SQLite 数据库、日志、缓存、字节码、虚拟环境、媒体、收集后的静态目录、覆盖率输出、IDE/OS 元数据和本机构建脚本。
15.2 文本规范化
所有发布文本必须是 UTF-8、无 BOM、无 NUL、只用 LF。构建时有 3 个迁移文件从 CRLF 规范化为 LF,共转换 280 个 CRLF;其他内容保持字节不变。规范化发生在暂存副本,不回写事实源,因此构建过程不会偷偷修改通过测试的项目。
15.3 固定 ZIP 元数据
- 压缩算法:DEFLATE,level 9。
- 固定时间:
2026-09-24 00:00:00。 - 普通文件权限:
0100644。 - 路径分隔符:
/。 - 成员顺序:完整 UTF-8 成员名按字节升序。
- 单一顶级目录:
devopsX-用户与权限管理源码-v2.0.0。 - 无 archive comment、member comment、extra field、目录成员和 ZIP64。
固定元数据让同一载荷在相同构建脚本、Python 3.10.8、zipfile/zlib 实现与压缩环境中得到相同 ZIP,便于比较 SHA-256;它不承诺不同 Python、zlib 或平台必然产出相同压缩字节。跨环境核验应以解压后的逐文件摘要和 source tree fingerprint 为准。
15.4 路径与文件类型校验
构建前确认源文件都是普通文件,且没有符号链接或 Windows reparse point。构建后再次检查 ZIP 路径穿越、重复名、Unicode NFC/大小写折叠后的冲突、反斜杠、绝对路径、设备文件、FIFO 和符号链接。最后把 ZIP 解压到隔离临时目录,并与暂存目录逐文件、逐字节对账。
15.5 secret 与本机路径扫描
扫描范围包含载荷内容和 ZIP 成员路径。固定密钥、私钥头、常见云密钥、GitHub/Slack token、带用户名密码的 URL、本机用户绝对路径和真实 Provider 凭据候选都会使构建失败。允许的 11 项命中都必须是明确的测试 fixture 或 .env.example 占位值;构建报告中拒绝项为 0。
“扫描通过”不表示可以把任意新文本放进包里。每次源文件变化都必须重新构建、重新扫描和重新计算摘要;不能沿用旧 ZIP 的哈希。
16. 安全边界与上线前清单
16.1 Django 负责身份安全基础,自定义代码负责业务授权
项目继续使用 Django 的密码哈希、认证、登录、退出、Session、AuthenticationMiddleware、CSRF、request.user、is_authenticated 和 is_active。这些组件回答“用户是谁、登录是否有效、请求是否来自当前会话”。业务授权只沿着 User → UserRole → Role → RoleCapability → Capability 读取,并通过 Policy facade 暴露。
激活超级用户是 break-glass 旁路;is_staff 只决定能否进入内部 Admin,不等于业务授权。普通用户、停用用户、停用角色和停用能力的规则都由测试固定。
16.2 第三方身份上线前仍需真实平台工作
企业微信、飞书和微信开放平台文件当前是有意的 fail-closed stub。接入真实平台时仍需根据各自官方文档实现授权 URL、token 交换、签名/响应验证和稳定 subject 提取,并准备真实测试租户、注册回调地址、scope、证书与网络策略。不能把 Fake Provider 的成功截图当成这些平台的联调证据。
即使未来实现真实适配器,也不得自行设计 OAuth/OIDC、token 签名、Session cookie 或加密算法。access token 和 refresh token 当前不持久化;若业务以后必须调用平台 API,应先设计独立的加密凭据存储、轮换、最小权限和审计边界。
16.3 生产环境最低检查项
DEVOPSX_DEBUG=false,并确认 Fake Provider 开关为 false。- 使用独立、随机、未进入源码和日志的
DEVOPSX_SECRET_KEY。 DEVOPSX_ALLOWED_HOSTS只列真实域名。- 根据 TLS 终止位置配置 HTTPS 重定向、HSTS、Secure Session Cookie、Secure CSRF Cookie 和可信代理头。
- 在与生产相同数据库引擎上执行迁移和并发测试。
- 把开发服务器替换为受支持的 WSGI/ASGI 部署方案。
- 对数据库、密钥、审计日志和 Provider 配置制定备份、轮换和访问控制。
- 运行
python manage.py check --deploy,逐条根据真实拓扑处理,不静默忽略警告。
16.4 不能删除的回归边界
- 匿名和 inactive 用户没有业务能力。
- 只有激活超级用户拥有 break-glass 旁路。
has_all_capabilities的空输入返回 false,避免空配置意外放行。- 同一请求复用快照,新请求重新查询,撤权后不跨请求缓存。
- 角色和用户角色写入在事务内重新锁定并复检授权。
- 删除、状态切换、退出、解绑和 Worker 管理动作只接受 POST。
- 外部身份不按邮箱自动合并,subject 按原始字节区分大小写。
- 回调事务绑定 provider、redirect URI、Session、state、PKCE、nonce、过期和一次消费。
- 登录与解绑统一先锁用户行,再锁身份行。
- Provider 异常只持久化受控 reason code,不记录响应体、code 或 token。
- Automation 必须同时满足全局能力和对象 grant;无 view 返回 404,已知对象缺动作授权返回 403。
17. 最终验收顺序
17.1 学习者本地验收
- 核对 ZIP SHA-256。
- 解压到空目录并确认只有一个顶级目录。
- 创建虚拟环境并安装
requirements.txt。 - 复制并填写
.env。 - 运行
check、migrate和两次bootstrap_rbac。 - 运行 90 项账号测试,并确认两项跳过的原因确实是当前连接不是 MySQL。
- 创建超级用户和普通用户,通过角色绑定观察授权差异。
- 在本地 DEBUG 环境完成 Fake 身份绑定、退出和再次登录。
- 保留企业微信、飞书和微信开放平台的不可用状态,直到具备真实联调条件。
17.2 修改源码后的验收
只要任意载荷文件改变,旧的逐文件摘要、源码树 fingerprint 和 ZIP 摘要全部失效。正确流程是先在事实源运行完整测试,再用相同 allowlist 重新构建,重新做路径、秘密、ZIP 和解压对账,最后更新校验文件与文章数据。不能直接打开 ZIP 修改一个文件,也不能只重新计算 ZIP 摘要而保留旧的逐文件表。
17.3 系列完成后的心智模型
到这里,账号与权限主线可以压缩成四层:
- Django 身份基础:密码、认证、Session、CSRF 和当前用户。
- 项目自定义 RBAC:UserRole、RoleCapability 和 Capability 决定全局业务能力。
- 对象授权:Automation 在全局能力之外继续检查具体对象的 AccessGrant。
- 外部身份:Provider 只证明外部 subject,并映射到已有本地用户;它不直接授予业务能力。
这四层彼此协作,但不能互相替代。只要以后新增页面或动作,先判断它属于身份、全局能力、对象范围还是 Provider 边界,再把检查放到对应层,而不是在视图里临时堆条件。
系列导航: 第 1 篇:登录、用户、角色与能力 | 第 2 篇:Policy 与管理页面 | 第 3 篇:管理模板与 Automation 对象授权 | 第 4 篇:第三方身份、源码与校验

浙公网安备 33010602011771号