feat(auth): 重构认证流程并补充前端登录态保护
- 拆分后端认证处理与会话创建逻辑,支持登录、注册、退出、个人资料和改密接口 - 增加二次验证挑战、Turnstile 人机校验与旧版邮箱迁移清理 - 新增前端认证守卫、管理员访问控制、退出 Hook 与验证工具 - 统一抽离接口类型,优化登录页、注册页和个人资料页的认证交互
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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") || ""),
|
||||
|
||||
Reference in New Issue
Block a user