feat(auth): 重构认证流程并补充前端登录态保护

- 拆分后端认证处理与会话创建逻辑,支持登录、注册、退出、个人资料和改密接口
- 增加二次验证挑战、Turnstile 人机校验与旧版邮箱迁移清理
- 新增前端认证守卫、管理员访问控制、退出 Hook 与验证工具
- 统一抽离接口类型,优化登录页、注册页和个人资料页的认证交互
This commit is contained in:
LanQin_
2026-06-16 10:10:21 +08:00
parent be8cd4be31
commit 45122162d0
20 changed files with 595 additions and 417 deletions
+11
View File
@@ -0,0 +1,11 @@
import React from "react"
import { Navigate } from "react-router-dom"
import { useMe } from "@/hooks/use-me"
export function AdminOnly({ children }: { children: React.ReactNode }) {
const me = useMe()
if (me.isLoading) return null
if (!me.data?.user) return <Navigate to="/login" replace />
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
return <>{children}</>
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react"
import { Navigate, useLocation } from "react-router-dom"
import { useMe, isTimeoutError } from "@/hooks/use-me"
import { AuthLoading, AuthError } from "@/components/auth-states"
export function AuthGuard({ children }: { children: React.ReactNode }) {
const me = useMe()
const location = useLocation()
if (me.isLoading) return <AuthLoading />
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
return <>{children}</>
}
+17
View File
@@ -0,0 +1,17 @@
import { Button } from "@/components/ui/button"
export function AuthLoading() {
return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div>
}
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="text-sm font-medium"></div>
<div className="text-sm text-muted-foreground">{message}</div>
<Button type="button" variant="outline" onClick={onRetry}></Button>
</div>
</div>
)
}
+13 -32
View File
@@ -1,9 +1,9 @@
import * as React from "react"
import { Navigate, Outlet, Link, useLocation, useNavigate } from "react-router-dom"
import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react"
import { useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
@@ -34,27 +34,24 @@ const adminSections = [
]
export function ProtectedLayout() {
return (
<AuthGuard>
<ProtectedContent />
</AuthGuard>
)
}
function ProtectedContent() {
const me = useMe()
const location = useLocation()
const navigate = useNavigate()
const qc = useQueryClient()
const logout = useLogout()
if (me.isLoading) return <AuthLoading />
if (me.isError && me.error.message.includes("请求超时")) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
const user = me.data.user
const user = me.data!.user
const isMailRoute = location.pathname === "/" || location.pathname.startsWith("/mail")
const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
async function logout() {
await api.logout().catch(() => undefined)
qc.clear()
navigate("/login", { replace: true })
}
if (isMailRoute || isProfileRoute) {
return <Outlet />
}
@@ -138,19 +135,3 @@ export function ProtectedLayout() {
</SidebarProvider>
)
}
function AuthLoading() {
return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div>
}
function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="text-sm font-medium"></div>
<div className="text-sm text-muted-foreground">{message}</div>
<Button type="button" variant="outline" onClick={onRetry}></Button>
</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react"
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: { sitekey: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
remove: (widgetId: string) => void
}
}
}
export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
const ref = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!siteKey || !ref.current) return
let cancelled = false
let widgetId = ""
function render() {
if (cancelled || !ref.current || !window.turnstile) return
ref.current.innerHTML = ""
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
"error-callback": () => onToken(""),
})
}
if (window.turnstile) {
render()
} else {
const existing = document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]')
if (existing) {
existing.addEventListener("load", render, { once: true })
} else {
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.addEventListener("load", render, { once: true })
document.head.appendChild(script)
}
}
return () => {
cancelled = true
onToken("")
if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
}
}, [siteKey, onToken])
return <div className="flex justify-center rounded-md border p-2"><div ref={ref} /></div>
}
+14
View File
@@ -0,0 +1,14 @@
import { useCallback } from "react"
import { useNavigate } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
export function useLogout() {
const qc = useQueryClient()
const navigate = useNavigate()
return useCallback(async () => {
await api.logout().catch(() => undefined)
qc.clear()
navigate("/login", { replace: true })
}, [qc, navigate])
}
+17 -3
View File
@@ -1,6 +1,20 @@
import { useQuery } from "@tanstack/react-query"
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { api } from "@/lib/api"
import type { User } from "@/lib/api"
export function useMe() {
return useQuery({ queryKey: ["me"], queryFn: api.me, retry: 1 })
type MeResponse = { user: User }
export function useMe(
options?: Omit<UseQueryOptions<MeResponse, Error, MeResponse, ["me"]>, "queryKey" | "queryFn">,
) {
return useQuery({
queryKey: ["me"],
queryFn: api.me,
retry: 1,
...options,
})
}
export function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message.includes("请求超时")
}
+54
View File
@@ -0,0 +1,54 @@
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
labels?: MailLabel[]
}
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
export type ListResponse<T> = { items: T[]; nextCursor?: string }
export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type SystemSettings = {
publicHostname: string
publicBaseUrl: string
smtpHost: string
smtpPort: string
smtpUsername: string
smtpPasswordSet: boolean
smtpRequireTls: boolean
maildirRoot: string
maildirScanSeconds: number
sessionTtlHours: number
allowInsecureHttp: boolean
openRegistration: boolean
twoFactorEnabled: boolean
turnstileEnabled: boolean
turnstileSiteKey: string
turnstileSecretSet: boolean
catchAllEnabled: boolean
mailAutoRefresh: boolean
mailRefreshSeconds: number
userMailboxApplyEnabled: boolean
userMailboxDomainIds: string[]
reservedMailboxPrefixes: string
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number }
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string }
+2 -54
View File
@@ -1,57 +1,5 @@
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
labels?: MailLabel[]
}
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
export type ListResponse<T> = { items: T[]; nextCursor?: string }
export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type SystemSettings = {
publicHostname: string
publicBaseUrl: string
smtpHost: string
smtpPort: string
smtpUsername: string
smtpPasswordSet: boolean
smtpRequireTls: boolean
maildirRoot: string
maildirScanSeconds: number
sessionTtlHours: number
allowInsecureHttp: boolean
openRegistration: boolean
twoFactorEnabled: boolean
turnstileEnabled: boolean
turnstileSiteKey: string
turnstileSecretSet: boolean
catchAllEnabled: boolean
mailAutoRefresh: boolean
mailRefreshSeconds: number
userMailboxApplyEnabled: boolean
userMailboxDomainIds: string[]
reservedMailboxPrefixes: string
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number }
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string }
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, Contact, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
+9
View File
@@ -0,0 +1,9 @@
export function validatePasswordConfirm(
password: string,
confirmPassword: string,
message?: string,
): void {
if (password !== confirmPassword) {
throw new Error(message || "两次输入的密码不一致")
}
}
+1 -9
View File
@@ -4,12 +4,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
import { Toaster } from "@/components/ui/toaster"
import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
import { LoginPage } from "@/pages/login"
import { RegisterPage } from "@/pages/register"
import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile"
import { useMe } from "@/hooks/use-me"
import "./index.css"
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
@@ -25,14 +25,6 @@ const router = createBrowserRouter([
] },
])
function AdminOnly({ children }: { children: React.ReactNode }) {
const me = useMe()
if (me.isLoading) return null
if (!me.data?.user) return <Navigate to="/login" replace />
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
return <>{children}</>
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
+1 -48
View File
@@ -3,6 +3,7 @@ import { Link, Navigate } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
import { TurnstileBox } from "@/components/turnstile-box"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -73,51 +74,3 @@ export function LoginPage() {
)
}
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: { sitekey: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
remove: (widgetId: string) => void
}
}
}
export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
const ref = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!siteKey || !ref.current) return
let cancelled = false
let widgetId = ""
function render() {
if (cancelled || !ref.current || !window.turnstile) return
ref.current.innerHTML = ""
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
"error-callback": () => onToken(""),
})
}
if (window.turnstile) {
render()
} else {
const existing = document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]')
if (existing) {
existing.addEventListener("load", render, { once: true })
} else {
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.addEventListener("load", render, { once: true })
document.head.appendChild(script)
}
}
return () => {
cancelled = true
onToken("")
if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
}
}, [siteKey, onToken])
return <div className="flex justify-center rounded-md border p-2"><div ref={ref} /></div>
}
+4 -2
View File
@@ -9,6 +9,8 @@ import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { validatePasswordConfirm } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -75,7 +77,7 @@ export function ProfilePage() {
const password = useMutation({
mutationFn: (form: FormData) => {
const newPassword = String(form.get("newPassword") || "")
if (newPassword !== String(form.get("confirmPassword") || "")) throw new Error("两次输入的新密码不一致")
validatePasswordConfirm(newPassword, String(form.get("confirmPassword") || ""), "两次输入的新密码不一致")
return api.changePassword({ currentPassword: String(form.get("currentPassword") || ""), newPassword })
},
onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) },
@@ -159,7 +161,7 @@ export function ProfilePage() {
React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId); else localStorage.removeItem("lanqin:selected-mailbox") }, [mailboxId])
React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current); themeMountedRef.current = true }, [darkMode])
async function logout() { await api.logout().catch(() => undefined); qc.clear(); navigate("/login", { replace: true }) }
const logout = useLogout()
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) }
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
+3 -2
View File
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast"
import { TurnstileBox } from "@/pages/login"
import { TurnstileBox } from "@/components/turnstile-box"
import { validatePasswordConfirm } from "@/lib/validation"
export function RegisterPage() {
const me = useMe()
@@ -20,7 +21,7 @@ export function RegisterPage() {
mutationFn: (form: FormData) => {
const password = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "")
if (password !== confirmPassword) throw new Error("两次输入的密码不一致")
validatePasswordConfirm(password, confirmPassword)
return api.register({
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),