Răsfoiți Sursa

🔐 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.
t0ng7u 3 luni în urmă
părinte
comite
347c31f93c

+ 1 - 1
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({

+ 13 - 7
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<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 (

+ 25 - 41
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<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='[email protected]' {...field} />
+                <Input placeholder='your username or email' {...field} />
               </FormControl>
               <FormMessage />
             </FormItem>

+ 31 - 0
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)
+}

+ 121 - 0
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<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' })
+}

+ 6 - 1
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,
 })

+ 15 - 2
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 <AuthenticatedLayout />
+  },
 })

+ 93 - 0
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<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 - 0
web/src/types/api.ts

@@ -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>