package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" githubep "golang.org/x/oauth2/github" "gorm.io/gorm" "oci-portal/internal/model" ) // OAuth 流程错误;api 层映射为用户可读的回跳提示。 var ( // ErrOAuthNotConfigured 表示 provider 未配置(clientID 缺失)。 ErrOAuthNotConfigured = errors.New("该登录方式未配置") // ErrOAuthNoAppURL 表示面板地址缺失,回调 URL 无从拼接。 ErrOAuthNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址") // ErrOAuthDisabled 表示 provider 已被禁用,登录入口不可用(绑定不受影响)。 ErrOAuthDisabled = errors.New("该登录方式已禁用") // ErrOAuthState 表示 state 无效或已过期(CSRF 防护)。 ErrOAuthState = errors.New("授权状态无效或已过期,请重新发起") // ErrOAuthNotBound 表示外部身份未绑定任何账号,拒绝登录。 ErrOAuthNotBound = errors.New("该外部身份未绑定面板账号,请先登录后在设置中绑定") // ErrOAuthBound 表示身份已被绑定(重复绑定)。 ErrOAuthBound = errors.New("该外部身份已绑定过") ) // oauthPendingTTL 是授权流程 state 的有效期。 const oauthPendingTTL = 10 * time.Minute // oauthPending 是一次进行中的授权流程上下文;state 一次性使用。 type oauthPending struct { provider string mode string // "login" / "bind" username string // bind 模式的绑定目标账号 nonce string // OIDC 防 id_token 重放 token string // bind 模式发起时的 Bearer;回调复验,防被盗令牌撤销后仍完成绑定 proof TokenProof // 发起时的版本/jti 快照;绑定事务行锁下复核,覆盖撤销全部/注销/定点撤销 expires time.Time } // OAuthService 承接外部身份登录与绑定(OIDC / GitHub,探索文档主题二)。 type OAuthService struct { db *gorm.DB settings *SettingService auth *AuthService mu sync.Mutex pending map[string]oauthPending } // NewOAuthService 组装依赖。 func NewOAuthService(db *gorm.DB, settings *SettingService, auth *AuthService) *OAuthService { return &OAuthService{db: db, settings: settings, auth: auth, pending: map[string]oauthPending{}} } // ProviderInfo 是登录页公开的 provider 信息(displayName 空配置时为默认名)。 type ProviderInfo struct { Provider string `json:"provider"` DisplayName string `json:"displayName"` } // Providers 返回可实际登录的 provider 列表,登录页据此显示按钮; // 与不变量检查同口径:clientID 与 secret 齐备(oidc 还需 issuer)、未禁用, // 且面板地址已设置(回调地址无从拼接时全部不可登录)——半配置不再暴露必败入口。 func (o *OAuthService) Providers(ctx context.Context) []ProviderInfo { out := []ProviderInfo{} if o.settings.EffectiveAppURL() == "" { return out } for _, p := range []string{"oidc", "github"} { id, secret, issuer, err := o.settings.oauthClient(ctx, p) if err != nil || id == "" || secret == "" || (p == "oidc" && issuer == "") { continue } display, disabled, err := o.settings.oauthProviderMeta(ctx, p) if err != nil || disabled { continue } out = append(out, ProviderInfo{Provider: p, DisplayName: display}) } return out } // randHex 生成 n 字节随机数的 hex 编码。 func randHex(n int) (string, error) { buf := make([]byte, n) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("random: %w", err) } return hex.EncodeToString(buf), nil } // callbackURL 是 provider 回调地址,固定拼自面板地址(白名单即此一条)。 func (o *OAuthService) callbackURL(provider string) string { return o.settings.EffectiveAppURL() + "/api/v1/auth/oauth/" + provider + "/callback" } // oauth2Config 构造 provider 的 oauth2 配置;oidc 时一并返回已发现的 provider。 func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oauth2.Config, *oidc.Provider, error) { clientID, secret, issuer, err := o.settings.oauthClient(ctx, provider) if err != nil { return nil, nil, err } if clientID == "" || secret == "" || (provider == "oidc" && issuer == "") { return nil, nil, ErrOAuthNotConfigured } if o.settings.EffectiveAppURL() == "" { return nil, nil, ErrOAuthNoAppURL } cfg := &oauth2.Config{ClientID: clientID, ClientSecret: secret, RedirectURL: o.callbackURL(provider)} if provider == "github" { cfg.Endpoint = githubep.Endpoint cfg.Scopes = []string{"read:user"} return cfg, nil, nil } op, err := oidc.NewProvider(ctx, issuer) if err != nil { return nil, nil, fmt.Errorf("oidc discovery: %w", err) } cfg.Endpoint = op.Endpoint() cfg.Scopes = []string{oidc.ScopeOpenID, "email", "profile"} return cfg, op, nil } // AuthorizeURL 构造授权跳转 URL 并登记一次性 state;mode 为 bind 时 username 必填。 // login 模式拒绝已禁用的 provider;bind 模式不受禁用影响(管理员仍可绑定)。 func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, username, bindToken string) (string, error) { if mode == "login" { if _, disabled, err := o.settings.oauthProviderMeta(ctx, provider); err == nil && disabled { return "", ErrOAuthDisabled } } var proof TokenProof if mode == "bind" { var err error if proof, err = o.bindTokenProof(ctx, username, bindToken); err != nil { return "", err } } cfg, _, err := o.oauth2Config(ctx, provider) if err != nil { return "", err } state, err := randHex(16) if err != nil { return "", err } nonce, err := randHex(16) if err != nil { return "", err } o.mu.Lock() o.gcPendingLocked() o.pending[state] = oauthPending{provider: provider, mode: mode, username: username, nonce: nonce, token: bindToken, proof: proof, expires: time.Now().Add(oauthPendingTTL)} o.mu.Unlock() opts := []oauth2.AuthCodeOption{} if provider == "oidc" { opts = append(opts, oidc.Nonce(nonce)) } return cfg.AuthCodeURL(state, opts...), nil } // bindTokenProof 验证 bind 发起令牌的有效性与归属,返回其版本 / jti 快照; // 绑定事务行锁下复核该快照,发起后改密、撤销全部、注销或定点撤销均令绑定作废。 func (o *OAuthService) bindTokenProof(ctx context.Context, username, token string) (TokenProof, error) { name, proof, err := o.auth.ParseTokenProof(ctx, token) if err != nil || name != username { return TokenProof{}, ErrOAuthState } return proof, nil } // gcPendingLocked 清理过期流程;调用方须持锁。 func (o *OAuthService) gcPendingLocked() { now := time.Now() for k, p := range o.pending { if now.After(p.expires) { delete(o.pending, k) } } } // takeState 取出并消费 state(一次性);provider 不匹配或过期视为无效。 func (o *OAuthService) takeState(provider, state string) (oauthPending, error) { o.mu.Lock() defer o.mu.Unlock() p, ok := o.pending[state] delete(o.pending, state) if !ok || p.provider != provider || time.Now().After(p.expires) { return oauthPending{}, ErrOAuthState } return p, nil } // externalIdentity 是从 provider 换回的外部身份。 type externalIdentity struct { Subject string Display string } // HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT; // token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。 func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string, meta SessionMeta) (token, username, mode string, err error) { p, err := o.takeState(provider, state) if err != nil { return "", "", "", err } if err := o.validateCallbackPending(ctx, provider, p); err != nil { return "", "", p.mode, err } ident, err := o.fetchIdentity(ctx, provider, code, p.nonce) if err != nil { return "", "", p.mode, err } if p.mode == "bind" { // 绑定不改登录方式:接续行保留原 method,旧版无行时新建 method 为空 // (该令牌并非经新绑定方式登录,与活跃会话设计一致) token, err := o.bind(ctx, p, ident, meta) return token, p.username, p.mode, err } meta.Method = provider token, username, err = o.loginByIdentity(ctx, provider, ident, meta) return token, username, p.mode, err } func (o *OAuthService) validateCallbackPending(ctx context.Context, provider string, p oauthPending) error { if p.mode == "login" { return o.ensureProviderLoginEnabled(ctx, provider) } // bind 回调换码前复验发起令牌,撤销后流程立即作废。 name, err := o.auth.ParseToken(ctx, p.token) if err != nil || name != p.username { return ErrOAuthState } return nil } func (o *OAuthService) ensureProviderLoginEnabled(ctx context.Context, provider string) error { _, disabled, err := o.settings.oauthProviderMeta(ctx, provider) if err != nil { return err } if disabled { return ErrOAuthDisabled } return nil } // fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。 func (o *OAuthService) fetchIdentity(ctx context.Context, provider, code, nonce string) (externalIdentity, error) { cfg, op, err := o.oauth2Config(ctx, provider) if err != nil { return externalIdentity{}, err } tok, err := cfg.Exchange(ctx, code) if err != nil { return externalIdentity{}, fmt.Errorf("exchange code: %w", err) } if provider == "github" { return githubIdentity(ctx, cfg, tok) } return oidcIdentity(ctx, cfg, op, tok, nonce) } // githubIdentity 调 GitHub /user 拿数字 id(login 可改名,不可作 subject)。 func githubIdentity(ctx context.Context, cfg *oauth2.Config, tok *oauth2.Token) (externalIdentity, error) { client := cfg.Client(ctx, tok) req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) if err != nil { return externalIdentity{}, fmt.Errorf("github user request: %w", err) } resp, err := client.Do(req) if err != nil { return externalIdentity{}, fmt.Errorf("github user: %w", sanitizeURLError(err)) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return externalIdentity{}, fmt.Errorf("github user: status %d", resp.StatusCode) } var u struct { ID int64 `json:"id"` Login string `json:"login"` } if err := json.NewDecoder(resp.Body).Decode(&u); err != nil || u.ID == 0 { return externalIdentity{}, fmt.Errorf("github user: invalid response") } return externalIdentity{Subject: fmt.Sprintf("%d", u.ID), Display: u.Login}, nil } // oidcIdentity 验证 id_token 签名 / audience / nonce,取 sub 作 subject。 func oidcIdentity(ctx context.Context, cfg *oauth2.Config, op *oidc.Provider, tok *oauth2.Token, nonce string) (externalIdentity, error) { raw, ok := tok.Extra("id_token").(string) if !ok || raw == "" { return externalIdentity{}, fmt.Errorf("oidc: id_token missing") } idToken, err := op.Verifier(&oidc.Config{ClientID: cfg.ClientID}).Verify(ctx, raw) if err != nil { return externalIdentity{}, fmt.Errorf("oidc verify: %w", err) } if idToken.Nonce != nonce { return externalIdentity{}, fmt.Errorf("oidc: nonce mismatch") } var claims struct { Email string `json:"email"` } _ = idToken.Claims(&claims) display := claims.Email if display == "" { display = idToken.Subject } return externalIdentity{Subject: idToken.Subject, Display: display}, nil } // bind 在单事务内完成绑定全程:行锁下比对发起时令牌版本(发起后被撤销即作废)、 // 身份写入、版本递增、原会话行接续换发;任一失败整体回滚,不留半程状态。 // 新令牌接续 p.token 的会话行(保留登录方式与创建时间),旧令牌无行时按 meta 新建。 func (o *OAuthService) bind(ctx context.Context, p oauthPending, ident externalIdentity, meta SessionMeta) (string, error) { var token string err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { user, err := lockUserForAuthChange(tx, p.username) if err != nil { return err } if err := o.auth.ensureTokenCurrentTx(tx, user, p.proof); err != nil { return ErrOAuthState } if err := createIdentityTx(tx, user.ID, p.provider, ident.Subject, ident.Display, ErrOAuthBound); err != nil { return err } if err := bumpTokenVersionTx(tx, p.username); err != nil { return err } user.TokenVersion++ token, _, err = o.auth.renewSessionTx(tx, user, p.token, meta) return err }) return token, err } // createIdentityTx 查重后写入外部身份;(provider,subject) 已存在返回 dupErr。 func createIdentityTx(tx *gorm.DB, userID uint, provider, subject, display string, dupErr error) error { var count int64 err := tx.Model(&model.UserIdentity{}). Where("provider = ? AND subject = ?", provider, subject).Count(&count).Error if err != nil { return fmt.Errorf("check identity: %w", err) } if count > 0 { return dupErr } row := model.UserIdentity{UserID: userID, Provider: provider, Subject: subject, Display: display} if err := tx.Create(&row).Error; err != nil { return fmt.Errorf("bind identity: %w", err) } return nil } // loginByIdentity 查绑定关系并签发面板 JWT(落地会话);未绑定一律拒绝(不开放注册)。 func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ident externalIdentity, meta SessionMeta) (string, string, error) { row, err := o.findIdentity(ctx, provider, ident.Subject) if err != nil { return "", "", err } return o.loginIdentityRow(ctx, row, provider, ident.Subject, meta) } func (o *OAuthService) loginIdentityRow( ctx context.Context, row *model.UserIdentity, provider, subject string, meta SessionMeta, ) (string, string, error) { var token, username string err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { user, err := lockUserByIDForAuthChange(tx, row.UserID) if err != nil { return err } if err := identityStillBoundTx(tx, row.ID, user.ID, provider, subject); err != nil { return err } ok, err := oauthProviderUsableTx(tx, provider) if err != nil { return err } if !ok { return ErrOAuthDisabled } token, _, err = o.auth.signSessionTokenTx(tx, user, meta) username = user.Username return err }) return token, username, err } func (o *OAuthService) findIdentity(ctx context.Context, provider, subject string) (*model.UserIdentity, error) { var row model.UserIdentity err := o.db.WithContext(ctx). Where("provider = ? AND subject = ?", provider, subject).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrOAuthNotBound } if err != nil { return nil, fmt.Errorf("find identity: %w", err) } return &row, nil } func identityStillBoundTx(tx *gorm.DB, id, userID uint, provider, subject string) error { var count int64 err := tx.Model(&model.UserIdentity{}). Where("id = ? AND user_id = ? AND provider = ? AND subject = ?", id, userID, provider, subject). Count(&count).Error if err != nil { return fmt.Errorf("recheck identity: %w", err) } if count == 0 { return ErrOAuthNotBound } return nil } // Identities 列出账号已绑定的外部身份。 func (o *OAuthService) Identities(ctx context.Context, username string) ([]model.UserIdentity, error) { user, err := o.auth.findUser(ctx, username) if err != nil { return nil, err } items := []model.UserIdentity{} err = o.db.WithContext(ctx).Where("user_id = ?", user.ID).Order("id").Find(&items).Error if err != nil { return nil, fmt.Errorf("list identities: %w", err) } return items, nil } // Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。 // 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。 func (o *OAuthService) Unbind(ctx context.Context, username string, id uint, proof TokenProof) error { err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { user, err := lockUserForAuthChange(tx, username) if err != nil { return err } if err := o.auth.ensureTokenCurrentTx(tx, user, proof); err != nil { return err } origin, err := effectiveOriginTx(tx, o.settings) if err != nil { return err } if err := ensureNotLastLogin(tx, user.ID, id, origin); err != nil { return err } res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{}) if res.Error != nil { return fmt.Errorf("unbind identity: %w", res.Error) } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } // 解绑属敏感变更:版本递增与删除同事务提交,不留「已删而旧令牌仍有效」半程 return bumpTokenVersionTx(tx, username) }) return err } // ensureNotLastLogin 事务内校验不变量:密码登录已禁用时,解绑该身份后 // 须仍存在可实际登录的免密方式(provider 被禁用的身份不算),否则拒绝; // 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。 func ensureNotLastLogin(tx *gorm.DB, userID, identityID uint, origin string) error { off, err := settingValueTx(tx, settingSecPasswordLoginOff) if err != nil || off != "1" { return err } ok, err := usablePasswordlessTx(tx, userID, identityID, 0, origin) if err != nil { return err } if !ok { return ErrLastIdentity } return nil }