From 347c31f93cbc693bc8a6558aee03f480db3e1e00 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 26 Sep 2025 01:03:17 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20feat(auth):=20integrate=20backen?= =?UTF-8?q?d=20session=20login=20+=202FA;=20add=20HTTP=20client=20and=20ro?= =?UTF-8?q?ute=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- web/src/components/sign-out-dialog.tsx | 2 +- .../features/auth/otp/components/otp-form.tsx | 20 ++- .../sign-in/components/user-auth-form.tsx | 66 ++++------ web/src/lib/auth.ts | 31 +++++ web/src/lib/http.ts | 121 ++++++++++++++++++ web/src/routes/(auth)/sign-in.tsx | 7 +- web/src/routes/_authenticated/route.tsx | 17 ++- web/src/stores/auth-store.ts | 93 ++++++++++++++ web/src/types/api.ts | 43 +++++++ 9 files changed, 348 insertions(+), 52 deletions(-) create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/http.ts create mode 100644 web/src/types/api.ts diff --git a/web/src/components/sign-out-dialog.tsx b/web/src/components/sign-out-dialog.tsx index 5f7e70de5..a4722f499 100644 --- a/web/src/components/sign-out-dialog.tsx +++ b/web/src/components/sign-out-dialog.tsx @@ -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({ diff --git a/web/src/features/auth/otp/components/otp-form.tsx b/web/src/features/auth/otp/components/otp-form.tsx index 7c8ce9867..7f6a24057 100644 --- a/web/src/features/auth/otp/components/otp-form.tsx +++ b/web/src/features/auth/otp/components/otp-form.tsx @@ -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) { - setIsLoading(true) - showSubmittedData(data) + const { auth } = useAuthStore() - setTimeout(() => { - setIsLoading(false) + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + await auth.verify2FA(data.otp) + toast.success('登录成功') navigate({ to: '/' }) - }, 1000) + } catch (e: any) { + toast.error(e?.message || '验证失败,请重试') + } finally { + setIsLoading(false) + } } return ( diff --git a/web/src/features/auth/sign-in/components/user-auth-form.tsx b/web/src/features/auth/sign-in/components/user-auth-form.tsx index e3fb831af..3f2d91d20 100644 --- a/web/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/src/features/auth/sign-in/components/user-auth-form.tsx @@ -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 { @@ -45,40 +40,29 @@ export function UserAuthForm({ const form = useForm>({ resolver: zodResolver(formSchema), - defaultValues: { - email: '', - password: '', - }, + defaultValues: { username: '', password: '' }, }) - function onSubmit(data: z.infer) { + async function onSubmit(data: z.infer) { 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({ > ( - Email + Username or Email - + diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 000000000..f91810497 --- /dev/null +++ b/web/src/lib/auth.ts @@ -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) +} diff --git a/web/src/lib/http.ts b/web/src/lib/http.ts new file mode 100644 index 000000000..a6eefe3ef --- /dev/null +++ b/web/src/lib/http.ts @@ -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 | Array | undefined + +export interface HttpError { + status: number + message: string + details?: unknown +} + +async function safeMessage(res: Response): Promise { + 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( + input: RequestInfo | URL, + init: RequestInit & { timeoutMs?: number; asText?: boolean } = {} +): Promise { + 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)['Authorization'] = + `Bearer ${JSON.parse(token)}` + } + + // 携带 New-Api-User 以通过后端鉴权 + const userId = getStoredUserId() + if (typeof userId === 'number' && userId > 0) { + ;(headers as Record)['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(url: string, init?: RequestInit) { + return http(url, { ...init, method: 'GET' }) +} + +export async function post( + url: string, + body?: JsonBody, + init?: RequestInit +) { + return http(url, { + ...init, + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }) +} + +export async function put(url: string, body?: JsonBody, init?: RequestInit) { + return http(url, { + ...init, + method: 'PUT', + body: body ? JSON.stringify(body) : undefined, + }) +} + +export async function patch( + url: string, + body?: JsonBody, + init?: RequestInit +) { + return http(url, { + ...init, + method: 'PATCH', + body: body ? JSON.stringify(body) : undefined, + }) +} + +export async function del(url: string, init?: RequestInit) { + return http(url, { ...init, method: 'DELETE' }) +} diff --git a/web/src/routes/(auth)/sign-in.tsx b/web/src/routes/(auth)/sign-in.tsx index 22fbf76af..454e85f88 100644 --- a/web/src/routes/(auth)/sign-in.tsx +++ b/web/src/routes/(auth)/sign-in.tsx @@ -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, }) diff --git a/web/src/routes/_authenticated/route.tsx b/web/src/routes/_authenticated/route.tsx index 39ea1a30e..4e42a4761 100644 --- a/web/src/routes/_authenticated/route.tsx +++ b/web/src/routes/_authenticated/route.tsx @@ -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 + }, }) diff --git a/web/src/stores/auth-store.ts b/web/src/stores/auth-store.ts index 33d368ff3..571437198 100644 --- a/web/src/stores/auth-store.ts +++ b/web/src/stores/auth-store.ts @@ -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 + fetchSelf: () => Promise + logout: () => Promise reset: () => void } } @@ -40,9 +56,86 @@ export const useAuthStore = create()((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(`/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('/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('/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: '' }, diff --git a/web/src/types/api.ts b/web/src/types/api.ts new file mode 100644 index 000000000..ad38c52ca --- /dev/null +++ b/web/src/types/api.ts @@ -0,0 +1,43 @@ +// 统一的后端响应与 DTO 类型 + +export type ApiResponse = { + 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 +export type Verify2FAResponse = ApiResponse +export type SelfResponse = ApiResponse