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
+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") || ""),