+172
-56
@@ -41,9 +41,11 @@ const oauthPendingTTL = 10 * time.Minute
|
||||
// oauthPending 是一次进行中的授权流程上下文;state 一次性使用。
|
||||
type oauthPending struct {
|
||||
provider string
|
||||
mode string // "login" / "bind"
|
||||
username string // bind 模式的绑定目标账号
|
||||
nonce string // OIDC 防 id_token 重放
|
||||
mode string // "login" / "bind"
|
||||
username string // bind 模式的绑定目标账号
|
||||
nonce string // OIDC 防 id_token 重放
|
||||
token string // bind 模式发起时的 Bearer;回调复验,防被盗令牌撤销后仍完成绑定
|
||||
proof TokenProof // 发起时的版本/jti 快照;绑定事务行锁下复核,覆盖撤销全部/注销/定点撤销
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
@@ -68,12 +70,17 @@ type ProviderInfo struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// Providers 返回可登录的 provider 列表(clientID 非空且未禁用),登录页据此显示按钮。
|
||||
// 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, _, _, err := o.settings.oauthClient(ctx, p)
|
||||
if err != nil || id == "" {
|
||||
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)
|
||||
@@ -105,7 +112,7 @@ func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oaut
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if clientID == "" {
|
||||
if clientID == "" || secret == "" || (provider == "oidc" && issuer == "") {
|
||||
return nil, nil, ErrOAuthNotConfigured
|
||||
}
|
||||
if o.settings.EffectiveAppURL() == "" {
|
||||
@@ -128,12 +135,19 @@ func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oaut
|
||||
|
||||
// AuthorizeURL 构造授权跳转 URL 并登记一次性 state;mode 为 bind 时 username 必填。
|
||||
// login 模式拒绝已禁用的 provider;bind 模式不受禁用影响(管理员仍可绑定)。
|
||||
func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, username string) (string, error) {
|
||||
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
|
||||
@@ -148,7 +162,7 @@ func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, usernam
|
||||
}
|
||||
o.mu.Lock()
|
||||
o.gcPendingLocked()
|
||||
o.pending[state] = oauthPending{provider: provider, mode: mode, username: username, nonce: nonce, expires: time.Now().Add(oauthPendingTTL)}
|
||||
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" {
|
||||
@@ -157,6 +171,16 @@ func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, usernam
|
||||
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()
|
||||
@@ -187,27 +211,52 @@ type externalIdentity struct {
|
||||
|
||||
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
||||
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, username, mode string, err error) {
|
||||
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" {
|
||||
if err := o.bind(ctx, p.username, provider, ident); err != nil {
|
||||
return "", p.username, p.mode, err
|
||||
}
|
||||
// 绑定属敏感变更:版本递增使旧令牌失效,同时为操作者签新令牌随回跳带回
|
||||
token, _, err := o.auth.RevokeSessions(ctx, p.username)
|
||||
// 绑定不改登录方式:接续行保留原 method,旧版无行时新建 method 为空
|
||||
// (该令牌并非经新绑定方式登录,与活跃会话设计一致)
|
||||
token, err := o.bind(ctx, p, ident, meta)
|
||||
return token, p.username, p.mode, err
|
||||
}
|
||||
token, username, err = o.loginByIdentity(ctx, provider, ident)
|
||||
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)
|
||||
@@ -273,45 +322,110 @@ func oidcIdentity(ctx context.Context, cfg *oauth2.Config, op *oidc.Provider, to
|
||||
return externalIdentity{Subject: idToken.Subject, Display: display}, nil
|
||||
}
|
||||
|
||||
// bind 把外部身份绑定到账号;(provider, subject) 唯一,重复绑定报错。
|
||||
func (o *OAuthService) bind(ctx context.Context, username, provider string, ident externalIdentity) error {
|
||||
user, err := o.auth.findUser(ctx, username)
|
||||
if err != 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 = o.db.WithContext(ctx).Model(&model.UserIdentity{}).
|
||||
Where("provider = ? AND subject = ?", provider, ident.Subject).Count(&count).Error
|
||||
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 ErrOAuthBound
|
||||
return dupErr
|
||||
}
|
||||
row := model.UserIdentity{UserID: user.ID, Provider: provider, Subject: ident.Subject, Display: ident.Display}
|
||||
if err := o.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
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) (string, string, error) {
|
||||
// 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, ident.Subject).First(&row).Error
|
||||
Where("provider = ? AND subject = ?", provider, subject).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrOAuthNotBound
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", ErrOAuthNotBound
|
||||
}
|
||||
return "", "", fmt.Errorf("find identity: %w", err)
|
||||
return nil, fmt.Errorf("find identity: %w", err)
|
||||
}
|
||||
var user model.User
|
||||
if err := o.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil {
|
||||
return "", "", fmt.Errorf("find bound user: %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)
|
||||
}
|
||||
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
|
||||
return token, user.Username, err
|
||||
if count == 0 {
|
||||
return ErrOAuthNotBound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Identities 列出账号已绑定的外部身份。
|
||||
@@ -330,13 +444,20 @@ func (o *OAuthService) Identities(ctx context.Context, username string) ([]model
|
||||
|
||||
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
|
||||
// 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。
|
||||
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error {
|
||||
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 := ensureNotLastLogin(tx, user.ID); err != nil {
|
||||
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{})
|
||||
@@ -346,30 +467,25 @@ func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) err
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
// 解绑属敏感变更:版本递增与删除同事务提交,不留「已删而旧令牌仍有效」半程
|
||||
return bumpTokenVersionTx(tx, username)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效
|
||||
return o.auth.bumpTokenVersion(ctx, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// ensureNotLastLogin 事务内校验不变量:仅剩一个身份且密码登录已禁用时拒绝解绑;
|
||||
// ensureNotLastLogin 事务内校验不变量:密码登录已禁用时,解绑该身份后
|
||||
// 须仍存在可实际登录的免密方式(provider 被禁用的身份不算),否则拒绝;
|
||||
// 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。
|
||||
func ensureNotLastLogin(tx *gorm.DB, userID uint) error {
|
||||
n, err := identityCountTx(tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 1 {
|
||||
return nil
|
||||
}
|
||||
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 off == "1" {
|
||||
if !ok {
|
||||
return ErrLastIdentity
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user