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
+12 -12
View File
@@ -238,7 +238,7 @@ mkdir -p data cache media downloads
```bash
cat > .env <<'EOF'
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
@@ -307,7 +307,7 @@ vim docker-compose.yml
#
# 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
#
# 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
@@ -516,7 +516,7 @@ docker compose up -d
```bash
cat > .env <<'EOF'
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
@@ -779,26 +779,26 @@ cd MediaStationGo
| 平台 | 包名示例 |
| --- | --- |
| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` |
| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` |
| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` |
| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` |
| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` |
部署步骤:
```bash
# Linux 示例
tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
cd MediaStationGo-v0.0.27-linux-amd64
tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz
cd MediaStationGo-v0.0.28-linux-amd64
MEDIASTATION_APP_PORT=18080 ./mediastation-go
```
Windows:
```powershell
Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
cd .\MediaStationGo-v0.0.27-windows-amd64
Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip
cd .\MediaStationGo-v0.0.28-windows-amd64
$env:MEDIASTATION_APP_PORT = "18080"
.\mediastation-go.exe
```
+11 -11
View File
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
```bash
cat > .env <<'EOF'
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
```bash
cat > .env <<'EOF'
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
| Platform | Package example |
| --- | --- |
| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` |
| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` |
| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` |
| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` |
| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` |
Linux example:
```bash
tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
cd MediaStationGo-v0.0.27-linux-amd64
tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz
cd MediaStationGo-v0.0.28-linux-amd64
MEDIASTATION_APP_PORT=18080 ./mediastation-go
```
Windows example:
```powershell
Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
cd .\MediaStationGo-v0.0.27-windows-amd64
Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip
cd .\MediaStationGo-v0.0.28-windows-amd64
$env:MEDIASTATION_APP_PORT = "18080"
.\mediastation-go.exe
```
+1 -1
View File
@@ -17,7 +17,7 @@
#
# 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
#
# 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
+19
View File
@@ -63,6 +63,10 @@ type adminUpdateUserReq struct {
Username string `json:"username" binding:"required"`
}
type adminResetPasswordReq struct {
Password string `json:"password" binding:"required,min=6"`
}
func updateUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserReq
@@ -132,6 +136,21 @@ func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
}
}
func resetUserPasswordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminResetPasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Auth.ResetPassword(c.Request.Context(), c.Param("id"), req.Password); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
if err != nil || firstAdmin == nil {
+1
View File
@@ -280,6 +280,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.GET("/users", listUsersHandler(svc))
admin.POST("/users", createUserHandler(svc))
admin.PATCH("/users/:id", updateUserHandler(svc))
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
+45 -2
View File
@@ -81,6 +81,27 @@ func (r *UserRepository) Create(ctx context.Context, u *model.User) error {
return r.db.WithContext(ctx).Create(u).Error
}
// ReleaseDeletedUsername renames soft-deleted rows that still hold a unique
// username so the same account name can be created again.
func (r *UserRepository) ReleaseDeletedUsername(ctx context.Context, username string) error {
if username == "" {
return nil
}
released := username + "__deleted__" + time.Now().Format("20060102150405.000000000")
if len(released) > 64 {
sum := sha256.Sum256([]byte(released))
released = username
if len(released) > 43 {
released = released[:43]
}
released += "__deleted__" + hex.EncodeToString(sum[:])[:10]
}
return r.db.WithContext(ctx).Unscoped().
Model(&model.User{}).
Where("username = ? AND deleted_at IS NOT NULL", username).
Update("username", released).Error
}
// FindByUsername returns the user matching username, or (nil, nil) when absent.
func (r *UserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var u model.User
@@ -161,9 +182,31 @@ func (r *UserRepository) TouchLogin(ctx context.Context, id string) error {
Update("last_login_at", &now).Error
}
// Delete removes a user (soft-delete via gorm.DeletedAt).
// Delete removes a user (soft-delete via gorm.DeletedAt), releases the unique
// username, and drops Telegram bindings so future re-created users bind cleanly.
func (r *UserRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Where("id = ?", id).First(&user).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&model.TelegramBinding{}).Error; err != nil {
return err
}
released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000")
if len(released) > 64 {
sum := sha256.Sum256([]byte(user.ID + user.Username))
base := user.Username
if len(base) > 43 {
base = base[:43]
}
released = base + "__deleted__" + hex.EncodeToString(sum[:])[:10]
}
if err := tx.Model(&model.User{}).Where("id = ?", id).Update("username", released).Error; err != nil {
return err
}
return tx.Delete(&model.User{}, "id = ?", id).Error
})
}
// ─── Library ─────────────────────────────────────────────────────────────────
+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)
}
}
+3
View File
@@ -10,6 +10,9 @@ export const adminAPI = {
updateUser: (id: string, payload: { username: string }) =>
api.patch<User>(`/admin/users/${id}`, payload).then((r) => r.data),
resetUserPassword: (id: string, password: string) =>
api.patch(`/admin/users/${id}/password`, { password }).then((r) => r.data),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
+31 -1
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
@@ -8,6 +8,7 @@ import type { Library, User } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
import { requestPassword } from '../components/PasswordDialog'
export function AdminPage() {
const [tab, setTab] = useState<'library' | 'users' | 'api'>('library')
@@ -206,6 +207,28 @@ function UsersPanel() {
}
}
const resetPassword = async (u: User) => {
const nextPassword = await requestPassword({
title: `重置 ${u.username} 的密码`,
message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
confirmText: '重置密码',
})
if (!nextPassword) return
if (nextPassword.length < 6) {
toast.error('新密码至少 6 位')
return
}
try {
await adminAPI.resetUserPassword(u.id, nextPassword)
toast.success('密码已重置')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'重置密码失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
@@ -303,6 +326,13 @@ function UsersPanel() {
<Pencil size={12} />
</button>
)}
<button
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
title="重置密码"
onClick={() => resetPassword(u)}
>
<KeyRound size={12} />
</button>
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
disabled={u.is_protected}