oidc

package
v1.8.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 4, 2026 License: Apache-2.0 Imports: 48 Imported by: 0

Documentation

Index

Constants

View Source
const ThirdAuthcodeRedisPrefix = "thirdlogin:authcode:"

ThirdAuthcodeRedisPrefix 与 user 模块 ThirdAuthcodePrefix 一致, 复用前端短码轮询取登录态的现有约定。注意:不能改名,前端协议公开。

Variables

View Source
var (
	ErrBindNotFound       = errors.New("oidc: bind session not found or expired")
	ErrBindStatusConflict = errors.New("oidc: bind status transition conflict")
	ErrBindRateLimited    = errors.New("oidc: bind rate limit exceeded")
	// ErrBindNoPhone claims 里没有可信手机号(空 / phone_verified=false),
	// 短信路径(SendSMS / VerifySMS)不可用 —— FR-3.3。
	// 与"SMSService 内部失败"区分,让 handler 把前者翻 400(业务前提不满足,
	// 客户端不应当 retry),后者翻 500(基础设施异常,可重试)。
	ErrBindNoPhone = errors.New("oidc: bind claims has no verified phone")
	// ErrBindConflictNeedManual claims 命中多条 dmwork user(同 phone 多账号),
	// 自助流程无法判定,走 P1 Admin 人工兜底。
	ErrBindConflictNeedManual = errors.New("oidc: bind claims match multiple dmwork users")
	// ErrBindAlreadyBound 同 (issuer, sub) 已绑到别的 uid;或同 (uid, issuer)
	// 已存在另一 sub。两种都触发 DB 唯一约束 → 1062,confirm 路径需要分别
	// 翻成对用户更友好的 409。
	ErrBindAlreadyBound = errors.New("oidc: bind identity already exists")
	// ErrBindAuthRejected 用户输入(密码/OTP)在底层校验被拒绝。与"内部错误"
	// 区分,让 metric label 能正确归到 unauthorized 而非 internal_error。
	// service 层用它包装 auth.* 的 matched=false / VerifyOIDCBindSMS 拒绝。
	ErrBindAuthRejected = errors.New("oidc: bind auth rejected")
	// ErrBindMethodDisabled 调用方请求的方法被运维通过 OCTO_OIDC_BIND_METHODS
	// 关闭了。Methods 必须是真实策略,不仅 UI 过滤 —— 否则攻击者可以硬调
	// 端点绕过运维"禁用密码"的安全开关。handler 翻 400,与"参数非法"同档,
	// 不属于身份凭据拒绝,因此与 ErrBindAuthRejected 区分。
	ErrBindMethodDisabled = errors.New("oidc: bind method disabled by configuration")

	// ErrBindCreateClaimsIncomplete claims 既无 verified email 也无 verified phone:
	// /bind/create 拒绝建号,因为后续客服 / 找回流程没有可信账号锚点。
	// handler 翻 422,metric label claims_incomplete。
	ErrBindCreateClaimsIncomplete = errors.New("oidc bind: claims missing required fields")

	// ErrBindCreateConflictNeedManual bind_token 是从 manual-conflict 来源签发的
	// (claims 命中多条 dmwork 账号);Create 路径拒绝建号,只能走 P1 Admin 人工合并。
	// handler 翻 409,metric label conflict_need_manual。与 ErrBindConflictNeedManual
	// (verify 路径短信多匹配)语义平行,但 token 维度的拒绝在 Create 入口落地,
	// 与 Issue 阶段固化的 IssueReason 字段联动。
	ErrBindCreateConflictNeedManual = errors.New("oidc bind: create rejected: claims conflict needs manual resolution")
)

哨兵错误。调用方按类型而非字符串判断,避免文案改动影响业务路径。

View Source
var (
	// ErrUnknownUser claims 未命中任何已存在的 dmwork 账号,且 AllowNewUser=false。
	ErrUnknownUser = errors.New("oidc: claims do not match any existing dmwork user")
	// ErrConflictNeedManual 同邮箱/手机号在 dmwork 端命中多条用户,需人工合并。
	ErrConflictNeedManual = errors.New("oidc: multiple dmwork users matched, manual link required")
	// ErrEmailNotVerified IdP 返回的邮箱未验证,且配置要求验证后才能自动绑定/创建。
	ErrEmailNotVerified = errors.New("oidc: email not verified by IdP")
)

业务错误。callback handler 会把这些错误翻成不同的 HTTP 状态/前端提示。

View Source
var ErrAlreadyRevoked = errors.New("oidc: refresh token already revoked")

ErrAlreadyRevoked 旧 RT 已被吊销;RotateRefresh 检测到并发竞争(另一 worker 抢先轮换)时返回

View Source
var ErrCallbackBlocked = errors.New("oidc callback rate limit exceeded")

ErrCallbackBlocked /callback 同 IP 失败次数过多,临时锁定。

View Source
var ErrReturnToRejected = errors.New("oidc: return_to rejected")

ErrReturnToRejected return_to 校验失败(空、非法 URL、host 不在白名单)

View Source
var ErrStateNotFound = errors.New("oidc: state not found or already consumed")

ErrStateNotFound state 已过期、被消费或从未存在

Functions

func NewPKCEPair

func NewPKCEPair() (verifier, challenge string, err error)

NewPKCEPair 按 RFC 7636 S256 生成 (code_verifier, code_challenge)

func NewRandomString

func NewRandomString(byteLen int) (string, error)

NewRandomString 生成 URL-safe Base64 编码的随机字符串(byteLen 字节熵)

func ValidateReturnTo

func ValidateReturnTo(raw string, allowedHosts []string) (string, error)

ValidateReturnTo 校验 callback 完成后跳转地址是否合法。

规则(防开放重定向):

  1. 空字符串 -> 视为合法,调用方应回退到默认页(返 "" 表示无跳转)
  2. 相对路径(以 / 开头,不含 //)-> 合法,host 由前端域名决定
  3. 绝对 URL -> 必须是 http/https,且 host 命中白名单

任何其他形态(scheme-less // 形式、javascript:、data: 等)一律拒绝。

Types

type AuditEvent

type AuditEvent string

AuditEvent 审计事件类型

const (
	EventAuthorize    AuditEvent = "authorize"
	EventCallbackOK   AuditEvent = "callback_ok"
	EventCallbackFail AuditEvent = "callback_fail"
	EventRefreshOK    AuditEvent = "refresh_ok"
	EventRefreshFail  AuditEvent = "refresh_fail"
	EventLogout       AuditEvent = "logout"

	// 自助绑定流程事件(P0,SR-6 审计完整性)。
	// 不改 oidc_audit_log schema —— 仍写同一张表,通过 event 列区分场景。
	EventBindIssued      AuditEvent = "bind_issued"
	EventBindVerifyOK    AuditEvent = "bind_verify_ok"
	EventBindVerifyFail  AuditEvent = "bind_verify_fail"
	EventBindOTPSend     AuditEvent = "bind_otp_send"
	EventBindOTPSendFail AuditEvent = "bind_otp_send_fail"
	EventBindConfirmOK   AuditEvent = "bind_confirm_ok"
	EventBindConfirmFail AuditEvent = "bind_confirm_fail"
	EventBindRefused     AuditEvent = "bind_refused"
	EventBindCreated     AuditEvent = "bind_created"
	EventBindCreateFail  AuditEvent = "bind_create_fail"
)

type AuditModel

type AuditModel struct {
	UID       string
	Event     AuditEvent
	IP        string
	UserAgent string
	Reason    string
	TraceID   string
	db.BaseModel
}

AuditModel 登录与状态同步审计

Event 用 AuditEvent 字符串别名,提供编译期类型安全防止误用任意字符串; dbr 通过反射读取底层 string,落库行为与裸 string 字段完全一致。

type BindAuthenticator added in v1.4.0

type BindAuthenticator interface {
	VerifyPasswordByUID(ctx context.Context, uid, password string) (matched bool, reason string, err error)
	SendOIDCBindSMS(ctx context.Context, zone, phone string) error
	VerifyOIDCBindSMS(ctx context.Context, zone, phone, code string) error
	// IsBindable Confirm 路径在 identity.Insert 前对 CandidateUID 做最后一次
	// 状态复核。详见 user.IService.IsBindable godoc。
	IsBindable(ctx context.Context, uid string) (bool, error)
}

BindAuthenticator OIDC 自助绑定流程对 user 模块的最小依赖接口。

生产路径下由 user.IService 直接实现(同 verificationUpserter 模式); 测试用 fake 注入断言入参。三方法签名与 user.IService 完全一致,这里 在 oidc 包内重新声明是为了:

  • 遵循 "Accept interfaces, return structs":小接口在使用方包内定义;
  • 让 bind_service_test.go 不必拉起整个 user.Service 就能跑契约测试。

type BindConfig added in v1.4.0

type BindConfig struct {
	Enabled         bool
	IssuerAllowlist []string
	TokenTTL        time.Duration
	VerifyMax       int64
	OTPSendMax      int64
	ConfirmMax      int64
	UIDFailPerDay   int64
	Methods         []BindMethod
	SupportContact  string
	RedirectBase    string
	AllowCreate     bool
}

BindConfig 自助绑定相关配置(NFR-4 全部走 env,不硬编码)。

**Enabled=false 时其余字段无效**:LoadConfig 不校验任何依赖关系, PR3 仅起骨架作用,callback 接管在 PR4 才打开。PR4 会加 "Enabled && RedirectBase==”" 这类硬校验。

keyspace 命名:OCTO_OIDC_BIND_* 与已有 DM_OIDC_* 并列,语义上 BindConfig 是 OIDC 模块的子配置块,但运行期可独立灰度(NFR-5)。

type BindConfirmResp added in v1.4.0

type BindConfirmResp struct {
	IssueResp *IssueSessionResp
	SD        *StateData
	UID       string
	Issuer    string
}

BindConfirmResp Confirm 返回给 handler 的完整快照。 handler 拿 IssueResp.LoginRespJSON 写 ThirdAuthcode,SD 用来回填原发起设备 的 authcode key(FR-6.3 跨设备流转)。

type BindCreateResp added in v1.4.0

type BindCreateResp struct {
	IssueResp *IssueSessionResp
	SD        *StateData
	UID       string
	Issuer    string
}

BindCreateResp Create 返给 handler 的完整快照,与 BindConfirmResp 对齐。

type BindInfoResp added in v1.4.0

type BindInfoResp struct {
	// MaskedEmail / MaskedPhone 协议约定恒在(无 omitempty):空字符串 "" 表示
	// 该 claim 不存在,前端按 truthy 分支隐藏对应行。omitempty 会让"无 email"
	// 路径整字段消失,迫使前端把"字段缺失"与"字段为空"当两种状态处理,
	// 也会触发前端 schema validator 抛出 "masked_email must be string"
	// 被通用错误处理误报为"网络异常"(GH#148)。
	MaskedEmail    string       `json:"masked_email"`
	MaskedPhone    string       `json:"masked_phone"`
	Name           string       `json:"name,omitempty"`
	Methods        []BindMethod `json:"methods"`
	SupportContact string       `json:"support_contact,omitempty"`
	AllowCreate    bool         `json:"allow_create"`
	// CreateBlocked 始终序列化(无 omitempty):/bind/info 协议约定该字段恒在,
	// 前端按字符串值分支("" / "disabled" / "claims_incomplete" /
	// "manual_conflict" / "consumed");omitempty 会让 "" 路径整字段消失,
	// 等价于前端要给"字段缺失"和"字段为空字符串"两种状态都写分支。
	CreateBlocked string `json:"create_blocked"`
}

BindInfoResp /info 端点返回给前端的脱敏身份信息(FR-2)。

不含 sub / issuer / claims 原值,避免社工攻击(SR-7)。

type BindLocator added in v1.4.0

type BindLocator interface {
	UIDByUsername(username string) (string, error)
	UIDsByPhone(zone, phone string) ([]string, error)
}

BindLocator 把用户输入(username)或 claims phone 解析到 dmwork uid。

多匹配场景按需扩展(P0 假定 username 全局唯一);UIDsByPhone 返回切片是因为 dmwork user 表的 (zone, phone) 不是强唯一约束 —— 历史脏数据可能有重复。 VerifySMS 路径多匹配 → ErrBindConflictNeedManual,走 P1 Admin 兜底。

type BindMethod added in v1.4.0

type BindMethod string

BindMethod 自助绑定流程允许的二次验证手段(需求 FR-3.1)。

类型化字符串便于:

  • 配置文件解析时做 allowlist 过滤(SR-3 禁用 email_otp)
  • 审计日志按手段维度聚合
  • 前端 /info 接口直接展示可用方法列表
const (
	// BindMethodPassword 账号密码二次验证。
	BindMethodPassword BindMethod = "password"
	// BindMethodSMSOTP 短信 OTP 二次验证。OTP 必须发到 OIDC claims 中
	// phone_number_verified=true 的手机号,不接受用户输入(FR-3.3)。
	BindMethodSMSOTP BindMethod = "sms_otp"
	// BindMethodEmailOTP **明确禁用**(SR-3)。若 IdP 返回的 email 与 dmwork
	// user.email 同值,邮箱 OTP 等价于"用 OIDC claim 自证身份",退化为单
	// 因子。常量保留是为了让"非法手段过滤"的代码路径可读 —— 见
	// loadBindMethods 中的显式 drop。
	BindMethodEmailOTP BindMethod = "email_otp"
)

type BindService added in v1.4.0

type BindService struct {
	// contains filtered or unexported fields
}

BindService 自助绑定状态机的业务逻辑层。

不持有 HTTP 上下文 / *wkhttp.Context;handler 层负责 HTTP 解析、CallbackGuard IP 限流、审计写入(events 在 model.go 已就位)。

identity / users 仅 Confirm 路径用到,在 Init() 检测到 Bind.Enabled=true 时统一注入(见 api.go:202-203)。其他 5 个方法(Issue/Info/Verify*/SendSMS) 不依赖这两个字段,单测可以不注入。

func (*BindService) Confirm added in v1.4.0

func (s *BindService) Confirm(ctx context.Context, jti string) (*BindConfirmResp, error)

Confirm 自助绑定终态写入。串行步骤(与下方代码一一对应):

  1. Get session(不消费)
  2. ConfirmMax counter +1(SR-2.1)—— 故意放在 status 检查之前 (asymmetry note,与 VerifyPassword/VerifySMS 顺序相反)
  3. 校验 Status == verified;否则拒绝
  4. 解出 claims/sd snapshot
  5. identity.Insert((uid, issuer, sub)) —— DB uk_issuer_subject + uk_uid_issuer 兜底 SR-5;duplicate-key → ErrBindAlreadyBound
  6. users.IssueSession 签发 dmwork 会话
  7. Consume session(SR-1 单次消费;Insert 已成功 + IssueSession 失败时**不** Consume,留给客户端重试,二次 Insert 撞唯一约束 → 409 引导走 OIDC 登录)

并发防护(AC-6):多个并发 confirm 同 jti,只有一个能拿到 status=verified 并写入 identity;其他要么撞 status conflict,要么撞 DB unique constraint。 都会返回明确错误,不会重复写。

计数器顺序的 asymmetry 说明:VerifyPassword/VerifySMS 是 status→counter (合法用户的 status 已经被推进就不该被攻击者再消耗 verify 配额);Confirm 是 counter→status(任何形式的"反复 confirm"都该烧 budget,包括 status 不对 的探测,否则攻击者可以零成本探测状态机位置)。

func (*BindService) Create added in v1.4.0

func (s *BindService) Create(ctx context.Context, jti string) (*BindCreateResp, error)

Create 在 status=issued 时用 bind_token 里的 claims 建号 + 写 identity + 签发会话。

顺序很关键(防 ghost user):

  1. Get session
  2. IncrAndCheck("bind:create:"+jti, bindCreateMax) — counter→status 与 Confirm 对齐, 任何对 create 的探测都消耗配额
  3. decode claims + 校验(纯只读,无副作用,失败保持 token 在 issued 让用户重试)
  4. CASSave issued→creating — 唯一的"锁":只有拿到锁的 goroutine 才能进入 产生副作用的 #5/#6/#7。并发 verify/create 都会撞 CAS 失败 → ErrBindStatusConflict, 不会出现"副作用已发生但终态锁不上"的 ghost-user 窗口。
  5. IssueSession({CreateUser:true, ...}) — 副作用 #1:dmwork user 入库
  6. identity.Insert((uid, issuer, sub)) — 副作用 #2:user_oidc_identity 入库
  7. Consume(token) — 终态:token 整条删除,后续 Get 返 ErrBindNotFound

中途失败语义:

  • #5/#6 失败:token 卡在 creating,5min TTL 后自然消失。**不会**被同一 token 的二次 create 重复触发(CAS issued→creating 第二次必败)—— 避免重试产生 第二个 ghost user。用户体感:需要重走 OIDC 登录拿新 bind_token。
  • #6 撞 uk_issuer_subject(并发不同 token 同 issuer+sub):返 ErrBindAlreadyBound, handler 引导用户重发起 OIDC 登录拾取赢家会话(与 plan §4 一致)。
  • #7 失败:用户已建,会话已发,token 5min TTL 自清,只 log warn。

func (*BindService) Info added in v1.4.0

func (s *BindService) Info(ctx context.Context, jti string) (*BindInfoResp, error)

Info 返回脱敏 claims + 可用方法 + create 能力状态。 可用方法 = 配置 Methods ∩ 当前 claims 支持的手段(claims 无 verified phone → 屏蔽 sms_otp,FR-3.3)。

func (*BindService) Issue added in v1.4.0

func (s *BindService) Issue(ctx context.Context, claims *IDTokenClaims, sd *StateData) (string, error)

Issue 在 callback ResolveOrLink 失败分支调用:签发 bind_token,持久化 claims + state_data 快照,返回 jti 供 handler 拼前端跳转 URL。

等价于 IssueWithReason(ctx, claims, sd, BindReasonUnknownUser) —— 历史调用方 (单元测试 + ShouldHandle 早期路径)在没有明确区分原因时保留旧签名,语义即 "claims 未命中已有账号"(可走 /bind/create)。生产 callback 路径应该走 IssueWithReason 明确传 reason,与 manual_conflict 拒建号路径协同。

不在此处写 audit —— handler 在拿到 jti 后统一写 EventBindIssued (handler 持 HTTP 上下文,IP/UA/trace_id 都齐)。

func (*BindService) IssueWithReason added in v1.4.0

func (s *BindService) IssueWithReason(ctx context.Context, claims *IDTokenClaims, sd *StateData, reason IssueReason) (string, error)

IssueWithReason 同 Issue,但额外把 reason 固化到 BindSession.IssueReason 字段。 callback 按 ResolveOrLink 返回的 err 类型选择 reason:ErrUnknownUser → BindReasonUnknownUser(可建号);ErrConflictNeedManual → BindReasonManualConflict (Create 路径拒绝)。详见 IssueReason godoc。

func (*BindService) SendSMS added in v1.4.0

func (s *BindService) SendSMS(ctx context.Context, jti string) error

SendSMS 走短信路径,zone/phone 从 claims snapshot 取(FR-3.3)。

限流:每 jti 维度的"OTP 发送"counter +1(SR-2.1, OTPSendMax 阈值,默认 3 次), 与底层 commonapi.SMSService 的 1min/手机号全局节流互补 —— 后者不带 codeType 跨流程串扰(详见 user.SendOIDCBindSMS godoc),所以 这里的 counter 是 bind_token 维度的真正反爆破。

func (*BindService) ShouldHandle added in v1.4.0

func (s *BindService) ShouldHandle(err error, claims *IDTokenClaims) bool

ShouldHandle 给 callback 失败分支用的接管判定。任何一条不满足就走旧路径, 行为完全保留(NFR-6 可回滚)。

func (*BindService) VerifyPassword added in v1.4.0

func (s *BindService) VerifyPassword(ctx context.Context, jti, identifier, password string) error

VerifyPassword 用户输入 (jti, identifier, password) → locator 解析 uid → auth.VerifyPasswordByUID → 推进状态机到 verified。

限流:每 jti 维度的"verify 尝试"counter +1(SR-2.1, VerifyMax 阈值),超返 ErrBindRateLimited。密码 / 短信共用同一 counter —— 需求文档 SR-2.1 说 "验证尝试 ≤ 5 次",不区分手段。

func (*BindService) VerifySMS added in v1.4.0

func (s *BindService) VerifySMS(ctx context.Context, jti, code string) error

VerifySMS 与 VerifyPassword 共用 verify counter。短信验证通过后推进到 verified, VerifiedMethod=sms_otp。CandidateUID 在短信路径上**留空** —— 短信不 直接确认 uid,confirm 阶段由 claims.phone → user 查询定位(P0 用 phone-only 路径时由 service.UIDsByPhone 走);多匹配场景走 P1 Admin。

type BindSession added in v1.4.0

type BindSession struct {
	JTI            string     `json:"jti"`
	Issuer         string     `json:"issuer"`
	Subject        string     `json:"sub"`
	CandidateUID   string     `json:"candidate_uid,omitempty"`
	ClaimsSnapshot []byte     `json:"claims"`
	SDSnapshot     []byte     `json:"sd"`
	Status         BindStatus `json:"status"`
	VerifiedMethod BindMethod `json:"verified_method,omitempty"`
	OriginIP       string     `json:"origin_ip,omitempty"`
	OriginUA       string     `json:"origin_ua,omitempty"`
	CreatedAt      int64      `json:"created_at"`
	// IssueReason 见 IssueReason godoc。Create 路径在 manual_conflict 时拒绝;
	// Info 路径用它回填 create_blocked 让前端展示对应的引导文案。
	// 旧 token(本字段引入前签发的)会落空字符串,Create 视同 unknown_user 放行,
	// 保持 5min TTL 窗口内的灰度兼容。
	IssueReason IssueReason `json:"issue_reason,omitempty"`
}

BindSession bind_token 在 Redis 里的完整快照。

字段说明:

  • JTI: bind_token 自身,32 字节 base64,Redis key 后缀(SR-7)
  • Issuer/Subject: OIDC claims 原值,confirm 时直接写 user_oidc_identity —— 用户在 5min TTL 内可能换 IdP session,这里固化避免漂移
  • CandidateUID: email/phone 多匹配场景下用户选定的 uid(M1 暂不支持选择, 字段预留)。当前实现下用户走密码路径需先输入 username/uid 定位
  • ClaimsSnapshot: 完整 IDTokenClaims JSON,confirm 时透传给 IssueSession
  • SDSnapshot: 原 StateData JSON 关键字段(authcode/return_to/device_flag), confirm 后回填到原发起设备的 ThirdAuthcode(FR-6.3)
  • Status: 状态机字段(见 BindStatus)
  • VerifiedMethod: 记录哪种手段通过的(audit 维度)
  • OriginIP/UA: 审计 + FR-6.2 设备差异提示
  • CreatedAt: 签发时 Unix 秒,前端展示 + TTL 兜底计算

JSON 序列化:tag 显式写小写,与 oidc 模块其他 *_redis 实现一致。

type BindStatus added in v1.4.0

type BindStatus string

BindStatus 自助绑定 bind_token 状态机。Redis 持久化,TTL 由 BindConfig.TokenTTL 控制。

合法迁移路径:

issued ─── verify ok ───▶ verified ─── confirm ok ───▶ Consume(token 消失)
  │                          │
  │                          └── confirm fail ──▶ verified (允许重试,直到 ConfirmMax)
  │
  ├── create CAS lock ───▶ creating ─── IssueSession+Insert+Consume ──▶ (token 消失)
  │                          │
  │                          └── 中途失败 ──▶ creating (stuck,TTL 自然清理)
  │
  └── 超限/手动放弃 ──▶ refused

CAS 由 BindStore.CASSave 保证。creating/refused 是中间或终止状态,任何对它们 的进一步推进都返 ErrBindStatusConflict。confirmed/created 不入 Redis —— 成功路径由 Consume 把 session 整条删除,后续 Get 拿到 ErrBindNotFound。

creating 引入的目的:把"建号副作用"(IssueSession 落库 + identity.Insert) 包在 CAS 锁后面,防止并发 verify/create 把已经在写真实用户的 token 推到其他 状态后再 saveCreated 撞 conflict —— 那种顺序会留下"ghost user"。

const (
	BindStatusIssued    BindStatus = "issued"
	BindStatusVerified  BindStatus = "verified"
	BindStatusConfirmed BindStatus = "confirmed"
	BindStatusRefused   BindStatus = "refused"
	// BindStatusCreating 介于 issued 与 Consume 之间的中间锁:CAS issued→creating
	// 成功后才允许进入 IssueSession / identity.Insert 等副作用阶段。失败留 creating,
	// 由 5min TTL 自然清理(用户需重走 OIDC 登录获取新 bind_token)。
	BindStatusCreating BindStatus = "creating"
)

type BindStore added in v1.4.0

type BindStore interface {
	// Save 写入新 session,TTL 由 cfg 控制(NFR-2 默认 5min)。
	// session.JTI 非空,Status 调用方指定(通常 BindStatusIssued)。
	//
	// **不做 CAS** —— Save 只在 Issue 阶段(首次写入)使用。状态机后续
	// 迁移必须走 CASSave,否则两个并发 verify 可同时观察到 status=issued,
	// 双 Save 后第二个覆盖第一个的 CandidateUID(身份接管)。
	Save(ctx context.Context, s *BindSession, ttl time.Duration) error

	// Get 读快照不消费。verify / info 路径用,允许多次读。
	// key 不存在返 ErrBindNotFound。
	Get(ctx context.Context, jti string) (*BindSession, error)

	// CASSave 原子地校验 Redis 里当前 sess.Status 与 expected 一致后,
	// 写入入参 sess 的完整 payload 并刷新 TTL。
	//
	// 与朴素 Save 的区别:在 Redis 单线程里完成 "check status → write payload",
	// 两个并发调用即便在客户端侧都读到了相同的旧快照,只有一个能成功 ——
	// 第二个会因 status 已被推进到新值而返 ErrBindStatusConflict。
	//
	// 调用方:bind_service.saveVerified(expected=issued),将来 verified→
	// confirmed 也走这条路径。key 不存在返 ErrBindNotFound。
	CASSave(ctx context.Context, sess *BindSession, expected BindStatus, ttl time.Duration) error

	// Consume 取出并立即删除,confirm 成功路径调(SR-1 单次消费)。
	// key 不存在返 ErrBindNotFound。
	Consume(ctx context.Context, jti string) (*BindSession, error)

	// IncrAndCheck 通用计数器 +1 → 与 limit 比较 → 超返 ErrBindRateLimited。
	// key 由调用方按维度拼装(如 "bind:verify:"+jti / "bind:uidfail:"+uid),
	// 避免 BindStore 持有维度知识。ttl 是首次 +1 设的窗口长度,后续 +1 沿用。
	//
	// 返回的 int64 是 +1 后的当前计数,供审计/告警按值定级。
	IncrAndCheck(ctx context.Context, key string, limit int64, ttl time.Duration) (int64, error)
}

BindStore bind_token 状态 + 限流计数器的 Redis 抽象。

与 StateStore 平行设计:生产用 Redis,测试用 memory 实现。两条实现必须 在 bind_store_test.go 的同一组表驱动测试下通过。

接口职责拆分(每个方法只做一件事):

  • Save / Get / Consume:bind_token 整体生命周期(签发 → 读取 → 终态消费)
  • CASSave:状态机 CAS 迁移 —— 期望 Status 匹配时原子写入整段 sess
  • IncrAndCheck:任意维度的限流计数器(SR-2.1 bind_token 维度 + SR-2.2 uid 维度)

一次性消费(SR-1)由 Consume 保证 —— 取出立即 DEL,即便后续 confirm 失败 也不可重放。状态机 CAS 是次要防护层(防 verified→confirmed 并发重入, 以及 issued→verified 并发 verify 覆盖 CandidateUID 的身份接管)。

type CallbackGuard

type CallbackGuard struct {
	// contains filtered or unexported fields
}

CallbackGuard 按客户端 IP 给 OIDC /callback 限流。

与 user.LoginGuard 同形:Redis INCR + EXPIRE 滑动窗口。

维度选 IP 而非 account/uid,因为 callback 阶段尚未拿到 IdP claims;state 32 字节 随机不可猜,合法用户失败率 ≈ 0,失败计数低门槛(默认 10 / 5min)对正常用户 极宽松,对扫描类攻击足够紧。Redis 不可用 fail-open。

一致性:INCR + EXPIRE 非原子,首次 Incr 成功而 Expire 失败会让 key 永不过期 → IP 永久锁定。RecordFailure 每次都重设 TTL,后续失败修复缺失的 TTL。代价是 滑动窗口语义(攻击者持续尝试时窗口续期),对防扫描更严格,符合安全预期。

func NewCallbackGuard

func NewCallbackGuard(r *redis.Conn, threshold int64, window time.Duration) *CallbackGuard

NewCallbackGuard 构造 CallbackGuard。threshold/window <=0 时回落默认值。

func (*CallbackGuard) Check

func (g *CallbackGuard) Check(ip string) error

Check 失败计数已达阈值返回 ErrCallbackBlocked,否则放行。空 IP 视为无效标识直接放行。

fail-open:Redis 抖动时不锁,与 LoginGuard 一致。基础设施层网关 cap 兜底 DoS。

nil receiver 安全:测试/配置 disabled 时直接放行,避免 callee 各自加 nil 判断。

func (*CallbackGuard) RecordFailure

func (g *CallbackGuard) RecordFailure(ip string) error

RecordFailure 失败 +1,每次重设 TTL(滑动窗口;详见 CallbackGuard 文档)。 nil receiver 安全。

func (*CallbackGuard) RecordFailureLogged

func (g *CallbackGuard) RecordFailureLogged(ip string)

RecordFailureLogged 包装 RecordFailure,失败仅 warn 不向上扩散。

func (*CallbackGuard) Reset

func (g *CallbackGuard) Reset(ip string) error

Reset 删除 IP 的失败计数(成功路径调用,清场避免长尾误锁)。 nil receiver 安全(测试 / disabled 路径)。

func (*CallbackGuard) ResetLogged

func (g *CallbackGuard) ResetLogged(ip string)

ResetLogged 包装 Reset,失败仅 warn。

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client 封装 go-oidc Provider + oauth2.Config。

接口设计:Client 不持任何请求级状态(state / nonce / verifier),由 service 层管理; Client 只做 Discovery / 构造授权 URL / 换 token / 验签 / 拉 userinfo / 刷新这几件事。

func NewClient

func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error)

NewClient 走 Discovery 拉 issuer metadata,构造 Client。

func (*Client) AuthCodeURL

func (c *Client) AuthCodeURL(state, nonce, codeChallenge string) (string, error)

AuthCodeURL 构造带 PKCE(S256) + state + nonce 的授权 URL。

调用方负责生成并持久化 state / nonce / code_verifier(写 StateStore), 这里只把 code_challenge 透传给 IdP。verifier 永远不上 URL。

func (*Client) EndSessionEndpoint added in v1.5.1

func (c *Client) EndSessionEndpoint() string

EndSessionEndpoint 返回 Discovery 暴露的 RP-Initiated Logout 端点; IdP 未声明时返回空字符串。

func (*Client) Exchange

func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error)

Exchange 用 authorization_code + code_verifier 换 token。

PKCE 的 code_verifier 通过 oauth2 的 SetAuthURLParam 传给 IdP;调用方需 确保和 AuthCodeURL 阶段的 challenge 是同一对(从 StateStore 取出)。

func (*Client) Issuer

func (c *Client) Issuer() string

Issuer 返回 issuer URL(便于断言/审计)。

func (*Client) Refresh

func (c *Client) Refresh(ctx context.Context, refreshToken string) (*oauth2.Token, error)

Refresh 用 refresh_token 换新 token。RT 轮换语义由 IdP 决定, 调用方拿到新 token 后应替换 DB 中的密文 RT(rotate)。

失败语义:

  • "invalid_grant" 等价于 RT 失效(被吊销 / 用户登出 / 自然过期), 调用方应把对应 identity 标记吊销,前端引导重新登录。
  • 其他错误(网络 / 5xx)调用方可重试。

func (*Client) UserInfo

func (c *Client) UserInfo(ctx context.Context, tok *oauth2.Token) (*UserInfoClaims, error)

UserInfo 用 oauth2.Token 拉 /userinfo claims。

go-oidc 的 UserInfo 内部会校验 sub 与 ID Token 的 sub 一致性(若 token 含 ID Token); 这里只暴露 claims,sub 校验由调用方决定信不信任。

func (*Client) VerifyIDToken

func (c *Client) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDTokenClaims, error)

VerifyIDToken 用 issuer JWKS 验签 ID Token,并把 claims 解码到 IDTokenClaims。

不在此处校验 nonce(由 service 层对 StateStore 中的 nonce 比较), 这里只做 RFC 7519 / OIDC Core §3.1.3.7 的签名 + iss + aud + exp 校验(go-oidc 默认行为)。

type ClientConfig

type ClientConfig struct {
	Issuer       string
	ClientID     string
	ClientSecret string
	RedirectURI  string
	Scopes       []string

	HTTPTimeout time.Duration
	ClockSkew   time.Duration
}

ClientConfig OIDC Client 构造参数。从 ProviderConfig 派生,只保留 Client 自身需要的字段。

type Config

type Config struct {
	Enabled  bool
	Provider ProviderConfig
	// Bind 自助绑定子配置(P0)。Bind.Enabled 独立于 Config.Enabled,允许
	// "OIDC 主流程开但 bind 灰度未开" 的中间态(NFR-5)。
	Bind BindConfig
}

Config OIDC 模块完整配置

func LoadConfig

func LoadConfig() (*Config, error)

LoadConfig 从环境变量加载 OIDC 配置

Enabled=false 时不校验 provider 字段,允许编译期配置但运行期关闭。 dmwork-lib 暂未支持 OIDC 配置块,因此走环境变量;后续 dmwork-lib 加完字段 再迁移到 YAML,接口签名保持稳定即可。

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB OIDC 模块数据访问层

func NewDB

func NewDB(ctx *config.Context) *DB

NewDB 构造 DB

func (*DB) DueRefreshes

func (d *DB) DueRefreshes(limit int) ([]*DueRefresh, error)

DueRefreshes 拉一批待刷新 RT,JOIN identity 把 uid 一并取出 —— invalid_grant 时 worker 直接拿 uid 调踢线,不需要再查一次 identity 表。

排序:最久未刷新的优先(`COALESCE(last_refreshed_at, created_at)`)。

索引提示:`COALESCE(...)` 上无 B-tree 索引,filesort 在小数据量(P0 上线初期 预计 < 1k active RT)可接受。RT 量明显增长(>10k)后,按需:

  1. 加复合索引 `(revoked_at, last_refreshed_at, created_at)` 让 WHERE + ORDER BY 局部覆盖;或
  2. 改成应用层维护"下次该刷新时间"列(避免 COALESCE),用单列索引兜住。

JOIN 到 identity 走主键,负担可忽略。

func (*DB) InsertAudit

func (d *DB) InsertAudit(m *AuditModel) error

InsertAudit 写入审计日志

func (*DB) InsertIdentity

func (d *DB) InsertIdentity(m *IdentityModel) error

InsertIdentity 新增绑定关系

LinkedAt 为零值时主动填上当前时间。util.AttrToUnderscore 会把所有字段塞进 Columns,Go 的 time.Time 零值是 0001-01-01,会覆盖 SQL 的 CURRENT_TIMESTAMP 默认值 — 这里显式补齐才能拿到有意义的时间戳。

func (*DB) InsertRefresh

func (d *DB) InsertRefresh(m *RefreshModel) error

InsertRefresh 新增 RT

func (*DB) MarkRefreshRevoked

func (d *DB) MarkRefreshRevoked(id int64) (int64, error)

MarkRefreshRevoked 标记吊销,返回真正改动的行数(0 表示已被其他 worker 抢先吊销)。

幂等语义保留:对已吊销的 id 再次调用不报错。返回 rowsAffected 是为了让多实例 SyncWorker 区分"我刚把 RT 吊销了"(=1)与"别人抢先一步"(=0)—— 后者表示 当前 invalid_grant 只是 IdP 端 RT 旋转后的副产品(其他实例已成功 rotate), 不应踢用户,否则会出现"两实例同时跑就乱踢"的假阳性。

func (*DB) QueryIdentitiesByEmail

func (d *DB) QueryIdentitiesByEmail(issuer, email string) ([]*IdentityModel, error)

QueryIdentitiesByEmail 通过邮箱查询(用于自动绑定时检测冲突)

func (*DB) QueryIdentitiesByUID

func (d *DB) QueryIdentitiesByUID(uid string) ([]*IdentityModel, error)

QueryIdentitiesByUID 查询某个 UID 已绑定的所有第三方身份

func (*DB) QueryIdentityByIssuerSubject

func (d *DB) QueryIdentityByIssuerSubject(issuer, subject string) (*IdentityModel, error)

QueryIdentityByIssuerSubject 通过 (issuer, sub) 查询绑定关系

未命中返回 (nil, nil),与项目其他模块的单条查询语义一致。 调用方通过 m == nil && err == nil 判定"记录不存在"。

func (*DB) QueryRefreshByHash

func (d *DB) QueryRefreshByHash(hash string) (*RefreshModel, error)

QueryRefreshByHash 通过 token_hash 查询(命中即代表本条 RT 仍有效)

未命中返回 (nil, nil),调用方通过 m == nil && err == nil 判定"记录不存在"。

func (*DB) QueryUIDsByEmail

func (d *DB) QueryUIDsByEmail(email string) ([]string, error)

QueryUIDsByEmail 按邮箱查 dmwork user 表的 uid 列表(用于自动绑定时检测多匹配冲突)。

过滤条件:

  • is_destroy=0:排除冷静期(1)和已注销(2),与 user.VerifyPasswordByUID 的 IsDestroyDone 检查同源
  • status<>0:排除被运维封禁/停用的账号。autolink 命中停用 uid 会让 IssueSession 在终态拒绝,但 OIDC bind 路径会在 confirm 时已经写完 user_oidc_identity, 残留脏数据让该用户后续 OIDC 登录持续失败

TODO: user.IService 后续应暴露 QueryUIDsByEmail,届时本方法迁移到 user 模块, oidc 改走接口避免跨模块 SQL 耦合。

func (*DB) QueryUIDsByPhone

func (d *DB) QueryUIDsByPhone(zone, phone string) ([]string, error)

QueryUIDsByPhone 按手机号查 dmwork user 表的 uid 列表(同 QueryUIDsByEmail)。 同样过滤 is_destroy=0 AND status<>0 —— 见 QueryUIDsByEmail godoc。

func (*DB) RevokeRefreshByUID

func (d *DB) RevokeRefreshByUID(uid string) (int64, error)

RevokeRefreshByUID 把 uid 名下所有未吊销 RT 标记吊销(logout 用)。

两步走是因为 dbr 的 Update builder 不支持 JOIN;先查 identity_id 再批量 IN。 没有 identity 行时直接返回 0,不触发空 IN(...) 子句。

func (*DB) RotateRefresh

func (d *DB) RotateRefresh(oldID int64, newRT *RefreshModel) error

RotateRefresh 用新 RT 替换旧 RT(成功刷新后调用)

旧 RT 的 revoke 走 "WHERE id=? AND revoked_at IS NULL" + RowsAffected 检查, 在并发场景下另一 worker 已轮换过该 RT 时返回 ErrAlreadyRevoked,避免重复轮换。

func (*DB) UpdateIdentityLogin

func (d *DB) UpdateIdentityLogin(id int64, email string, emailVerified int, phone string, phoneVerified int) error

UpdateIdentityLogin 更新最近登录时间与最新 claims 字段

type DueRefresh

type DueRefresh struct {
	ID              int64
	IdentityID      int64
	UID             string
	Subject         string
	TokenCiphertext []byte
	ExpiresAt       time.Time
}

DueRefresh 待刷新 RT 与所属 identity 的 uid 联合查询结果。

uid 必须随 RT 一起取出,否则 invalid_grant 时无法定位要踢谁。

Subject(YUJ-409 Round 2):identity 持有的 OIDC sub,用于 rotate 后调 /userinfo 时做 ownership 校验 —— 防 RT 串台 / IdP 返回错 subject 把别人 的实名 claims 写到本 uid。callback 路径已有同类校验(modules/oidc/api.go:479)。

type Encryptor

type Encryptor struct {
	// contains filtered or unexported fields
}

Encryptor 提供 refresh_token 的对称加密 + 抗预映射哈希

加密:AES-256-GCM,密文格式 nonce(12B) || ciphertext || tag(16B) 哈希:HMAC-SHA256,密钥从主密钥派生(域分离),输出 64 字符小写 hex

func NewEncryptor

func NewEncryptor(key []byte) (*Encryptor, error)

NewEncryptor 用 32 字节主密钥构造 Encryptor

func (*Encryptor) Decrypt

func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error)

Decrypt 解密 Encrypt 产生的密文,认证失败返回错误

显式拒绝 < nonce + GCM tag 的密文(payload 至少 0 字节,但 tag 占 16B 不能少), 避免对 GCM 内部行为的隐式依赖,错误信息也更明确。

func (*Encryptor) Encrypt

func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error)

Encrypt 加密明文,每次返回带随机 nonce 的密文

func (*Encryptor) HashToken

func (e *Encryptor) HashToken(token string) string

HashToken 用派生密钥对 token 做 HMAC-SHA256,作为 user_oidc_refresh.token_hash

相比裸 SHA256,HMAC 让 DB 行单独泄漏时无法离线穷举 token —— 攻击者还需要拿到主密钥。

type IDTokenClaims

type IDTokenClaims struct {
	Issuer  string `json:"iss"`
	Subject string `json:"sub"`
	// 不解 aud:OIDC Core §2 允许 aud 是 string 或 []string,go-oidc 的 Verify
	// 已经做了完整 aud 校验,业务层不需要这个字段。曾经写成 `string` 接,Keycloak /
	// Auth0 多 audience 场景返回 ["client-id"] 时 json.Unmarshal 会 TypeError
	// 直接挂掉所有登录 — 删字段比加自定义 unmarshal 简单。
	Email         string `json:"email"`
	EmailVerified bool   `json:"email_verified"`
	PhoneNumber   string `json:"phone_number"`
	PhoneVerified bool   `json:"phone_number_verified"`
	Name          string `json:"name"`
	Nonce         string `json:"nonce"`
	IssuedAt      int64  `json:"iat"`
	Expiry        int64  `json:"exp"`

	// Aegis identity_verification scope claims(YUJ-382 / Aegis OIDC Phase 1)。
	//
	// 类型按 POC 实测 + wire-format 兜底:Aegis 对接文档把 is_verified / verified_at 写成 string,
	// 实际 token payload 里 is_verified 是 JSON bool、verified_at 是 Unix 秒(number)。
	// 任何一端改了 wire 类型(比如切 Keycloak/Auth0,或 Aegis 管理后台把字段重落 string)
	// 就直接全站登录失败(json.Unmarshal TypeError),aud 字段历史已经踩过一次坑。
	// 这里用 IsVerifiedClaim / VerifiedAtClaim 自定义 UnmarshalJSON 兜底 bool/number/string。
	//
	// 语义:
	//   - IsVerified=true + LegalName != "" 视为"该 IdP 返回了一次有效实名结果",
	//     dmworkim 侧据此 upsert user_verification 表。
	//   - VerifiedProvider 是具体来源域名(如 "cas.example.com"),strip 到一级
	//     (cas/wecom/feishu)再写库 + allowlist 校验,防 Aegis 返回意外值污染 source。
	//   - LegalEmail 允许空,LegalName 必填(空字符串视为未实名,不写库)。
	IsVerified       IsVerifiedClaim `json:"is_verified"`
	VerifiedAt       VerifiedAtClaim `json:"verified_at"`
	VerifiedProvider string          `json:"verified_provider"`
	LegalName        string          `json:"legal_name"`
	LegalEmail       string          `json:"legal_email"`
}

IDTokenClaims 解析后的 ID Token 标准 claims + 业务关心字段。

不暴露原始 Token 对象,避免上层耦合 go-oidc 类型。

type IdentityModel

type IdentityModel struct {
	UID           string
	Issuer        string
	Subject       string
	Email         string
	EmailVerified int
	Phone         string
	PhoneVerified int
	LinkedAt      time.Time
	LastLoginAt   *time.Time
	db.BaseModel
}

IdentityModel 第三方 OIDC 身份与本地 UID 的绑定关系

type IsVerifiedClaim

type IsVerifiedClaim bool

IsVerifiedClaim 兜底 is_verified 的 bool / number / string 三种序列化形态。

背景:Aegis 当前实测把 is_verified 返成 JSON bool,但对接文档又写成 string ("true" / "false")。Keycloak / Auth0 / Okta 等 IdP 的自定义 claim 写法也普遍 按厂商管控面下发成 string(管理后台手动填值,UI 落 JSON 时加了引号)。

aud 字段历史踩过一次类似坑(Verify 阶段 json.Unmarshal TypeError 直接挂掉所有登录, 见 IDTokenClaims 的 aud 注释)。为防下次 IdP 把 wire 类型改了就全站登录失败, is_verified 用 Custom Unmarshal 接 bool / number / string / null。

语义映射:

  • JSON bool true / false → true / false
  • JSON number 0/1 及其他 → 非零为 true
  • JSON string "true" / "1" / "yes"(大小写/空白不敏感) → true
  • 其他字符串 / null / 缺字段 → false(保守,等价于 "未实名")
  • 类型完全无法识别时保留 error,落到 decode 阶段 → 登录失败但会带可读错误, 而不是整个 Unmarshal 静默吞掉其他字段

func (IsVerifiedClaim) Bool

func (c IsVerifiedClaim) Bool() bool

Bool 返回内部 bool 值,用于和 Go 原生 bool 互转(比如写 user 模块时)。

func (*IsVerifiedClaim) UnmarshalJSON

func (c *IsVerifiedClaim) UnmarshalJSON(data []byte) error

type IssueReason added in v1.4.0

type IssueReason string

IssueReason 描述 bind_token 是从哪种 callback 失败分支签发出来的。 Create 路径依赖此字段决定是否允许走"自助建号":多账号脏数据(manual_conflict) 来源的 token 不应当被允许凭 claims 建号 —— 用户在 dmwork 已经有(多个)账号, 走 /bind/create 会再造出新账号加剧数据混乱;只能走 P1 Admin 人工合并。

字段在 BindSession 中持久化,Info 路径用来回填 create_blocked,Create 路径 用来拒绝建号。两个 Issue 入口(callback 的 ResolveOrLink 失败分支)按 err 类型显式置位,避免 oidc 模块的状态隐式扩散。

const (
	// BindReasonUnknownUser claims 未命中已有 dmwork 账号(AllowNewUser=false
	// 或 autolink 未命中)。这是"可以走 /bind/create 自助建号"的合法来源。
	BindReasonUnknownUser IssueReason = "unknown_user"
	// BindReasonManualConflict claims email/phone 命中**多条** dmwork 账号(脏数据)。
	// 不允许走 /bind/create:重复建号会加剧数据混乱;只能走 Admin 人工合并(P1)。
	BindReasonManualConflict IssueReason = "manual_conflict"
)

type IssueSessionReq

type IssueSessionReq struct {
	UID        string
	CreateUser bool
	Name       string
	Email      string
	Phone      string
	Zone       string
	DeviceFlag uint8
	DeviceID   string
	DeviceName string
	DeviceMod  string
	PublicIP   string

	// TrustedSSOCreate 透传到 user.ExternalLoginReq.TrustedSSOCreate,
	// 让可信 IdP 触发的新建用户路径绕过 user 模块的 register.off 全局开关。
	//
	// 仅在 CreateUser=true 时有意义。callback `res.IsNew=true` 与 /bind/create
	// 两条入口显式置 true(代表 IssuerAllowlist 已经过),其他路径(verify→confirm
	// 绑定老用户)留 false —— 与 CreateUser 本身的语义对齐:不建用户的请求不该
	// 表达"信任创建"语义。
	TrustedSSOCreate bool
}

IssueSessionReq 把 ResolveOrLink 的输出 + 请求级设备信息透给 IssueSession。

字段命名与 user.ExternalLoginReq 平行,但保持独立类型避免 oidc 直接依赖 user 类型。

type IssueSessionResp

type IssueSessionResp struct {
	UID           string
	IsNewUser     bool
	LoginRespJSON string
}

IssueSessionResp 会话签发结果。LoginRespJSON 直接塞 ThirdAuthcode Redis, 前端短码轮询取走;调用方不解析其内容。

type MockProvider

type MockProvider struct {
	Server   *httptest.Server
	Issuer   string
	ClientID string
	// contains filtered or unexported fields
}

MockProvider 自签 RSA 的 httptest OIDC server。

端点按测试需要增量挂载。字段全部线程安全,测试可在多 goroutine 中 PrepXxx。

设计原则:mock 只覆盖被测代码路径会触达的最小协议子集,不追求 RFC 完备。

func NewMockProvider

func NewMockProvider(t *testing.T) *MockProvider

NewMockProvider 启动 httptest server,t.Cleanup 自动 Close。

默认 ClientID="test-client" / ClientSecret="test-secret",与 newTestClient 对齐。

func (*MockProvider) ForceUserInfoStatus

func (m *MockProvider) ForceUserInfoStatus(code int)

ForceUserInfoStatus 让 /userinfo 直接返指定 HTTP 状态(>=400),用于测试 IdP 抖动。 设为 0 恢复正常行为。

func (*MockProvider) PrepCode

func (m *MockProvider) PrepCode(code, sub, nonce string)

PrepCode 预置 authorization_code → sub + nonce(可空)。

nonce 模拟 IdP 在 /authorize 阶段记录的 nonce,后续签 ID Token 时回填。

func (*MockProvider) PrepUser

func (m *MockProvider) PrepUser(sub string, claims map[string]interface{})

PrepUser 预置 sub → claims 映射。重复 sub 后写覆盖前写。

func (*MockProvider) PrepUserInfoOnly

func (m *MockProvider) PrepUserInfoOnly(sub string, claims map[string]interface{})

PrepUserInfoOnly 预置仅在 /userinfo 暴露的 claims(不入 ID Token)。 模拟 Aegis 等 IdP 把 email/phone 放 /userinfo 而非 ID Token 的行为。

type OIDC

type OIDC struct {
	log.Log
	// contains filtered or unexported fields
}

OIDC OIDC 登录模块。

字段全部包内可见:测试在 New 后可替换 stateStore / authcode 为内存实现。

func New

func New(ctx *config.Context) *OIDC

New 构造 OIDC 模块(生产路径)。

Enabled=false 时只挂 Route 占位,handler 一律返回 404,避免漏配置时静默通过。 Discovery 失败不阻塞启动,handler 自检后返回 500,跟进运维告警即可。

func (*OIDC) Close

func (o *OIDC) Close() error

Close 释放 OIDC 模块持有的资源(redisStateStore 连接池 + SyncWorker goroutine)。

注册到 register.Module.Stop,框架优雅退出时调用。可被多次调用(幂等):

  • New() 内 Discovery 失败会调一次清理 stateStore
  • 之后 framework shutdown 又会调一次,此时 stateStore 已 nil,早返回

func (*OIDC) Init

func (o *OIDC) Init() error

Init 在所有模块初始化完成后调用(register.Module.Start), 此时 user 模块的 IService 已通过 register.GetService 可用。

func (*OIDC) Route

func (o *OIDC) Route(r *wkhttp.WKHttp)

Route 路由注册。Enabled=false 时所有端点返回 404,避免漏配置静默通过。

路径段从 cfg.Provider.ID 取,默认 "oidc";老前端硬编码的 "/aegis" 路径在 ID 不为 "aegis" 时同时挂载作为兼容入口,迁移完成后删除 legacyProviderPathID 即可。

authorize/callback 是公开端点(IdP 重定向到 callback 时不带 dmwork token); logout 必须 AuthMiddleware 校验后拿 uid 才能踢线 + 吊销 RT,所以单独分组。

type ProviderConfig

type ProviderConfig struct {
	// ID/Name 标识本 provider, 用于路由路径段、审计日志、appconfig 下发给前端做按钮文案与跳转。
	// 未配置时分别默认 "oidc" / "SSO", 保证基础部署不强制运维填这两个字段。
	ID   string
	Name string

	Issuer       string
	ClientID     string
	ClientSecret string
	RedirectURI  string
	Scopes       []string

	RequireEmailVerified bool
	RequirePKCE          bool
	AutoLinkByEmail      bool
	// AutoLinkByPhone phone_number_verified=true 时按手机号自动绑历史账号。
	// 单独开关因为部分场景里"邮箱可信"但"手机号未必",分开控制更精细。
	AutoLinkByPhone bool
	AllowNewUser    bool

	ClockSkew   time.Duration
	HTTPTimeout time.Duration

	SyncInterval    time.Duration
	SyncConcurrency int

	// AES-256-GCM 主密钥,用于加密 refresh_token,从 base64 字符串解码
	RefreshTokenEncryptionKey []byte

	// ReturnToHosts callback 完成后允许的 return_to 跳转 host 白名单
	// (DM_OIDC_RETURN_TO_HOSTS,逗号分隔)。空列表表示禁用 return_to,
	// 防开放重定向是 P1.2 必须做的硬约束。
	ReturnToHosts []string

	// PostLogoutRedirectURI logout 成功后让 IdP 回跳的地址(写死的登录页)。
	// 空时 logout 不生成 end_session_url,前端退回"仅清本地"。安全考量:此值由
	// 运维写死、不接受前端传入,因此无需在服务端再做 redirect 白名单 —— 单值即白名单。
	// 上线前需在 IdP 侧注册该回跳地址。
	PostLogoutRedirectURI string

	// EndSessionURL 覆盖/兜底 IdP 的 end_session 端点。优先级高于 Discovery 解析值,
	// 仅在 Discovery 未暴露 end_session_endpoint 时才需要配置。
	EndSessionURL string

	// IDTokenTTL callback 成功后缓存 id_token(供 logout 当 id_token_hint)的 TTL。
	// 默认对齐 RT 生命周期(7 天 = 168h),覆盖用户登录后较长时间才登出的场景。
	// 注意 env 值用 time.ParseDuration 解析,只认 h/m/s —— 写 "7d" 会解析失败并静默
	// 回落默认,要 7 天请填 "168h"。
	IDTokenTTL time.Duration
}

ProviderConfig 单个 OIDC Provider 配置

type RedisTickLock

type RedisTickLock struct {
	// contains filtered or unexported fields
}

RedisTickLock 用 Redis SET NX EX + Lua CAS-DEL 实现 tickLock。

持有独立 *redis.Client 与 redisStateStore 同样的理由(详见该文件): dmwork-lib 的 Redis wrapper 不暴露 Eval/SETNX 等高级原语。

func (*RedisTickLock) Acquire

func (l *RedisTickLock) Acquire(_ context.Context, key, token string, ttl time.Duration) (bool, error)

Acquire 用 SET NX EX 原子抢锁。token 后续 Release 校验用,必须每次新生成。

返回 (true, nil)=抢到, (false, nil)=别人持锁(正常竞争), (_, err)=Redis 故障。

func (*RedisTickLock) Close

func (l *RedisTickLock) Close() error

Close 释放底层连接池。

func (*RedisTickLock) Release

func (l *RedisTickLock) Release(_ context.Context, key, token string) (bool, error)

Release 走 Lua CAS-DEL,只在 token 匹配时释放。

返回 (true, nil)=成功释放, (false, nil)=token 不匹配 / key 已过期(都视为 正常,不报错), (_, err)=Redis 故障。

type RefreshModel

type RefreshModel struct {
	IdentityID      int64
	TokenHash       string
	TokenCiphertext []byte
	ExpiresAt       time.Time
	LastRefreshedAt *time.Time
	RevokedAt       *time.Time
	db.BaseModel
}

RefreshModel OIDC refresh_token 的加密存储,用于后台状态同步轮询

type RefreshResult

type RefreshResult struct {
	AccessToken  string
	RefreshToken string
	ExpiresAt    time.Time
}

RefreshResult Refresh 成功返回的最小子集。

AccessToken 用于 YUJ-405:rotate 成功后立刻用新 access_token 调 /userinfo 同步实名 claims。旧调用路径(单纯 RT 轮转)忽略 AccessToken 即可。

type ResolveResult

type ResolveResult struct {
	UID   string
	IsNew bool
}

ResolveResult ResolveOrLink 的结果(UID + 是否新创建)。

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service OIDC 业务编排层。

职责:

  1. ResolveOrLink — 把 IdP claims 解析为 dmwork uid(必要时建账或绑定历史账号)
  2. IssueSession — 调 user.IService.LoginByExternalIdentity 签发会话

func (*Service) IssueSession

func (s *Service) IssueSession(ctx context.Context, req IssueSessionReq) (*IssueSessionResp, error)

IssueSession 委托给 user.IService.LoginByExternalIdentity 签发会话。

只是个薄壳:负责把 oidc IssueSessionReq 透传到 userLookup,真正逻辑(token / IM token / 创建用户)在 user 模块。返回的 LoginRespJSON 由 callback 直接落 ThirdAuthcode Redis,前端短码轮询取走。

func (s *Service) ResolveOrLink(ctx context.Context, claims *IDTokenClaims) (*ResolveResult, error)

ResolveOrLink Issue #1120 的历史账号绑定矩阵。

规则(按顺序短路):

  • 1. (issuer, sub) 命中已绑定 identity 行 → 返回原 UID(场景 3:重复登录)

  • 2. AutoLinkByEmail=true && claims.email_verified: a. user.email 命中 1 条 → 写绑定 → 返回该 UID(场景 3:首次绑历史账号) b. user.email 命中多条 → ErrConflictNeedManual(场景 4:脏数据冲突) c. 未命中 → 走 step 3

  • 3. user.phone 同上(命中 1 / 多条 / 未命中)

  • 4. AllowNewUser=true → 不写本地 user 表,只返回 IsNew=true 让 IssueSession 创建 (避免 service 层直接持 user.IService 写权限,职责更清)

  • 5. AllowNewUser=false → ErrUnknownUser

返回 IsNew=true 时 UID 为空,由 IssueSession 通过 user.IService 创建并回填。

type StateData

type StateData struct {
	Provider     string    `json:"provider"`
	CodeVerifier string    `json:"code_verifier"`
	Nonce        string    `json:"nonce"`
	IP           string    `json:"ip"`
	UserAgent    string    `json:"user_agent"`
	ReturnTo     string    `json:"return_to"`
	CreatedAt    time.Time `json:"created_at"`

	// ClientAuthcode 前端在 /authorize 阶段传入的短码,callback 完成后用作 ThirdAuthcode
	// Redis key 的后半段,前端按 GitHub 模式短码轮询取 LoginRespJSON。
	ClientAuthcode string `json:"client_authcode,omitempty"`
	// DeviceFlag 调用方期望的设备类型,透传给 IssueSession。
	// 取值与 octo-lib config.DeviceFlag 一致(iota 顺序):0=APP / 1=Web / 2=PC。
	DeviceFlag uint8 `json:"device_flag,omitempty"`
}

StateData OIDC authorize → callback 之间的临时状态(/authorize 时写入,/callback 时一次性消费)

type StateStore

type StateStore interface {
	Save(ctx context.Context, state string, data *StateData, ttl time.Duration) error
	// Consume 取出并删除,实现 CSRF 防护的一次性语义
	Consume(ctx context.Context, state string) (*StateData, error)
}

StateStore 状态存储抽象,生产用 Redis,测试用内存

type SyncWorker

type SyncWorker struct {
	log.Log
	// contains filtered or unexported fields
}

SyncWorker 周期性 refresh active RT,失败即吊销 + 踢线 + 审计。

func NewSyncWorker

func NewSyncWorker(cfg SyncWorkerConfig, store syncStore, enc *Encryptor,
	rfsh refresher, killer sessionKiller, audit auditWriter, lock tickLock) *SyncWorker

NewSyncWorker 构造 worker,并发 / 批大小 / 锁 TTL 有兜底默认值。

lock 传 nil 时退化为"每实例独立 tick" —— rowsAffected 竞态检测仍能保证 不出假阳性踢线,只是 IdP 流量翻 N 倍。生产部署建议注入 RedisTickLock。

ui / verif 可选(nil 时 sync_worker 跳过实名同步,保持 YUJ-405 前的原行为)。

func (*SyncWorker) RunOnce

func (w *SyncWorker) RunOnce(ctx context.Context) error

RunOnce 执行一次同步:拉一批 → 受 Concurrency 限的并发处理。

多实例部署:抢到 tick lock 才跑,抢不到直接返回 nil(本 tick 由别人跑)。 lock 不可用时降级为"每实例独立跑",依赖 rowsAffected 竞态检测兜底。

ctx 取消时已派发的 goroutine 仍会跑完当前 RT(processOne 内部 best-effort), 但不再派发新任务。返回 ctx.Err() 让上层日志可识别 "正常停机" vs "异常失败"。

func (*SyncWorker) Start

func (w *SyncWorker) Start(ctx context.Context)

Start 启动后台 ticker goroutine。Interval ≤ 0 视为禁用。

真幂等:重复调用会先 Stop 旧 goroutine 再启动新的,避免泄漏。 生产路径只在模块 Init 中调一次,这里的幂等是防误用(测试 / 配置热更等)。

func (*SyncWorker) Stop

func (w *SyncWorker) Stop()

Stop 通知 worker 退出并等待所有进行中的刷新完成。

func (*SyncWorker) WithVerificationSync

func (w *SyncWorker) WithVerificationSync(ui userInfoFetcher, verif verificationUpserter) *SyncWorker

WithVerificationSync 注入 /userinfo + upsert 依赖,启用 YUJ-405 的实名 sync 路径。 生产路径在 OIDC.Init 中调用;传 nil 保持原行为(不做实名同步)。

type SyncWorkerConfig

type SyncWorkerConfig struct {
	Interval    time.Duration // ≤ 0 视为禁用,Start 直接返回
	Concurrency int           // 单批内并发刷新数量上限
	BatchSize   int           // 单 tick 拉取条数

	// LockKey / LockTTL 控制多实例 tick 级互斥(详见 sync_lock.go)。
	// 仅当 worker 注入了非 nil 的 tickLock 时生效。
	// LockTTL 默认 = Interval:lock 持有期不超过一个 tick 周期,
	// 实例崩溃 → TTL 自动到期 → 下个 tick 自然恢复。
	LockKey string
	LockTTL time.Duration
}

SyncWorkerConfig SyncWorker 调度参数。

type UserInfoClaims

type UserInfoClaims struct {
	Subject       string `json:"sub"`
	Email         string `json:"email"`
	EmailVerified bool   `json:"email_verified"`
	PhoneNumber   string `json:"phone_number"`
	PhoneVerified bool   `json:"phone_number_verified"`
	Name          string `json:"name"`

	// identity_verification scope 字段。类型与 IDTokenClaims 一致(POC 实测 + bool/number/string 兜底)。
	IsVerified       IsVerifiedClaim `json:"is_verified"`
	VerifiedAt       VerifiedAtClaim `json:"verified_at"`
	VerifiedProvider string          `json:"verified_provider"`
	LegalName        string          `json:"legal_name"`
	LegalEmail       string          `json:"legal_email"`
}

UserInfoClaims /userinfo 端点返回的 claims(子集,与 IDTokenClaims 交集字段)。

/userinfo 比 ID Token 通常更新(IdP 侧后台变更可立即反映), 是登录后做"账户信息同步"的权威源。

identity_verification scope 下的 5 个字段也从 /userinfo 解出:部分 Aegis 部署把它们放 ID Token,另一些只在 /userinfo 暴露,拉完 /userinfo 后由 callback 合并到 IDTokenClaims(YUJ-382 codex review 点)。

type VerifiedAtClaim

type VerifiedAtClaim int64

VerifiedAtClaim 兜底 verified_at 的 number / string 两种序列化形态(Unix 秒)。

背景:Aegis 当前实测返 JSON number(Unix 秒),但:

  • 部分 IdP 管理后台把数字类型 claim 落成 string("1778331902")
  • 某些前置网关把大数用 JSON number 下发时会变成 JS float(1.778e9), Go 直接 Unmarshal 成 int64 会 TypeError(float64 不能收 int64)

处理:接 int64 / float64 / string("123")/ null / 缺字段。非法值返回 0, 下游 hasCompleteVerificationClaims / UpsertVerificationFromOIDC 已在 VerifiedAt<=0 分支拒写库,不会污染 user_verification 表。

func (VerifiedAtClaim) Int64

func (c VerifiedAtClaim) Int64() int64

Int64 返回内部 Unix 秒(与 time.Unix(sec, 0) 对齐)。

func (*VerifiedAtClaim) UnmarshalJSON

func (c *VerifiedAtClaim) UnmarshalJSON(data []byte) error

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL