mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-30 17:51:47 +00:00
🔐 feat(auth): integrate backend session login + 2FA; add HTTP client and route guards
- add lib/http.ts: unified fetch wrapper with: - 30s timeout via AbortController - JSON/text parsing and normalized HttpError - Authorization header from cookie - New-Api-User header from persisted user id - get/post/put/patch/del helpers - add types/api.ts: ApiResponse, UserBasic, UserSelf, LoginResponse, Verify2FAResponse, SelfResponse - add lib/auth.ts: persist/restore user in localStorage (get/set/clear helpers) - refactor stores/auth-store.ts: - implement login (POST /api/user/login?turnstile=...), 2FA verify (POST /api/user/login/2fa) - implement fetchSelf (GET /api/user/self), logout (GET /api/user/logout) - persist user on successful login/verify/self - refactor auth/sign-in form: - switch field to "username" (supports username or email), loosen validation - call auth.login, handle 2FA branch → redirect to /otp - refactor OTP form: - call auth.verify2FA; on success, toast + redirect to home - add route guards: - /_authenticated: beforeLoad redirect to /sign-in if no user; silently refresh session via auth.fetchSelf - /(auth)/sign-in: beforeLoad redirect to / if already logged in - update sign-out dialog: use auth.logout and preserve redirect - cleanup: remove mock auth, no direct axios/fetch usage outside http.ts, no Semi UI imports in new code Notes - Backend expects username or email in "username" field. - Turnstile support: pass token via ?turnstile=... during login when enabled. Security - Ensure New-Api-User header matches logged-in user id per backend middleware. - Session-based auth with optional Bearer token via cookie. No breaking changes.
This commit is contained in:
@@ -13,7 +13,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const handleSignOut = () => {
|
||||
auth.reset()
|
||||
auth.logout()
|
||||
// Preserve current location for redirect after sign-in
|
||||
const currentPath = location.href
|
||||
navigate({
|
||||
|
||||
@@ -3,7 +3,8 @@ import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { showSubmittedData } from '@/lib/show-submitted-data'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -41,14 +42,19 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
|
||||
const otp = form.watch('otp')
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
showSubmittedData(data)
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await auth.verify2FA(data.otp)
|
||||
toast.success('登录成功')
|
||||
navigate({ to: '/' })
|
||||
}, 1000)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '验证失败,请重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Loader2, LogIn } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
@@ -21,13 +21,8 @@ import { Input } from '@/components/ui/input'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email({
|
||||
error: (iss) => (iss.input === '' ? 'Please enter your email' : undefined),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password')
|
||||
.min(7, 'Password must be at least 7 characters long'),
|
||||
username: z.string().min(1, 'Please enter your username or email'),
|
||||
password: z.string().min(1, 'Please enter your password'),
|
||||
})
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
@@ -45,40 +40,29 @@ export function UserAuthForm({
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
defaultValues: { username: '', password: '' },
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
// Mock successful authentication
|
||||
const mockUser = {
|
||||
accountNo: 'ACC001',
|
||||
email: data.email,
|
||||
role: ['user'],
|
||||
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
|
||||
try {
|
||||
const res = await auth.login({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
})
|
||||
if (res.require2FA) {
|
||||
toast.success('请输入两步验证码')
|
||||
navigate({ to: '/otp', replace: true })
|
||||
return
|
||||
}
|
||||
toast.success(`Welcome back, ${data.username}!`)
|
||||
const targetPath = redirectTo || '/'
|
||||
navigate({ to: targetPath, replace: true })
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Sign in failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Signing in...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
|
||||
// Set user and access token
|
||||
auth.setUser(mockUser)
|
||||
auth.setAccessToken('mock-access-token')
|
||||
|
||||
// Redirect to the stored location or default to dashboard
|
||||
const targetPath = redirectTo || '/'
|
||||
navigate({ to: targetPath, replace: true })
|
||||
|
||||
return `Welcome back, ${data.email}!`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -90,12 +74,12 @@ export function UserAuthForm({
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>Username or Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
<Input placeholder='your username or email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
31
web/src/lib/auth.ts
Normal file
31
web/src/lib/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { User } from '@/types/api'
|
||||
|
||||
const LS_USER_KEY = 'user'
|
||||
|
||||
export function getStoredUser(): User | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_USER_KEY)
|
||||
return raw ? (JSON.parse(raw) as User) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredUser(user: User | null) {
|
||||
try {
|
||||
if (user) localStorage.setItem(LS_USER_KEY, JSON.stringify(user))
|
||||
else localStorage.removeItem(LS_USER_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredUserId(): number | undefined {
|
||||
const user = getStoredUser()
|
||||
if (!user) return undefined
|
||||
return (user as any).id as number
|
||||
}
|
||||
|
||||
export function clearStoredUser() {
|
||||
setStoredUser(null)
|
||||
}
|
||||
121
web/src/lib/http.ts
Normal file
121
web/src/lib/http.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// 统一 http 封装:超时、JSON、错误语义、鉴权头
|
||||
import { getStoredUserId } from '@/lib/auth'
|
||||
import { getCookie } from '@/lib/cookies'
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
type JsonBody = Record<string, unknown> | Array<unknown> | undefined
|
||||
|
||||
export interface HttpError {
|
||||
status: number
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
async function safeMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
const data = await res.clone().json()
|
||||
if (typeof data?.message === 'string') return data.message
|
||||
return JSON.stringify(data)
|
||||
} catch {
|
||||
try {
|
||||
return await res.clone().text()
|
||||
} catch {
|
||||
return res.statusText || 'Request failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30000
|
||||
const ACCESS_TOKEN = 'thisisjustarandomstring'
|
||||
|
||||
export async function http<T>(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit & { timeoutMs?: number; asText?: boolean } = {}
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
init.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
)
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers || {}),
|
||||
}
|
||||
|
||||
// 从 cookie 读取 token,并自动附带
|
||||
const token = getCookie(ACCESS_TOKEN)
|
||||
if (token) {
|
||||
;(headers as Record<string, string>)['Authorization'] =
|
||||
`Bearer ${JSON.parse(token)}`
|
||||
}
|
||||
|
||||
// 携带 New-Api-User 以通过后端鉴权
|
||||
const userId = getStoredUserId()
|
||||
if (typeof userId === 'number' && userId > 0) {
|
||||
;(headers as Record<string, string>)['New-Api-User'] = String(userId)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await safeMessage(response)
|
||||
const error: HttpError = { status: response.status, message }
|
||||
throw error
|
||||
}
|
||||
|
||||
if (init.asText) {
|
||||
return (await response.text()) as unknown as T
|
||||
}
|
||||
// 缺省按 JSON 解析
|
||||
return (await response.json()) as T
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function get<T>(url: string, init?: RequestInit) {
|
||||
return http<T>(url, { ...init, method: 'GET' })
|
||||
}
|
||||
|
||||
export async function post<T>(
|
||||
url: string,
|
||||
body?: JsonBody,
|
||||
init?: RequestInit
|
||||
) {
|
||||
return http<T>(url, {
|
||||
...init,
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export async function put<T>(url: string, body?: JsonBody, init?: RequestInit) {
|
||||
return http<T>(url, {
|
||||
...init,
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export async function patch<T>(
|
||||
url: string,
|
||||
body?: JsonBody,
|
||||
init?: RequestInit
|
||||
) {
|
||||
return http<T>(url, {
|
||||
...init,
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export async function del<T>(url: string, init?: RequestInit) {
|
||||
return http<T>(url, { ...init, method: 'DELETE' })
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { getStoredUser } from '@/lib/auth'
|
||||
import { SignIn } from '@/features/auth/sign-in'
|
||||
|
||||
const searchSchema = z.object({
|
||||
@@ -7,6 +8,10 @@ const searchSchema = z.object({
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/(auth)/sign-in')({
|
||||
beforeLoad: () => {
|
||||
const user = getStoredUser()
|
||||
if (user) throw redirect({ to: '/' })
|
||||
},
|
||||
component: SignIn,
|
||||
validateSearch: searchSchema,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getStoredUser } from '@/lib/auth'
|
||||
import { AuthenticatedLayout } from '@/components/layout/authenticated-layout'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
component: AuthenticatedLayout,
|
||||
beforeLoad: async ({ location }) => {
|
||||
const user = getStoredUser()
|
||||
if (!user) {
|
||||
throw redirect({ to: '/sign-in', search: { redirect: location.href } })
|
||||
}
|
||||
},
|
||||
component: () => {
|
||||
// 进入后尝试刷新一次会话信息(静默失败)
|
||||
const { auth } = useAuthStore()
|
||||
auth.fetchSelf().catch(() => {})
|
||||
return <AuthenticatedLayout />
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type {
|
||||
LoginResponse,
|
||||
Verify2FAResponse,
|
||||
SelfResponse,
|
||||
UserBasic,
|
||||
} from '@/types/api'
|
||||
import { create } from 'zustand'
|
||||
import { clearStoredUser, setStoredUser } from '@/lib/auth'
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
import { get, post } from '@/lib/http'
|
||||
|
||||
const ACCESS_TOKEN = 'thisisjustarandomstring'
|
||||
|
||||
@@ -17,6 +25,14 @@ interface AuthState {
|
||||
accessToken: string
|
||||
setAccessToken: (accessToken: string) => void
|
||||
resetAccessToken: () => void
|
||||
login: (payload: {
|
||||
username: string
|
||||
password: string
|
||||
turnstile?: string
|
||||
}) => Promise<{ require2FA?: boolean }>
|
||||
verify2FA: (code: string) => Promise<void>
|
||||
fetchSelf: () => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
}
|
||||
@@ -40,9 +56,86 @@ export const useAuthStore = create<AuthState>()((set) => {
|
||||
removeCookie(ACCESS_TOKEN)
|
||||
return { ...state, auth: { ...state.auth, accessToken: '' } }
|
||||
}),
|
||||
login: async ({ username, password, turnstile }) => {
|
||||
const qs = turnstile
|
||||
? `?turnstile=${encodeURIComponent(turnstile)}`
|
||||
: ''
|
||||
const res = await post<LoginResponse>(`/api/user/login${qs}`, {
|
||||
username,
|
||||
password,
|
||||
})
|
||||
if (!res.success) throw new Error(res.message || '登录失败')
|
||||
const data = res.data
|
||||
if (data && (data as any).require_2fa) {
|
||||
return { require2FA: true }
|
||||
}
|
||||
const user = data as UserBasic
|
||||
setStoredUser(user)
|
||||
set((state) => ({
|
||||
...state,
|
||||
auth: {
|
||||
...state.auth,
|
||||
user: {
|
||||
accountNo: String(user.id),
|
||||
email: user.username,
|
||||
role: ['user'],
|
||||
exp: Date.now() + 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
}))
|
||||
return {}
|
||||
},
|
||||
verify2FA: async (code: string) => {
|
||||
const res = await post<Verify2FAResponse>('/api/user/login/2fa', {
|
||||
code,
|
||||
})
|
||||
if (!res.success) throw new Error(res.message || '验证失败')
|
||||
const user = res.data as UserBasic
|
||||
setStoredUser(user)
|
||||
set((state) => ({
|
||||
...state,
|
||||
auth: {
|
||||
...state.auth,
|
||||
user: {
|
||||
accountNo: String(user.id),
|
||||
email: user.username,
|
||||
role: ['user'],
|
||||
exp: Date.now() + 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
fetchSelf: async () => {
|
||||
const res = await get<SelfResponse>('/api/user/self')
|
||||
if (!res.success) return
|
||||
const user = res.data!
|
||||
setStoredUser(user)
|
||||
set((state) => ({
|
||||
...state,
|
||||
auth: {
|
||||
...state.auth,
|
||||
user: {
|
||||
accountNo: String(user.id),
|
||||
email: (user as any).email || user.username,
|
||||
role: ['user'],
|
||||
exp: Date.now() + 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await get('/api/user/logout')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearStoredUser()
|
||||
set((state) => ({ ...state, auth: { ...state.auth, user: null } }))
|
||||
},
|
||||
reset: () =>
|
||||
set((state) => {
|
||||
removeCookie(ACCESS_TOKEN)
|
||||
clearStoredUser()
|
||||
return {
|
||||
...state,
|
||||
auth: { ...state.auth, user: null, accessToken: '' },
|
||||
|
||||
43
web/src/types/api.ts
Normal file
43
web/src/types/api.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 统一的后端响应与 DTO 类型
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
// 基础用户信息(登录返回)
|
||||
export interface UserBasic {
|
||||
id: number
|
||||
username: string
|
||||
display_name?: string
|
||||
role: number
|
||||
status: number
|
||||
group?: string
|
||||
}
|
||||
|
||||
// /api/user/self 返回会更丰富
|
||||
export interface UserSelf extends UserBasic {
|
||||
email?: string
|
||||
quota?: number
|
||||
used_quota?: number
|
||||
request_count?: number
|
||||
aff_code?: string
|
||||
aff_count?: number
|
||||
aff_quota?: number
|
||||
aff_history_quota?: number
|
||||
inviter_id?: number
|
||||
linux_do_id?: string
|
||||
setting?: unknown
|
||||
stripe_customer?: string
|
||||
sidebar_modules?: unknown
|
||||
permissions?: unknown
|
||||
}
|
||||
|
||||
export type User = UserSelf | UserBasic
|
||||
|
||||
export type LoginTwoFAData = { require_2fa: true }
|
||||
export type LoginSuccessData = UserBasic
|
||||
export type LoginResponse = ApiResponse<LoginTwoFAData | LoginSuccessData>
|
||||
export type Verify2FAResponse = ApiResponse<UserBasic>
|
||||
export type SelfResponse = ApiResponse<UserSelf>
|
||||
Reference in New Issue
Block a user