feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user