🔐 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:
t0ng7u
2025-09-26 01:03:17 +08:00
parent 836ae7affe
commit 347c31f93c
9 changed files with 348 additions and 52 deletions

View File

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

View File

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

View File

@@ -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
View 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
View 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' })
}

View File

@@ -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,
})

View File

@@ -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 />
},
})

View File

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