176 lines
6.2 KiB
Go
176 lines
6.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"oci-portal/internal/model"
|
|
)
|
|
|
|
// ErrCredentialConfirm 表示当前密码校验失败;api 层映射 401。
|
|
var ErrCredentialConfirm = errors.New("当前密码不正确")
|
|
|
|
// ErrCredentialInvalid 标记凭据输入非法;api 层映射 400。
|
|
var ErrCredentialInvalid = errors.New("凭据输入非法")
|
|
|
|
// ErrPasswordLoginDisabled 表示密码登录已被禁用;api 层映射 403。
|
|
var ErrPasswordLoginDisabled = errors.New("密码登录已禁用,请使用外部身份登录")
|
|
|
|
// ErrNeedIdentity 表示未绑定外部身份不可禁用密码登录;api 层映射 409。
|
|
var ErrNeedIdentity = errors.New("至少绑定一个外部身份后才能禁用密码登录")
|
|
|
|
// ErrLastIdentity 表示密码登录禁用期间不可解绑最后一个身份(防自锁);api 层映射 409。
|
|
var ErrLastIdentity = errors.New("密码登录已禁用,不能解绑最后一个外部身份;请先允许密码登录")
|
|
|
|
// UpdateCredentialsInput 是修改登录凭据的请求体;两项至少改一项,当前密码必填。
|
|
type UpdateCredentialsInput struct {
|
|
NewUsername string `json:"newUsername"`
|
|
NewPassword string `json:"newPassword"`
|
|
CurrentPassword string `json:"currentPassword" binding:"required"`
|
|
}
|
|
|
|
// UpdateCredentials 修改用户名 / 密码:当前密码必验;成功后令牌版本递增
|
|
// (全部旧 JWT 立即失效),返回最终用户名供调用方为操作者重签新令牌。
|
|
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) (string, error) {
|
|
user, err := s.findUser(ctx, username)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)) != nil {
|
|
return "", ErrCredentialConfirm
|
|
}
|
|
newName := strings.TrimSpace(in.NewUsername)
|
|
if err := validateCredentialChange(user, newName, in.NewPassword); err != nil {
|
|
return "", err
|
|
}
|
|
updates, finalName, err := s.credentialUpdates(ctx, user, newName, in.NewPassword)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// 同一条 UPDATE 里递增令牌版本,与凭据变更保持原子
|
|
updates["token_version"] = gorm.Expr("token_version + 1")
|
|
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
|
return "", fmt.Errorf("update credentials: %w", err)
|
|
}
|
|
return finalName, nil
|
|
}
|
|
|
|
// credentialUpdates 组装凭据变更字段并返回最终用户名。
|
|
func (s *AuthService) credentialUpdates(ctx context.Context, user *model.User, newName, newPassword string) (map[string]any, string, error) {
|
|
updates := map[string]any{}
|
|
finalName := user.Username
|
|
if newName != "" && newName != user.Username {
|
|
taken, err := s.usernameTaken(ctx, newName, user.ID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if taken {
|
|
return nil, "", fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid)
|
|
}
|
|
updates["username"] = newName
|
|
finalName = newName
|
|
}
|
|
if newPassword != "" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("hash password: %w", err)
|
|
}
|
|
updates["password_hash"] = string(hash)
|
|
}
|
|
return updates, finalName, nil
|
|
}
|
|
|
|
// validateCredentialChange 校验改名 / 改密输入;两者均无实际变更时报非法。
|
|
func validateCredentialChange(user *model.User, newName, newPassword string) error {
|
|
if newName != "" && len(newName) > 64 {
|
|
return fmt.Errorf("用户名最长 64 字符: %w", ErrCredentialInvalid)
|
|
}
|
|
// bcrypt 只取前 72 字节,超长部分静默截断,直接拒绝
|
|
if newPassword != "" && (len(newPassword) < 8 || len(newPassword) > 72) {
|
|
return fmt.Errorf("新密码长度须在 8-72 之间: %w", ErrCredentialInvalid)
|
|
}
|
|
if (newName == "" || newName == user.Username) && newPassword == "" {
|
|
return fmt.Errorf("没有需要保存的变更: %w", ErrCredentialInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) usernameTaken(ctx context.Context, name string, selfID uint) (bool, error) {
|
|
var count int64
|
|
err := s.db.WithContext(ctx).Model(&model.User{}).
|
|
Where("username = ? AND id <> ?", name, selfID).Count(&count).Error
|
|
if err != nil {
|
|
return false, fmt.Errorf("check username: %w", err)
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// PasswordLoginDisabled 读密码登录禁用开关;settings 未注入(测试)视为未禁用。
|
|
func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
|
|
if s.settings == nil {
|
|
return false, nil
|
|
}
|
|
return s.settings.PasswordLoginDisabled(ctx)
|
|
}
|
|
|
|
// SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。
|
|
// 检查与写入在同一事务内并锁定用户行,防与解绑身份并发绕过「至少一种登录方式」。
|
|
func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error {
|
|
if s.settings == nil {
|
|
return errors.New("settings unavailable")
|
|
}
|
|
value := ""
|
|
if disabled {
|
|
value = "1"
|
|
}
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
user, err := lockUserForAuthChange(tx, username)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if disabled {
|
|
n, err := identityCountTx(tx, user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return ErrNeedIdentity
|
|
}
|
|
}
|
|
return saveSettingTx(tx, settingSecPasswordLoginOff, value)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// 登录策略属敏感变更:递增令牌版本,已签发会话全部失效
|
|
return s.bumpTokenVersion(ctx, username)
|
|
}
|
|
|
|
// lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁),
|
|
// 「禁用密码登录」与「解绑身份」都先过这把锁,保证不变量检查与写入不交叉。
|
|
func lockUserForAuthChange(tx *gorm.DB, username string) (*model.User, error) {
|
|
var user model.User
|
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
First(&user, "username = ?", username).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find user %s: %w", username, err)
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func identityCountTx(tx *gorm.DB, userID uint) (int64, error) {
|
|
var count int64
|
|
err := tx.Model(&model.UserIdentity{}).
|
|
Where("user_id = ?", userID).Count(&count).Error
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count identities: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|