Implement password change functionality and initialize superuser

This commit is contained in:
2026-01-26 16:41:22 +08:00
parent a3b69df2c2
commit 86247aa5a2
10 changed files with 368 additions and 38 deletions

View File

@@ -14,8 +14,8 @@ from core.security import (
decode_access_token decode_access_token
) )
from db.database import get_db from db.database import get_db
from db.crud import get_user_by_username, get_user_by_email, create_user from db.crud import get_user_by_username, get_user_by_email, create_user, change_user_password
from schemas.user import User, UserCreate, Token from schemas.user import User, UserCreate, Token, PasswordChange
router = APIRouter(prefix="/auth", tags=["authentication"]) router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -105,3 +105,33 @@ async def get_current_user_info(
current_user: Annotated[User, Depends(get_current_user)] current_user: Annotated[User, Depends(get_current_user)]
): ):
return current_user return current_user
@router.post("/change-password", response_model=User)
@limiter.limit("5/minute")
async def change_password(
request: Request,
password_data: PasswordChange,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db)
):
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect"
)
new_hashed_password = get_password_hash(password_data.new_password)
user = change_user_password(
db,
user_id=current_user.id,
new_hashed_password=new_hashed_password
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user

View File

@@ -0,0 +1,38 @@
import logging
from core.database import SessionLocal
from core.security import get_password_hash
from db.crud import count_users, create_user_by_admin
logger = logging.getLogger(__name__)
def init_superuser():
db = SessionLocal()
try:
user_count = count_users(db)
if user_count > 0:
logger.info(f"Database already has {user_count} user(s), skipping admin initialization")
return
logger.info("No users found in database, initializing default superuser")
hashed_password = get_password_hash("admin123456")
admin_user = create_user_by_admin(
db,
username="admin",
email="admin@example.com",
hashed_password=hashed_password,
is_superuser=True
)
logger.info(f"Default superuser created successfully: {admin_user.username}")
logger.warning("SECURITY WARNING: Default admin credentials are in use!")
logger.warning(" Username: admin")
logger.warning(" Password: admin123456")
logger.warning(" Please change the password immediately after first login!")
except Exception as e:
logger.error(f"Failed to initialize superuser: {e}")
raise
finally:
db.close()

View File

@@ -11,6 +11,9 @@ def get_user_by_username(db: Session, username: str) -> Optional[User]:
def get_user_by_email(db: Session, email: str) -> Optional[User]: def get_user_by_email(db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first() return db.query(User).filter(User.email == email).first()
def count_users(db: Session) -> int:
return db.query(User).count()
def create_user(db: Session, username: str, email: str, hashed_password: str) -> User: def create_user(db: Session, username: str, email: str, hashed_password: str) -> User:
user = User( user = User(
username=username, username=username,
@@ -85,6 +88,21 @@ def delete_user(db: Session, user_id: int) -> bool:
db.commit() db.commit()
return True return True
def change_user_password(
db: Session,
user_id: int,
new_hashed_password: str
) -> Optional[User]:
user = get_user_by_id(db, user_id)
if not user:
return None
user.hashed_password = new_hashed_password
user.updated_at = datetime.utcnow()
db.commit()
db.refresh(user)
return user
def create_job(db: Session, user_id: int, job_type: str, input_data: Dict[str, Any]) -> Job: def create_job(db: Session, user_id: int, job_type: str, input_data: Dict[str, Any]) -> Job:
job = Job( job = Job(
user_id=user_id, user_id=user_id,

View File

@@ -70,6 +70,13 @@ async def lifespan(app: FastAPI):
logger.error(f"Database initialization failed: {e}") logger.error(f"Database initialization failed: {e}")
raise raise
try:
from core.init_admin import init_superuser
init_superuser()
except Exception as e:
logger.error(f"Superuser initialization failed: {e}")
raise
try: try:
model_manager = await ModelManager.get_instance() model_manager = await ModelManager.get_instance()
await model_manager.load_model("custom-voice") await model_manager.load_model("custom-voice")

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator, ConfigDict
import re import re
class UserBase(BaseModel): class UserBase(BaseModel):
@@ -89,3 +89,25 @@ class Token(BaseModel):
class TokenData(BaseModel): class TokenData(BaseModel):
username: Optional[str] = None username: Optional[str] = None
class PasswordChange(BaseModel):
current_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=8, max_length=128)
confirm_password: str = Field(..., min_length=8, max_length=128)
@field_validator('new_password')
@classmethod
def validate_password_strength(cls, v: str) -> str:
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one digit')
return v
@model_validator(mode='after')
def passwords_match(self) -> 'PasswordChange':
if self.new_password != self.confirm_password:
raise ValueError('Passwords do not match')
return self

View File

@@ -1,8 +1,13 @@
import { Menu, LogOut, Users } from 'lucide-react' import { Menu, LogOut, Users, KeyRound } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/ThemeToggle' import { ThemeToggle } from '@/components/ThemeToggle'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { authApi } from '@/lib/api'
import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog'
import type { PasswordChangeRequest } from '@/types/auth'
interface NavbarProps { interface NavbarProps {
onToggleSidebar?: () => void onToggleSidebar?: () => void
@@ -10,41 +15,72 @@ interface NavbarProps {
export function Navbar({ onToggleSidebar }: NavbarProps) { export function Navbar({ onToggleSidebar }: NavbarProps) {
const { logout, user } = useAuth() const { logout, user } = useAuth()
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false)
const [isChangingPassword, setIsChangingPassword] = useState(false)
const handlePasswordChange = async (data: PasswordChangeRequest) => {
try {
setIsChangingPassword(true)
await authApi.changePassword(data)
toast.success('密码修改成功')
setPasswordDialogOpen(false)
} catch (error: any) {
toast.error(error.message || '密码修改失败')
} finally {
setIsChangingPassword(false)
}
}
return ( return (
<nav className="h-16 border-b bg-background flex items-center px-4 gap-4"> <>
{onToggleSidebar && ( <nav className="h-16 border-b bg-background flex items-center px-4 gap-4">
<Button {onToggleSidebar && (
variant="ghost" <Button
size="icon" variant="ghost"
onClick={onToggleSidebar} size="icon"
className="lg:hidden" onClick={onToggleSidebar}
> className="lg:hidden"
<Menu className="h-5 w-5" /> >
</Button> <Menu className="h-5 w-5" />
)} </Button>
<div className="flex-1">
<Link to="/">
<h1 className="text-sm md:text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity">
Qwen3-TTS-WebUI
</h1>
</Link>
</div>
<div className="flex items-center gap-2">
{user?.is_superuser && (
<Link to="/users">
<Button variant="ghost" size="icon">
<Users className="h-5 w-5" />
</Button>
</Link>
)} )}
<ThemeToggle />
<Button variant="ghost" size="icon" onClick={logout}> <div className="flex-1">
<LogOut className="h-5 w-5" /> <Link to="/">
</Button> <h1 className="text-sm md:text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity">
</div> Qwen3-TTS-WebUI
</nav> </h1>
</Link>
</div>
<div className="flex items-center gap-2">
{user?.is_superuser && (
<Link to="/users">
<Button variant="ghost" size="icon">
<Users className="h-5 w-5" />
</Button>
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={() => setPasswordDialogOpen(true)}
>
<KeyRound className="h-5 w-5" />
</Button>
<ThemeToggle />
<Button variant="ghost" size="icon" onClick={logout}>
<LogOut className="h-5 w-5" />
</Button>
</div>
</nav>
<ChangePasswordDialog
open={passwordDialogOpen}
onOpenChange={setPasswordDialogOpen}
onSubmit={handlePasswordChange}
isLoading={isChangingPassword}
/>
</>
) )
} }

View File

@@ -0,0 +1,161 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import type { PasswordChangeRequest } from '@/types/auth'
const passwordChangeSchema = z.object({
current_password: z.string().min(1, '请输入当前密码'),
new_password: z
.string()
.min(8, '密码至少8个字符')
.regex(/[A-Z]/, '密码必须包含至少一个大写字母')
.regex(/[a-z]/, '密码必须包含至少一个小写字母')
.regex(/\d/, '密码必须包含至少一个数字'),
confirm_password: z.string().min(1, '请确认新密码'),
}).refine((data) => data.new_password === data.confirm_password, {
message: '两次输入的密码不一致',
path: ['confirm_password'],
})
type PasswordChangeFormValues = z.infer<typeof passwordChangeSchema>
interface ChangePasswordDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (data: PasswordChangeRequest) => Promise<void>
isLoading: boolean
}
export function ChangePasswordDialog({
open,
onOpenChange,
onSubmit,
isLoading,
}: ChangePasswordDialogProps) {
const form = useForm<PasswordChangeFormValues>({
resolver: zodResolver(passwordChangeSchema),
defaultValues: {
current_password: '',
new_password: '',
confirm_password: '',
},
})
const handleSubmit = async (data: PasswordChangeFormValues) => {
await onSubmit(data)
form.reset()
}
const handleOpenChange = (open: boolean) => {
if (!open && !isLoading) {
form.reset()
}
onOpenChange(open)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
8
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="current_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入当前密码"
disabled={isLoading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入新密码"
disabled={isLoading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请再次输入新密码"
disabled={isLoading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isLoading}
>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? '提交中...' : '确认修改'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import type { LoginRequest, LoginResponse, User } from '@/types/auth' import type { LoginRequest, LoginResponse, User, PasswordChangeRequest } from '@/types/auth'
import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job' import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job'
import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts' import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts'
import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user' import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user'
@@ -32,6 +32,9 @@ const FIELD_NAMES: Record<string, string> = {
username: '用户名', username: '用户名',
email: '邮箱', email: '邮箱',
password: '密码', password: '密码',
current_password: '当前密码',
new_password: '新密码',
confirm_password: '确认密码',
is_active: '激活状态', is_active: '激活状态',
is_superuser: '超级管理员', is_superuser: '超级管理员',
} }
@@ -174,6 +177,14 @@ export const authApi = {
const response = await apiClient.get<User>(API_ENDPOINTS.AUTH.ME) const response = await apiClient.get<User>(API_ENDPOINTS.AUTH.ME)
return response.data return response.data
}, },
changePassword: async (data: PasswordChangeRequest): Promise<User> => {
const response = await apiClient.post<User>(
API_ENDPOINTS.AUTH.CHANGE_PASSWORD,
data
)
return response.data
},
} }
export const ttsApi = { export const ttsApi = {

View File

@@ -2,6 +2,7 @@ export const API_ENDPOINTS = {
AUTH: { AUTH: {
LOGIN: '/auth/token', LOGIN: '/auth/token',
ME: '/auth/me', ME: '/auth/me',
CHANGE_PASSWORD: '/auth/change-password',
}, },
TTS: { TTS: {
LANGUAGES: '/tts/languages', LANGUAGES: '/tts/languages',

View File

@@ -23,3 +23,9 @@ export interface AuthState {
isLoading: boolean isLoading: boolean
isAuthenticated: boolean isAuthenticated: boolean
} }
export interface PasswordChangeRequest {
current_password: string
new_password: string
confirm_password: string
}