fix: repair user password reset and recreate flow

This commit is contained in:
ShukeBta
2026-05-30 03:33:36 +08:00
parent 99ecae0c44
commit ce9abf6306
12 changed files with 279 additions and 30 deletions
+26
View File
@@ -95,6 +95,9 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
} else if existing != nil {
return nil, nil, ErrUsernameTaken
}
if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil {
return nil, nil, err
}
if n, err := s.repo.User.Count(ctx); err != nil {
return nil, nil, err
} else if n >= LicensedMaxUsers(ctx, s.repo) {
@@ -181,6 +184,29 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd
return s.repo.User.UpdatePassword(ctx, userID, hash)
}
// ResetPassword lets an administrator set a new password without knowing the
// user's old password.
func (s *AuthService) ResetPassword(ctx context.Context, userID, newPwd string) error {
if strings.TrimSpace(userID) == "" {
return errors.New("missing user id")
}
if strings.TrimSpace(newPwd) == "" || len(newPwd) < 6 {
return errors.New("new password must be at least 6 characters")
}
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil {
return err
}
if u == nil {
return errors.New("user not found")
}
hash, err := hashPassword(newPwd)
if err != nil {
return err
}
return s.repo.User.UpdatePassword(ctx, userID, hash)
}
// VerifyPassword checks a user's current password without mutating account
// state. It is used for sensitive self-service actions such as hiding adult
// libraries or deleting play profiles.
+82 -1
View File
@@ -21,7 +21,7 @@ func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *Pr
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.TelegramBinding{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
@@ -66,6 +66,87 @@ func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) {
}
}
func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) {
ctx := context.Background()
repos, auth, _, _ := newAuthTestServices(t)
user, _, err := auth.Register(ctx, "viewer", "old-password")
if err != nil {
t.Fatalf("register old user: %v", err)
}
if err := repos.DB.Create(&model.TelegramBinding{
TelegramUserID: 10001,
TelegramName: "@viewer",
ChatID: 10001,
UserID: user.ID,
}).Error; err != nil {
t.Fatalf("create telegram binding: %v", err)
}
if err := repos.User.Delete(ctx, user.ID); err != nil {
t.Fatalf("delete user: %v", err)
}
next, _, err := auth.Register(ctx, "viewer", "new-password")
if err != nil {
t.Fatalf("register same username after delete: %v", err)
}
if next.ID == user.ID {
t.Fatal("recreated user should be a new account row")
}
if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
t.Fatalf("login recreated user: %v", err)
}
var bindings int64
if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 10001).Count(&bindings).Error; err != nil {
t.Fatalf("count bindings: %v", err)
}
if bindings != 0 {
t.Fatalf("deleted user telegram bindings should be removed, got %d", bindings)
}
}
func TestRegisterReleasesLegacySoftDeletedUsername(t *testing.T) {
ctx := context.Background()
repos, auth, _, _ := newAuthTestServices(t)
if err := repos.User.Create(ctx, &model.User{
Username: "legacy",
PasswordHash: "hash",
Role: "user",
Tier: "free",
}); err != nil {
t.Fatal(err)
}
legacy, err := repos.User.FindByUsername(ctx, "legacy")
if err != nil || legacy == nil {
t.Fatalf("find legacy user: %v", err)
}
if err := repos.DB.Delete(&model.User{}, "id = ?", legacy.ID).Error; err != nil {
t.Fatalf("legacy soft delete: %v", err)
}
if _, _, err := auth.Register(ctx, "legacy", "new-password"); err != nil {
t.Fatalf("register should release old soft-deleted username: %v", err)
}
}
func TestAdminResetPasswordAllowsLoginWithNewPassword(t *testing.T) {
ctx := context.Background()
_, auth, _, _ := newAuthTestServices(t)
user, _, err := auth.Register(ctx, "viewer", "old-password")
if err != nil {
t.Fatalf("register: %v", err)
}
if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil {
t.Fatalf("reset password: %v", err)
}
if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("old password should fail, got %v", err)
}
if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
t.Fatalf("new password should login: %v", err)
}
}
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
perms := DefaultPermissions("user-1")
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
+9 -2
View File
@@ -231,14 +231,18 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage,
if len(args) == 0 {
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
user, _ := s.repo.User.FindByID(ctx, binding.UserID)
if user == nil {
_ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error
return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n<code>/start 用户名 密码</code>"}
}
status := "未隐藏"
if user != nil && user.HideAdult {
if user.HideAdult {
status = "已隐藏"
}
return telegramCommandReply{
Text: fmt.Sprintf("<b>MediaStationGo 已绑定</b>\n\n你好 %s,当前账号:<b>%s</b>\n成人目录:<b>%s</b>", name, userNameOrFallback(user), status),
Buttons: [][]telegramInlineButton{{{
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user != nil && user.HideAdult],
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
Data: "adult_toggle",
}}},
}
@@ -895,6 +899,9 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if err := s.repo.DB.WithContext(ctx).Unscoped().Where("telegram_user_id = ?", int64(msg.From.ID)).Delete(&model.TelegramBinding{}).Error; err != nil {
return err
}
return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{
TelegramUserID: int64(msg.From.ID),
TelegramName: name,
@@ -0,0 +1,39 @@
package service
import (
"strings"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
repos, _, _, _ := newAuthTestServices(t)
if err := repos.DB.Create(&model.TelegramBinding{
TelegramUserID: 20001,
TelegramName: "@viewer",
ChatID: 20001,
UserID: "deleted-user",
}).Error; err != nil {
t.Fatalf("create binding: %v", err)
}
bot := NewTelegramBotService(zap.NewNop(), repos, nil)
reply := bot.cmdStart(t.Context(), &TelegramMessage{
From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"},
Chat: TelegramChat{ID: 20001, Type: "private"},
}, nil)
if !strings.Contains(reply.Text, "已不存在") {
t.Fatalf("expected stale binding message, got %q", reply.Text)
}
var count int64
if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 20001).Count(&count).Error; err != nil {
t.Fatalf("count binding: %v", err)
}
if count != 0 {
t.Fatalf("stale binding should be removed, got %d", count)
}
}