用户与权限管理(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/22712552Policy 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.providersProvider 包或 __init__.py 未创建按本文完整目录创建六个文件,确认包名与导入一致。
no such column: subject_digest模型已更新但 0005 未执行运行 showmigrations 和 migrate,不要手工改表。
Fake 返回 404DEBUG 关闭或 Fake 开关关闭只在本地学习环境启用;生产不能为通过测试而打开。
绑定总提示密码错误用户没有可用本地密码或输入不是当前密码先通过受信任的本地账号恢复流程设置密码;不能跳过复核。
回调提示流程失效Session 变化、state/UUID 不匹配、过期或重放从登录/绑定入口重新开始,不复用旧回调 URL。
第三方身份已验证但不能登录身份未绑定、绑定用户停用或绑定在并发中变化这是预期 fail-closed;不要按邮箱自动找用户。
两个 MySQL 测试 skipped当前测试数据库是 SQLite记录为环境跳过,不得改写成 MySQL 并发已通过。

6.5 浏览器 Fake 验收步骤

  1. 使用本地密码登录一个激活测试用户,打开“登录方式”。
  2. 点击“绑定 Fake 身份”,选择预置身份并再次输入当前密码。
  3. 在 Fake 授权页选择同意,确认返回身份列表并出现绑定记录。
  4. POST 退出;在登录页使用相同 Fake 身份登录,确认进入首页。
  5. 确认未绑定 Fake 身份不会创建用户,也不会因相同邮箱合并用户。
  6. 复制旧回调 URL 再访问,确认重放失败。
  7. 尝试解绑最后一种可用登录方式,确认被拒绝;为用户保留本地密码或另一可用身份后再解绑。
  8. 检查审计只含固定事件、结果、关联主键、Provider、reason code 和安全 detail,不含 code、token、state、nonce、verifier 或 Session key。
  9. 验收后删除临时测试用户和可删除业务数据;审计与身份保留规则按项目合规要求执行。当前记录中的浏览器 Fake 绑定/登录已通过,临时数据已清理。

这套验收只覆盖 Fake 编排。企业微信、飞书、微信开放平台仍处于 is_available() == False,真实平台保持未配置和 fail closed。

7. 第三方身份安全矩阵

场景通过条件拒绝边界与禁止捷径
外部流程Provider、Session、state、redirect、TTL、PKCE、nonce、单次消费全部匹配统一失败;不能只校验 state
subjectprovider + UTF-8 摘要唯一,命中后比较原始字节冲突 409;不 lower/trim,不依赖数据库排序规则
绑定当前激活用户重新提交正确本地密码,身份尚未被其他用户绑定失败不写绑定;禁止按邮箱自动合并
登录身份已绑定激活本地用户,事务归属和回调校验全部通过未绑定身份不能自动创建本地用户
重放/解绑pending 事务在行锁内一次消费;解绑后仍保留一种当前可用登录方式旧回调、旧 code 失败;未配置 Provider 不算可用
审计固定事件、结果、原因码和安全 detail不存 token、code、state、nonce、verifier 或响应正文
FakeDEBUG 且显式启用生产加载即拒绝;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.pyDjango 命令入口;把命令交给项目设置和管理框架。不放业务授权判断。
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
  1. 用超级用户登录,确认首页、用户、部门、角色、能力目录和第三方身份入口可以打开。
  2. 创建普通用户,创建或选择角色,把角色绑定给用户。
  3. 用普通用户重新登录,确认导航和页面由 Capability 决定;is_staff 本身不授予业务能力。
  4. 在另一个管理员会话撤销角色,再让普通用户发起新请求,确认权限立即失效。
  5. 在 DEBUG 学习环境绑定 Fake 身份,退出后用相同 Fake subject 再次登录。
  6. 确认 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_identitystate、Session 绑定、PKCE、nonce、过期、一次消费、重放、错误净化、未绑定拒绝、邮箱不自动合并、绑定冲突、inactive 拒绝和最后登录方式保护。
accounts.test_migrations0003 历史访问迁移、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用途
规范化源码树 fingerprint3537102a7945e743b3a71caa039a6f94718acf9678c9d996cae3965fc56214e5绑定载荷文件的路径、大小与内容。
SOURCE_SHA256SUMS.txta5ee2c26203372245d16a2f7d6eaaf7e904076431ac10367d344d49ed1100c70确认包内校验清单本身未变化。
最终 ZIPc4d2f063d9dec8011137a82139c10c1e811b149bfb36e2be8fed532425b3af11下载后先核对整个交付物。

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 学习者本地验收

  1. 核对 ZIP SHA-256。
  2. 解压到空目录并确认只有一个顶级目录。
  3. 创建虚拟环境并安装 requirements.txt。
  4. 复制并填写 .env。
  5. 运行 check、migrate 和两次 bootstrap_rbac。
  6. 运行 90 项账号测试,并确认两项跳过的原因确实是当前连接不是 MySQL。
  7. 创建超级用户和普通用户,通过角色绑定观察授权差异。
  8. 在本地 DEBUG 环境完成 Fake 身份绑定、退出和再次登录。
  9. 保留企业微信、飞书和微信开放平台的不可用状态,直到具备真实联调条件。

17.2 修改源码后的验收

只要任意载荷文件改变,旧的逐文件摘要、源码树 fingerprint 和 ZIP 摘要全部失效。正确流程是先在事实源运行完整测试,再用相同 allowlist 重新构建,重新做路径、秘密、ZIP 和解压对账,最后更新校验文件与文章数据。不能直接打开 ZIP 修改一个文件,也不能只重新计算 ZIP 摘要而保留旧的逐文件表。

17.3 系列完成后的心智模型

到这里,账号与权限主线可以压缩成四层:

  1. Django 身份基础:密码、认证、Session、CSRF 和当前用户。
  2. 项目自定义 RBAC:UserRole、RoleCapability 和 Capability 决定全局业务能力。
  3. 对象授权:Automation 在全局能力之外继续检查具体对象的 AccessGrant。
  4. 外部身份:Provider 只证明外部 subject,并映射到已有本地用户;它不直接授予业务能力。

这四层彼此协作,但不能互相替代。只要以后新增页面或动作,先判断它属于身份、全局能力、对象范围还是 Provider 边界,再把检查放到对应层,而不是在视图里临时堆条件。


系列导航: 第 1 篇:登录、用户、角色与能力 | 第 2 篇:Policy 与管理页面 | 第 3 篇:管理模板与 Automation 对象授权 | 第 4 篇:第三方身份、源码与校验

posted @ 2026-08-27 14:28  小家电维修  阅读(10)  评论(0)    收藏  举报