移除外管首次改密与密码下限

This commit is contained in:
zhx 2026-07-16 10:29:42 +08:00
parent e6767899c3
commit 8a40bec075
6 changed files with 66 additions and 35 deletions

View File

@ -5,7 +5,7 @@
外管后台是独立于主后台、Databi 和 Finance 的浏览器入口: 外管后台是独立于主后台、Databi 和 Finance 的浏览器入口:
- 主后台 `/operations/external-admin-users` 继续使用主后台 JWT、RBAC 和全局 App 选择器,负责查找 App 用户、签发外管账号、配置权限、启停账号和重置密码。 - 主后台 `/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` - 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts``external_admin_sessions``external_admin_login_logs``external_admin_operation_logs`
- 登录只提交外管账号和密码,不提交或选择 App。账号名全局唯一服务端通过账号行自动确定 `app_code` 并固化到会话Fami 账号只能访问 Fami 数据,请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。 - 登录只提交外管账号和密码,不提交或选择 App。账号名全局唯一服务端通过账号行自动确定 `app_code` 并固化到会话Fami 账号只能访问 Fami 数据,请求头缺省时由会话补齐,伪造其他 App 时直接拒绝。
@ -16,7 +16,7 @@
- `platform-admin`:查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create``external-admin-user:permissions`,避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号。 - `platform-admin`:查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create``external-admin-user:permissions`,避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号。
- `ops-admin``operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。 - `ops-admin``operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。
- 外管登录账号名跨 App 全局唯一;绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查,数据库唯一索引负责收敛并发创建竞态。 - 外管登录账号名跨 App 全局唯一;绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查,数据库唯一索引负责收敛并发创建竞态。
- 创建、重置后的临时密码必须首次修改;停用或重置会撤销该账号的全部活跃会话。 - 创建、重置后的密码可直接使用,不要求首次登录修改;停用或重置仍会撤销该账号的全部活跃会话。
## 权限配置 ## 权限配置
@ -59,7 +59,7 @@
- 登录请求不带 App 即可进入账号所属租户;即使旧客户端伪造其他 `appCode`,也必须登录到账号行所属 App。Fami 账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403省略 header 时仍只能看到 Fami 数据。 - 登录请求不带 App 即可进入账号所属租户;即使旧客户端伪造其他 `appCode`,也必须登录到账号行所属 App。Fami 账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403省略 header 时仍只能看到 Fami 数据。
- 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。 - 外管账号或 App 停用后,已有会话直接访问任一业务接口必须返回 401。
- 临时密码登录后,除会话、退出和修改密码外的业务接口必须返回 403改密后才可进入授权页面 - 创建或重置密码后可直接进入已授权页面;历史 `password_change_required` 标记也不能阻断登录或业务接口
- 用旧 `revision` 更新权限必须返回 409成功减少权限后旧会话必须立即返回 401原权限接口必须返回 403。重复提交相同快照时版本保持不变。 - 用旧 `revision` 更新权限必须返回 409成功减少权限后旧会话必须立即返回 401原权限接口必须返回 403。重复提交相同快照时版本保持不变。
- 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。 - 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`;伪造 `X-Forwarded-For` 不能改变限流身份。
- Redis 临时不可用时新登录应在约 200 ms 后返回 503恢复后不需要重启服务。 - Redis 临时不可用时新登录应在约 200 ms 后返回 503恢复后不需要重启服务。

View File

@ -167,7 +167,7 @@ func (h *Handler) CreateAccount(c *gin.Context) {
return return
} }
if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") {
response.BadRequest(c, "账户名称格式不正确,密码长度需为 8-72 位") response.BadRequest(c, "账户名称格式不正确,或密码超过 72 字节")
return return
} }
if err != nil { if err != nil {
@ -221,7 +221,7 @@ func (h *Handler) ResetPassword(c *gin.Context) {
return return
} }
if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") {
response.BadRequest(c, "密码长度需为 8-72 位") response.BadRequest(c, "密码不能超过 72 字节")
return return
} }
if errors.Is(err, ErrAccountNotFound) { if errors.Is(err, ErrAccountNotFound) {
@ -359,7 +359,7 @@ func (h *Handler) ChangePassword(c *gin.Context) {
return return
} }
if errors.Is(err, ErrInvalidInput) { if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "新密码长度需为 8-72 位") response.BadRequest(c, "新密码不能超过 72 字节")
return return
} }
if err != nil { if err != nil {

View File

@ -92,6 +92,8 @@ func (h *Handler) RequireCSRF() gin.HandlerFunc {
} }
func (h *Handler) RequirePasswordChanged() 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) { return func(c *gin.Context) {
principal, ok := CurrentPrincipal(c) principal, ok := CurrentPrincipal(c)
if !ok { if !ok {

View File

@ -59,10 +59,11 @@ func RegisterExternalRoutes(api *gin.RouterGroup, h *Handler, handlers BusinessH
protectedAuth.POST("/logout", h.Logout) protectedAuth.POST("/logout", h.Logout)
protectedAuth.POST("/change-password", h.ChangePassword) protectedAuth.POST("/change-password", h.ChangePassword)
// Initial/reset credentials are deliberately limited to me/logout/change-password. // Password changes remain available as a self-service action, but they are not a
// Only after changing the temporary password can the account reach business handlers. // 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 := external.Group("")
business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF(), h.RequirePasswordChanged()) business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF())
registerBusinessWhitelist(business, h, handlers) registerBusinessWhitelist(business, h, handlers)
} }

View File

@ -194,7 +194,7 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
if err != nil { if err != nil {
return AccountDTO{}, err return AccountDTO{}, err
} }
passwordHash, err := security.HashPassword(input.Password) passwordHash, err := security.HashPasswordWithoutMinimum(input.Password)
if err != nil { if err != nil {
return AccountDTO{}, ErrInvalidInput return AccountDTO{}, ErrInvalidInput
} }
@ -203,17 +203,30 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
return AccountDTO{}, err return AccountDTO{}, err
} }
account := model.ExternalAdminAccount{ account := model.ExternalAdminAccount{
AppCode: input.AppCode, AppCode: input.AppCode,
LinkedAppUserID: linkedUser.UserID, LinkedAppUserID: linkedUser.UserID,
Username: input.Username, Username: input.Username,
PasswordHash: passwordHash, PasswordHash: passwordHash,
PermissionsJSON: permissionsJSON, PermissionsJSON: permissionsJSON,
PermissionRevision: 1, PermissionRevision: 1,
Status: model.ExternalAdminStatusActive, Status: model.ExternalAdminStatusActive,
PasswordChangeRequired: true, CreatedByAdminID: input.CreatedByAdminID,
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) { if isDuplicateKey(err) {
return AccountDTO{}, ErrAccountConflict return AccountDTO{}, ErrAccountConflict
} }
@ -267,7 +280,7 @@ func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64,
if err := validatePassword(password); err != nil { if err := validatePassword(password); err != nil {
return AccountDTO{}, err return AccountDTO{}, err
} }
hash, err := security.HashPassword(password) hash, err := security.HashPasswordWithoutMinimum(password)
if err != nil { if err != nil {
return AccountDTO{}, ErrInvalidInput return AccountDTO{}, ErrInvalidInput
} }
@ -277,8 +290,10 @@ func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64,
return err return err
} }
if err := tx.Model(&account).Updates(map[string]any{ if err := tx.Model(&account).Updates(map[string]any{
"password_hash": hash, "password_hash": hash,
"password_change_required": true, // 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, "failed_login_count": 0,
"locked_until_ms": 0, "locked_until_ms": 0,
}).Error; err != nil { }).Error; err != nil {
@ -548,7 +563,9 @@ func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPri
return SessionPrincipal{ return SessionPrincipal{
SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode, SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode,
LinkedAppUserID: session.Account.LinkedAppUserID, Username: session.Account.Username, 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, CSRFTokenHash: session.CSRFTokenHash, ExpiresAtMS: session.ExpiresAtMS,
}, nil }, nil
} }
@ -572,7 +589,7 @@ func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal
if strings.TrimSpace(input.CurrentPassword) == "" { if strings.TrimSpace(input.CurrentPassword) == "" {
return ErrInvalidInput return ErrInvalidInput
} }
hash, err := security.HashPassword(input.NewPassword) hash, err := security.HashPasswordWithoutMinimum(input.NewPassword)
if err != nil { if err != nil {
return ErrInvalidInput return ErrInvalidInput
} }
@ -587,10 +604,9 @@ func (s *Service) ChangePassword(ctx context.Context, principal SessionPrincipal
return nil return nil
} }
// This comparison must use the hash read under the account row lock. Otherwise // 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 // two concurrent password changes could both compare against a stale hash.
// one request could clear password_change_required without changing the secret. // Returning before both Updates calls also guarantees a rejected request does
// Returning before both Updates calls also guarantees rejection neither clears // not revoke the account's other active sessions.
// the first-login gate nor revokes the account's other active sessions.
if security.CheckPassword(account.PasswordHash, input.NewPassword) { if security.CheckPassword(account.PasswordHash, input.NewPassword) {
passwordErr = ErrPasswordReused passwordErr = ErrPasswordReused
return nil return nil
@ -645,7 +661,9 @@ func (s *Service) sessionView(ctx context.Context, account model.ExternalAdminAc
User: user, User: user,
Account: AccountSummary{ID: account.ID, Username: account.Username, Status: account.Status}, Account: AccountSummary{ID: account.ID, Username: account.Username, Status: account.Status},
App: app, AppCode: account.AppCode, Permissions: permissions, Capabilities: capabilitiesFor(permissions), 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 }, nil
} }
@ -845,14 +863,14 @@ func truncateText(value string, maxRunes int) string {
} }
func validatePassword(password string) error { func validatePassword(password string) error {
// Length alone would accept eight spaces. Such an account cannot complete the // External administrators intentionally have no minimum password length. Preserve
// first-login flow because currentPassword is intentionally required to contain // exact bytes (no trim), reject unusable all-whitespace credentials, and enforce
// a non-whitespace character, so reject the unusable credential at every writer. // bcrypt's hard 72-byte input limit before doing the expensive hash operation.
if strings.TrimSpace(password) == "" { if strings.TrimSpace(password) == "" {
return ErrPasswordBlank return ErrPasswordBlank
} }
if len(password) < 8 || len(password) > 72 { if len(password) > 72 {
return fmt.Errorf("%w: password length must be between 8 and 72", ErrInvalidInput) return fmt.Errorf("%w: password length must not exceed 72 bytes", ErrInvalidInput)
} }
return nil return nil
} }

View File

@ -13,7 +13,17 @@ func HashPassword(password string) (string, error) {
if len(password) < 6 { if len(password) < 6 {
return "", errors.New("password must contain at least 6 characters") 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) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return "", err return "", err