From 8a40bec075b5fbcaa403e2aea9a17b1335fa4718 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 10:29:42 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=A4=96=E7=AE=A1=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E6=94=B9=E5=AF=86=E4=B8=8E=E5=AF=86=E7=A0=81=E4=B8=8B?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/admin/docs/外管后台.md | 6 +- .../internal/modules/externaladmin/handler.go | 6 +- .../modules/externaladmin/middleware.go | 2 + .../internal/modules/externaladmin/routes.go | 7 +- .../internal/modules/externaladmin/service.go | 70 ++++++++++++------- server/admin/internal/security/security.go | 10 +++ 6 files changed, 66 insertions(+), 35 deletions(-) diff --git a/server/admin/docs/外管后台.md b/server/admin/docs/外管后台.md index 3d34c899..81f6934d 100644 --- a/server/admin/docs/外管后台.md +++ b/server/admin/docs/外管后台.md @@ -5,7 +5,7 @@ 外管后台是独立于主后台、Databi 和 Finance 的浏览器入口: - 主后台 `/operations/external-admin-users` 继续使用主后台 JWT、RBAC 和全局 App 选择器,负责查找 App 用户、签发外管账号、配置权限、启停账号和重置密码。 -- 外管入口 `/external-admin/` 使用独立账号、服务端 opaque session、CSRF token 和首次改密流程,不读取主后台 token 或 localStorage。 +- 外管入口 `/external-admin/` 使用独立账号、服务端 opaque session 和 CSRF token,不读取主后台 token 或 localStorage。 - 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts`、`external_admin_sessions`、`external_admin_login_logs`、`external_admin_operation_logs`。 - 登录只提交外管账号和密码,不提交或选择 App。账号名全局唯一,服务端通过账号行自动确定 `app_code` 并固化到会话;Fami 账号只能访问 Fami 数据,请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。 @@ -16,7 +16,7 @@ - `platform-admin`:查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create` 和 `external-admin-user:permissions`,避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号。 - `ops-admin`、`operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。 - 外管登录账号名跨 App 全局唯一;绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查,数据库唯一索引负责收敛并发创建竞态。 -- 创建、重置后的临时密码必须首次修改;停用或重置会撤销该账号的全部活跃会话。 +- 创建、重置后的密码可直接使用,不要求首次登录修改;停用或重置仍会撤销该账号的全部活跃会话。 ## 权限配置 @@ -59,7 +59,7 @@ - 登录请求不带 App 即可进入账号所属租户;即使旧客户端伪造其他 `appCode`,也必须登录到账号行所属 App。Fami 账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403,省略 header 时仍只能看到 Fami 数据。 - 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。 -- 临时密码登录后,除会话、退出和修改密码外的业务接口必须返回 403;改密后才可进入授权页面。 +- 创建或重置密码后可直接进入已授权页面;历史 `password_change_required` 标记也不能阻断登录或业务接口。 - 用旧 `revision` 更新权限必须返回 409;成功减少权限后旧会话必须立即返回 401,原权限接口必须返回 403。重复提交相同快照时版本保持不变。 - 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。 - Redis 临时不可用时新登录应在约 200 ms 后返回 503;恢复后不需要重启服务。 diff --git a/server/admin/internal/modules/externaladmin/handler.go b/server/admin/internal/modules/externaladmin/handler.go index 0f958992..497f113e 100644 --- a/server/admin/internal/modules/externaladmin/handler.go +++ b/server/admin/internal/modules/externaladmin/handler.go @@ -167,7 +167,7 @@ func (h *Handler) CreateAccount(c *gin.Context) { return } if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { - response.BadRequest(c, "账户名称格式不正确,密码长度需为 8-72 位") + response.BadRequest(c, "账户名称格式不正确,或密码超过 72 字节") return } if err != nil { @@ -221,7 +221,7 @@ func (h *Handler) ResetPassword(c *gin.Context) { return } if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { - response.BadRequest(c, "密码长度需为 8-72 位") + response.BadRequest(c, "密码不能超过 72 字节") return } if errors.Is(err, ErrAccountNotFound) { @@ -359,7 +359,7 @@ func (h *Handler) ChangePassword(c *gin.Context) { return } if errors.Is(err, ErrInvalidInput) { - response.BadRequest(c, "新密码长度需为 8-72 位") + response.BadRequest(c, "新密码不能超过 72 字节") return } if err != nil { diff --git a/server/admin/internal/modules/externaladmin/middleware.go b/server/admin/internal/modules/externaladmin/middleware.go index 807c7bd9..e3e4b676 100644 --- a/server/admin/internal/modules/externaladmin/middleware.go +++ b/server/admin/internal/modules/externaladmin/middleware.go @@ -92,6 +92,8 @@ func (h *Handler) RequireCSRF() gin.HandlerFunc { } func (h *Handler) RequirePasswordChanged() gin.HandlerFunc { + // Deprecated helper retained for downstream source compatibility. External routes + // never install this gate, and session principals always expose the marker as false. return func(c *gin.Context) { principal, ok := CurrentPrincipal(c) if !ok { diff --git a/server/admin/internal/modules/externaladmin/routes.go b/server/admin/internal/modules/externaladmin/routes.go index b44acb63..8f713cf0 100644 --- a/server/admin/internal/modules/externaladmin/routes.go +++ b/server/admin/internal/modules/externaladmin/routes.go @@ -59,10 +59,11 @@ func RegisterExternalRoutes(api *gin.RouterGroup, h *Handler, handlers BusinessH protectedAuth.POST("/logout", h.Logout) protectedAuth.POST("/change-password", h.ChangePassword) - // Initial/reset credentials are deliberately limited to me/logout/change-password. - // Only after changing the temporary password can the account reach business handlers. + // Password changes remain available as a self-service action, but they are not a + // business-route gate. Legacy rows may still carry password_change_required=true; + // authorization must therefore depend only on the active session and permissions. business := external.Group("") - business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF(), h.RequirePasswordChanged()) + business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF()) registerBusinessWhitelist(business, h, handlers) } diff --git a/server/admin/internal/modules/externaladmin/service.go b/server/admin/internal/modules/externaladmin/service.go index 30ca306b..ab42fc82 100644 --- a/server/admin/internal/modules/externaladmin/service.go +++ b/server/admin/internal/modules/externaladmin/service.go @@ -194,7 +194,7 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account if err != nil { return AccountDTO{}, err } - passwordHash, err := security.HashPassword(input.Password) + passwordHash, err := security.HashPasswordWithoutMinimum(input.Password) if err != nil { return AccountDTO{}, ErrInvalidInput } @@ -203,17 +203,30 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account return AccountDTO{}, err } account := model.ExternalAdminAccount{ - AppCode: input.AppCode, - LinkedAppUserID: linkedUser.UserID, - Username: input.Username, - PasswordHash: passwordHash, - PermissionsJSON: permissionsJSON, - PermissionRevision: 1, - Status: model.ExternalAdminStatusActive, - PasswordChangeRequired: true, - CreatedByAdminID: input.CreatedByAdminID, + AppCode: input.AppCode, + LinkedAppUserID: linkedUser.UserID, + Username: input.Username, + PasswordHash: passwordHash, + PermissionsJSON: permissionsJSON, + PermissionRevision: 1, + Status: model.ExternalAdminStatusActive, + CreatedByAdminID: input.CreatedByAdminID, } - if err := s.db.WithContext(ctx).Create(&account).Error; err != nil { + // The legacy model/database default is true, so assigning Go's false zero value + // is not enough: GORM substitutes the default during Create. Clear the marker in + // the same transaction so a rolling-release request handled by an older instance + // cannot revive the retired first-login gate for a newly created account. + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&account).Error; err != nil { + return err + } + if err := tx.Model(&account).UpdateColumn("password_change_required", false).Error; err != nil { + return err + } + account.PasswordChangeRequired = false + return nil + }) + if err != nil { if isDuplicateKey(err) { return AccountDTO{}, ErrAccountConflict } @@ -267,7 +280,7 @@ func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64, if err := validatePassword(password); err != nil { return AccountDTO{}, err } - hash, err := security.HashPassword(password) + hash, err := security.HashPasswordWithoutMinimum(password) if err != nil { return AccountDTO{}, ErrInvalidInput } @@ -277,8 +290,10 @@ func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64, return err } if err := tx.Model(&account).Updates(map[string]any{ - "password_hash": hash, - "password_change_required": true, + "password_hash": hash, + // A reset still revokes every active session, but the replacement password + // is immediately usable and never creates a first-login rotation gate. + "password_change_required": false, "failed_login_count": 0, "locked_until_ms": 0, }).Error; err != nil { @@ -548,7 +563,9 @@ func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPri return SessionPrincipal{ SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode, LinkedAppUserID: session.Account.LinkedAppUserID, Username: session.Account.Username, - Permissions: permissions, PasswordChangeRequired: session.Account.PasswordChangeRequired, + // The persisted flag is a deprecated compatibility column. Always expose false + // so historical rows marked by older releases cannot recreate the retired gate. + Permissions: permissions, PasswordChangeRequired: false, CSRFTokenHash: session.CSRFTokenHash, ExpiresAtMS: session.ExpiresAtMS, }, nil } @@ -572,7 +589,7 @@ func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal if strings.TrimSpace(input.CurrentPassword) == "" { return ErrInvalidInput } - hash, err := security.HashPassword(input.NewPassword) + hash, err := security.HashPasswordWithoutMinimum(input.NewPassword) if err != nil { return ErrInvalidInput } @@ -587,10 +604,9 @@ func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal return nil } // This comparison must use the hash read under the account row lock. Otherwise - // two concurrent password changes could both compare against a stale hash and - // one request could clear password_change_required without changing the secret. - // Returning before both Updates calls also guarantees rejection neither clears - // the first-login gate nor revokes the account's other active sessions. + // two concurrent password changes could both compare against a stale hash. + // Returning before both Updates calls also guarantees a rejected request does + // not revoke the account's other active sessions. if security.CheckPassword(account.PasswordHash, input.NewPassword) { passwordErr = ErrPasswordReused return nil @@ -645,7 +661,9 @@ func (s *Service) sessionView(ctx context.Context, account model.ExternalAdminAc User: user, Account: AccountSummary{ID: account.ID, Username: account.Username, Status: account.Status}, App: app, AppCode: account.AppCode, Permissions: permissions, Capabilities: capabilitiesFor(permissions), - PasswordChangeRequired: account.PasswordChangeRequired, CSRFToken: csrfToken, + // Keep the wire field during rolling upgrades, but never surface the deprecated + // database marker as an instruction to block navigation or business APIs. + PasswordChangeRequired: false, CSRFToken: csrfToken, }, nil } @@ -845,14 +863,14 @@ func truncateText(value string, maxRunes int) string { } func validatePassword(password string) error { - // Length alone would accept eight spaces. Such an account cannot complete the - // first-login flow because currentPassword is intentionally required to contain - // a non-whitespace character, so reject the unusable credential at every writer. + // External administrators intentionally have no minimum password length. Preserve + // exact bytes (no trim), reject unusable all-whitespace credentials, and enforce + // bcrypt's hard 72-byte input limit before doing the expensive hash operation. if strings.TrimSpace(password) == "" { return ErrPasswordBlank } - if len(password) < 8 || len(password) > 72 { - return fmt.Errorf("%w: password length must be between 8 and 72", ErrInvalidInput) + if len(password) > 72 { + return fmt.Errorf("%w: password length must not exceed 72 bytes", ErrInvalidInput) } return nil } diff --git a/server/admin/internal/security/security.go b/server/admin/internal/security/security.go index 1a855dab..70489bb2 100644 --- a/server/admin/internal/security/security.go +++ b/server/admin/internal/security/security.go @@ -13,7 +13,17 @@ func HashPassword(password string) (string, error) { if len(password) < 6 { return "", errors.New("password must contain at least 6 characters") } + return hashPassword(password) +} +// HashPasswordWithoutMinimum is reserved for credential domains that define their +// own policy before hashing. bcrypt still enforces its 72-byte maximum; keeping this +// separate prevents the external-admin exception from weakening main-admin passwords. +func HashPasswordWithoutMinimum(password string) (string, error) { + return hashPassword(password) +} + +func hashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err