Documentation
¶
Index ¶
- Constants
- Variables
- func NewPKCEPair() (verifier, challenge string, err error)
- func NewRandomString(byteLen int) (string, error)
- func ValidateReturnTo(raw string, allowedHosts []string) (string, error)
- type AuditEvent
- type AuditModel
- type BindAuthenticator
- type BindConfig
- type BindConfirmResp
- type BindCreateResp
- type BindInfoResp
- type BindLocator
- type BindMethod
- type BindService
- func (s *BindService) Confirm(ctx context.Context, jti string) (*BindConfirmResp, error)
- func (s *BindService) Create(ctx context.Context, jti string) (*BindCreateResp, error)
- func (s *BindService) Info(ctx context.Context, jti string) (*BindInfoResp, error)
- func (s *BindService) Issue(ctx context.Context, claims *IDTokenClaims, sd *StateData) (string, error)
- func (s *BindService) IssueWithReason(ctx context.Context, claims *IDTokenClaims, sd *StateData, reason IssueReason) (string, error)
- func (s *BindService) SendSMS(ctx context.Context, jti string) error
- func (s *BindService) ShouldHandle(err error, claims *IDTokenClaims) bool
- func (s *BindService) VerifyPassword(ctx context.Context, jti, identifier, password string) error
- func (s *BindService) VerifySMS(ctx context.Context, jti, code string) error
- type BindSession
- type BindStatus
- type BindStore
- type CallbackGuard
- type Client
- func (c *Client) AuthCodeURL(state, nonce, codeChallenge string) (string, error)
- func (c *Client) EndSessionEndpoint() string
- func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error)
- func (c *Client) Issuer() string
- func (c *Client) Refresh(ctx context.Context, refreshToken string) (*oauth2.Token, error)
- func (c *Client) UserInfo(ctx context.Context, tok *oauth2.Token) (*UserInfoClaims, error)
- func (c *Client) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDTokenClaims, error)
- type ClientConfig
- type Config
- type DB
- func (d *DB) DueRefreshes(limit int) ([]*DueRefresh, error)
- func (d *DB) InsertAudit(m *AuditModel) error
- func (d *DB) InsertIdentity(m *IdentityModel) error
- func (d *DB) InsertRefresh(m *RefreshModel) error
- func (d *DB) MarkRefreshRevoked(id int64) (int64, error)
- func (d *DB) QueryIdentitiesByEmail(issuer, email string) ([]*IdentityModel, error)
- func (d *DB) QueryIdentitiesByUID(uid string) ([]*IdentityModel, error)
- func (d *DB) QueryIdentityByIssuerSubject(issuer, subject string) (*IdentityModel, error)
- func (d *DB) QueryRefreshByHash(hash string) (*RefreshModel, error)
- func (d *DB) QueryUIDsByEmail(email string) ([]string, error)
- func (d *DB) QueryUIDsByPhone(zone, phone string) ([]string, error)
- func (d *DB) RevokeRefreshByUID(uid string) (int64, error)
- func (d *DB) RotateRefresh(oldID int64, newRT *RefreshModel) error
- func (d *DB) UpdateIdentityLogin(id int64, email string, emailVerified int, phone string, phoneVerified int) error
- type DueRefresh
- type Encryptor
- type IDTokenClaims
- type IdentityModel
- type IsVerifiedClaim
- type IssueReason
- type IssueSessionReq
- type IssueSessionResp
- type MockProvider
- type OIDC
- type ProviderConfig
- type RedisTickLock
- type RefreshModel
- type RefreshResult
- type ResolveResult
- type Service
- type StateData
- type StateStore
- type SyncWorker
- type SyncWorkerConfig
- type UserInfoClaims
- type VerifiedAtClaim
Constants ¶
const ThirdAuthcodeRedisPrefix = "thirdlogin:authcode:"
ThirdAuthcodeRedisPrefix 与 user 模块 ThirdAuthcodePrefix 一致, 复用前端短码轮询取登录态的现有约定。注意:不能改名,前端协议公开。
Variables ¶
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") )
哨兵错误。调用方按类型而非字符串判断,避免文案改动影响业务路径。
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 状态/前端提示。
var ErrAlreadyRevoked = errors.New("oidc: refresh token already revoked")
ErrAlreadyRevoked 旧 RT 已被吊销;RotateRefresh 检测到并发竞争(另一 worker 抢先轮换)时返回
var ErrCallbackBlocked = errors.New("oidc callback rate limit exceeded")
ErrCallbackBlocked /callback 同 IP 失败次数过多,临时锁定。
var ErrReturnToRejected = errors.New("oidc: return_to rejected")
ErrReturnToRejected return_to 校验失败(空、非法 URL、host 不在白名单)
var ErrStateNotFound = errors.New("oidc: state not found or already consumed")
ErrStateNotFound state 已过期、被消费或从未存在
Functions ¶
func NewPKCEPair ¶
NewPKCEPair 按 RFC 7636 S256 生成 (code_verifier, code_challenge)
func NewRandomString ¶
NewRandomString 生成 URL-safe Base64 编码的随机字符串(byteLen 字节熵)
func ValidateReturnTo ¶
ValidateReturnTo 校验 callback 完成后跳转地址是否合法。
规则(防开放重定向):
- 空字符串 -> 视为合法,调用方应回退到默认页(返 "" 表示无跳转)
- 相对路径(以 / 开头,不含 //)-> 合法,host 由前端域名决定
- 绝对 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 自助绑定终态写入。串行步骤(与下方代码一一对应):
- Get session(不消费)
- ConfirmMax counter +1(SR-2.1)—— 故意放在 status 检查之前 (asymmetry note,与 VerifyPassword/VerifySMS 顺序相反)
- 校验 Status == verified;否则拒绝
- 解出 claims/sd snapshot
- identity.Insert((uid, issuer, sub)) —— DB uk_issuer_subject + uk_uid_issuer 兜底 SR-5;duplicate-key → ErrBindAlreadyBound
- users.IssueSession 签发 dmwork 会话
- 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):
- Get session
- IncrAndCheck("bind:create:"+jti, bindCreateMax) — counter→status 与 Confirm 对齐, 任何对 create 的探测都消耗配额
- decode claims + 校验(纯只读,无副作用,失败保持 token 在 issued 让用户重试)
- CASSave issued→creating — 唯一的"锁":只有拿到锁的 goroutine 才能进入 产生副作用的 #5/#6/#7。并发 verify/create 都会撞 CAS 失败 → ErrBindStatusConflict, 不会出现"副作用已发生但终态锁不上"的 ghost-user 窗口。
- IssueSession({CreateUser:true, ...}) — 副作用 #1:dmwork user 入库
- identity.Insert((uid, issuer, sub)) — 副作用 #2:user_oidc_identity 入库
- 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 ¶
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 ¶
AuthCodeURL 构造带 PKCE(S256) + state + nonce 的授权 URL。
调用方负责生成并持久化 state / nonce / code_verifier(写 StateStore), 这里只把 code_challenge 透传给 IdP。verifier 永远不上 URL。
func (*Client) EndSessionEndpoint ¶ added in v1.5.1
EndSessionEndpoint 返回 Discovery 暴露的 RP-Initiated Logout 端点; IdP 未声明时返回空字符串。
func (*Client) Exchange ¶
Exchange 用 authorization_code + code_verifier 换 token。
PKCE 的 code_verifier 通过 oauth2 的 SetAuthURLParam 传给 IdP;调用方需 确保和 AuthCodeURL 阶段的 challenge 是同一对(从 StateStore 取出)。
func (*Client) Refresh ¶
Refresh 用 refresh_token 换新 token。RT 轮换语义由 IdP 决定, 调用方拿到新 token 后应替换 DB 中的密文 RT(rotate)。
失败语义:
- "invalid_grant" 等价于 RT 失效(被吊销 / 用户登出 / 自然过期), 调用方应把对应 identity 标记吊销,前端引导重新登录。
- 其他错误(网络 / 5xx)调用方可重试。
func (*Client) UserInfo ¶
UserInfo 用 oauth2.Token 拉 /userinfo claims。
go-oidc 的 UserInfo 内部会校验 sub 与 ID Token 的 sub 一致性(若 token 含 ID Token); 这里只暴露 claims,sub 校验由调用方决定信不信任。
func (*Client) VerifyIDToken ¶
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 ¶
LoadConfig 从环境变量加载 OIDC 配置
Enabled=false 时不校验 provider 字段,允许编译期配置但运行期关闭。 dmwork-lib 暂未支持 OIDC 配置块,因此走环境变量;后续 dmwork-lib 加完字段 再迁移到 YAML,接口签名保持稳定即可。
type DB ¶
type DB struct {
// contains filtered or unexported fields
}
DB OIDC 模块数据访问层
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)后,按需:
- 加复合索引 `(revoked_at, last_refreshed_at, created_at)` 让 WHERE + ORDER BY 局部覆盖;或
- 改成应用层维护"下次该刷新时间"列(避免 COALESCE),用单列索引兜住。
JOIN 到 identity 走主键,负担可忽略。
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) MarkRefreshRevoked ¶
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 ¶
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 ¶
QueryUIDsByPhone 按手机号查 dmwork user 表的 uid 列表(同 QueryUIDsByEmail)。 同样过滤 is_destroy=0 AND status<>0 —— 见 QueryUIDsByEmail godoc。
func (*DB) RevokeRefreshByUID ¶
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,避免重复轮换。
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 ¶
NewEncryptor 用 32 字节主密钥构造 Encryptor
func (*Encryptor) Decrypt ¶
Decrypt 解密 Encrypt 产生的密文,认证失败返回错误
显式拒绝 < nonce + GCM tag 的密文(payload 至少 0 字节,但 tag 占 16B 不能少), 避免对 GCM 内部行为的隐式依赖,错误信息也更明确。
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 ¶
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 ¶
OIDC OIDC 登录模块。
字段全部包内可见:测试在 New 后可替换 stateStore / authcode 为内存实现。
func New ¶
New 构造 OIDC 模块(生产路径)。
Enabled=false 时只挂 Route 占位,handler 一律返回 404,避免漏配置时静默通过。 Discovery 失败不阻塞启动,handler 自检后返回 500,跟进运维告警即可。
func (*OIDC) Close ¶
Close 释放 OIDC 模块持有的资源(redisStateStore 连接池 + SyncWorker goroutine)。
注册到 register.Module.Stop,框架优雅退出时调用。可被多次调用(幂等):
- New() 内 Discovery 失败会调一次清理 stateStore
- 之后 framework shutdown 又会调一次,此时 stateStore 已 nil,早返回
func (*OIDC) Init ¶
Init 在所有模块初始化完成后调用(register.Module.Start), 此时 user 模块的 IService 已通过 register.GetService 可用。
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 等高级原语。
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 ¶
RefreshResult Refresh 成功返回的最小子集。
AccessToken 用于 YUJ-405:rotate 成功后立刻用新 access_token 调 /userinfo 同步实名 claims。旧调用路径(单纯 RT 轮转)忽略 AccessToken 即可。
type ResolveResult ¶
ResolveResult ResolveOrLink 的结果(UID + 是否新创建)。
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service OIDC 业务编排层。
职责:
- ResolveOrLink — 把 IdP claims 解析为 dmwork uid(必要时建账或绑定历史账号)
- 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 (*Service) ResolveOrLink ¶
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 ¶
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) 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
Source Files
¶
- 1module.go
- api.go
- api_bind.go
- api_i18n.go
- bind_config.go
- bind_model.go
- bind_service.go
- bind_store.go
- bind_store_redis.go
- callback_guard.go
- config.go
- crypto.go
- db.go
- logging.go
- logout_idtoken.go
- metrics.go
- mock_provider.go
- model.go
- oidc_client.go
- return_to.go
- service.go
- state_store.go
- state_store_redis.go
- sync_lock.go
- sync_worker.go
- user_adapter.go