feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
Frontend CI / Frontend (push) Successful in 3m8s
Backend CI / Backend (push) Successful in 14m48s

Add self-service password change that re-hashes the credential and bumps a
per-user session version so all existing access/refresh tokens are rejected.
Session version is tracked in Redis with an in-memory fallback and enforced in
middleware and refresh.

Scope prompt rules and rule groups to a brand: new brand_id column (migrated to
a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and
brand-aware admin-web tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 18:09:53 +08:00
parent 9f9e4f8e19
commit e045e00fbf
41 changed files with 1135 additions and 167 deletions
+90 -2
View File
@@ -51,6 +51,21 @@ func (s *AuthService) WithPasswordCipher(cipher *auth.PasswordCipher) *AuthServi
return s
}
func (s *AuthService) sessionVersion(ctx context.Context, userID int64) (int64, error) {
if s == nil || s.sessions == nil {
return 0, nil
}
return s.sessions.SessionVersion(ctx, userID)
}
func (s *AuthService) revokeUserSessions(ctx context.Context, userID int64) error {
if s == nil || s.sessions == nil {
return nil
}
_, err := s.sessions.BumpSessionVersion(ctx, userID)
return err
}
type LoginRequest struct {
Identifier string `json:"identifier"`
Email string `json:"email"`
@@ -153,7 +168,11 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership")
}
pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole)
sessionVersion, err := s.sessionVersion(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("load session version: %w", err)
}
pair, err := s.jwt.IssueWithSessionVersion(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole, sessionVersion)
if err != nil {
return nil, fmt.Errorf("issue token: %w", err)
}
@@ -261,6 +280,14 @@ type RefreshResponse struct {
ExpiresAt int64 `json:"expires_at"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
EncryptedOldPassword string `json:"encrypted_old_password"`
EncryptedNewPassword string `json:"encrypted_new_password"`
PasswordKeyID string `json:"password_key_id"`
}
func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*RefreshResponse, error) {
claims, err := s.jwt.Parse(req.RefreshToken)
if err != nil || claims.Subject != "refresh" {
@@ -272,7 +299,15 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh
return nil, response.ErrUnauthorized(40123, "membership_revoked", "tenant membership is no longer active")
}
pair, err := s.jwt.Issue(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole)
currentVersion, err := s.sessionVersion(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("load session version: %w", err)
}
if claims.SessionVersion < currentVersion {
return nil, response.ErrUnauthorized(40124, "session_revoked", "session has been revoked")
}
pair, err := s.jwt.IssueWithSessionVersion(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole, currentVersion)
if err != nil {
return nil, fmt.Errorf("issue token: %w", err)
}
@@ -302,6 +337,59 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh
}, nil
}
func (s *AuthService) ChangePassword(ctx context.Context, actor auth.Actor, req ChangePasswordRequest) error {
oldPassword, newPassword, err := s.changePasswordValues(req)
if err != nil {
return err
}
if oldPassword == "" {
return response.ErrBadRequest(40001, "invalid_params", "old password is required")
}
if len(newPassword) < 8 {
return response.ErrBadRequest(40011, "weak_password", "新密码至少 8 位")
}
user, err := s.repo.GetUserByID(ctx, actor.UserID)
if err != nil {
return response.ErrNotFound(40401, "user_not_found", "user not found")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
return response.ErrUnauthorized(40111, "invalid_old_password", "原密码错误")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := s.repo.UpdateUserPassword(ctx, actor.UserID, string(hash)); err != nil {
return fmt.Errorf("update user password: %w", err)
}
if err := s.revokeUserSessions(ctx, actor.UserID); err != nil {
return fmt.Errorf("revoke user sessions: %w", err)
}
return nil
}
func (s *AuthService) changePasswordValues(req ChangePasswordRequest) (string, string, error) {
if strings.TrimSpace(req.EncryptedOldPassword) != "" || strings.TrimSpace(req.EncryptedNewPassword) != "" {
if strings.TrimSpace(req.EncryptedOldPassword) == "" || strings.TrimSpace(req.EncryptedNewPassword) == "" {
return "", "", response.ErrBadRequest(40001, "invalid_params", "old password and new password are required")
}
if s.cipher == nil {
return "", "", response.ErrBadRequest(40002, "password_cipher_unavailable", "encrypted password is not supported by this server")
}
oldPassword, err := s.cipher.Decrypt(req.EncryptedOldPassword, req.PasswordKeyID)
if err != nil {
return "", "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
}
newPassword, err := s.cipher.Decrypt(req.EncryptedNewPassword, req.PasswordKeyID)
if err != nil {
return "", "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
}
return oldPassword, newPassword, nil
}
return req.OldPassword, req.NewPassword, nil
}
func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, error) {
user, err := s.repo.GetUserByID(ctx, actor.UserID)
if err != nil {