From 58284f3fe07a52b32c622d1955ea1793f80f95f8 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 18 Jun 2026 00:00:38 +0800 Subject: [PATCH] feat: add login/auth system + blog page with rainbow design - Client: auth API wrapper (api/auth.ts) + useAuth hook with localStorage persistence - Client: LoginPage/LoginForm with remember-me and loading states - Client: BlogPage with hero section, rainbow spotlight cursor effect, post list - Client: CSS rainbow gradient utilities for text/borders/backgrounds - Server: POST /api/web/login with username/password verification + token - Server: CORS extended to POST + Content-Type header - Server: auth.rs token issuer, serde dependency for JSON - Chore: gitignore Server/.vite Co-Authored-By: Claude --- .gitignore | 1 + Client/src/App.tsx | 11 +- Client/src/api/auth.ts | 38 +++++ Client/src/components/BlogPage.tsx | 184 +++++++++++++++++++++ Client/src/components/LoginForm.tsx | 72 +++++--- Client/src/components/LoginPage.tsx | 9 +- Client/src/components/RainbowSpotlight.tsx | 62 +++++++ Client/src/components/ui/Button.tsx | 1 + Client/src/hooks/useAuth.ts | 62 +++++++ Client/src/index.css | 113 ++++++++++++- Server/Cargo.lock | 72 ++++++++ Server/Cargo.toml | 5 + Server/src/auth.rs | 93 +++++++++++ Server/src/main.rs | 1 + Server/src/routes/web.rs | 48 +++++- Server/src/state.rs | 4 + 16 files changed, 750 insertions(+), 26 deletions(-) create mode 100644 Client/src/api/auth.ts create mode 100644 Client/src/components/BlogPage.tsx create mode 100644 Client/src/components/RainbowSpotlight.tsx create mode 100644 Client/src/hooks/useAuth.ts create mode 100644 Server/src/auth.rs diff --git a/.gitignore b/.gitignore index 0c8cee5..587939c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ lerna-debug.log* # --- Server --- Server/target +Server/.vite diff --git a/Client/src/App.tsx b/Client/src/App.tsx index fe2646a..8eba02b 100644 --- a/Client/src/App.tsx +++ b/Client/src/App.tsx @@ -1,11 +1,20 @@ import { CursorFollower } from './components/CursorFollower' import { LoginPage } from './components/LoginPage' +import { BlogPage } from './components/BlogPage' +import { useAuth } from './hooks/useAuth' function App() { + // 登录态从 localStorage 恢复:已登录直接进博客页,否则进登录页。 + const auth = useAuth() + return ( <> - + {auth.username ? ( + + ) : ( + + )} ) } diff --git a/Client/src/api/auth.ts b/Client/src/api/auth.ts new file mode 100644 index 0000000..783cdd5 --- /dev/null +++ b/Client/src/api/auth.ts @@ -0,0 +1,38 @@ +// 登录接口封装。后端基址默认本机 8080,可用 VITE_API_BASE 覆盖 +// (与 useSystemStats 保持一致)。 +const API_BASE = + (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080' + +export interface LoginResult { + ok: boolean + username: string + token: string +} + +/** + * 提交用户名/明文密码到后端校验。 + * - 成功:解析 200 响应体(含 token)。 + * - 凭据错误:后端返回 401,这里抛出可读错误。 + * - 后端不可达:fetch 失败,抛出网络错误。 + */ +export async function login(username: string, password: string): Promise { + let res: Response + try { + res = await fetch(`${API_BASE}/api/web/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + } catch { + throw new Error('无法连接服务器,请确认后端已启动') + } + + if (res.status === 401) { + throw new Error('用户名或密码错误') + } + if (!res.ok) { + throw new Error(`登录失败(HTTP ${res.status})`) + } + + return (await res.json()) as LoginResult +} diff --git a/Client/src/components/BlogPage.tsx b/Client/src/components/BlogPage.tsx new file mode 100644 index 0000000..124e8dd --- /dev/null +++ b/Client/src/components/BlogPage.tsx @@ -0,0 +1,184 @@ +import { motion } from 'framer-motion' +import { useSystemStats } from '../hooks/useSystemStats' +import { fadeUp, stagger } from './motion' +import { AnnouncementBar } from './AnnouncementBar' +import { RainbowSpotlight } from './RainbowSpotlight' + +interface BlogPageProps { + /** 当前登录用户名 */ + username: string + /** 退出登录 */ + onSignOut: () => void +} + +interface Post { + title: string + date: string + readingTime: string + excerpt: string + tag: string +} + +// 示例文章数据(占位)。将来可换成从后端 /api/web/posts 拉取。 +const POSTS: Post[] = [ + { + title: 'An Interactive Guide to CSS Gradients', + date: 'Jun 2026', + readingTime: '12 min', + excerpt: + '从线性到锥形渐变,拆解每个参数如何影响最终画面,并顺手做一道流动的彩虹。', + tag: 'CSS', + }, + { + title: '用 Rust + axum 写一个会呼吸的监控后端', + date: 'May 2026', + readingTime: '9 min', + excerpt: + '采样与请求解耦、RwLock 快照、CORS 只拦浏览器——把顶部栏那行系统状态讲透。', + tag: 'Rust', + }, + { + title: 'framer-motion 里那些恰到好处的微交互', + date: 'Apr 2026', + readingTime: '7 min', + excerpt: '光标跟随、入场编排、spring 手感。动效不是炫技,是把注意力放对地方。', + tag: 'Motion', + }, + { + title: '为什么我给只有 20 个用户的系统拒绝了数据库', + date: 'Mar 2026', + readingTime: '6 min', + excerpt: '一个进程内 HashMap 就够了。聊聊在小规模下,简单是怎样跑赢“正确架构”的。', + tag: 'Backend', + }, +] + +export function BlogPage({ username, onSignOut }: BlogPageProps) { + // 顶部栏沿用系统状态彩蛋,保持与登录页同一风格。 + const stats = useSystemStats() + + return ( +
+ + + {/* 顶部导航 */} +
+
+ + cc.dev + +
+ + Signed in as {username} + + +
+
+
+ +
+ {/* Hero:整段全宽,彩虹光斑横跨整个页面;文字内容仍居中限宽 */} +
+ + + + + Personal blog + + + + Hello, I'm cc + _ + + + + 我在这里写关于前端动效、Rust 后端和把事情做简单的笔记。把鼠标移到这片区域, + 会有一道彩虹跟着你——灵感来自{' '} + + Josh W. Comeau + + 。 + + +
+ + {/* 文章列表 */} +
+

+ Latest writing +

+ + + {POSTS.map((post) => ( + + + {/* 左侧彩虹竖条:hover 时出现 */} + +
+
{post.date}
+
{post.tag}
+
+
+

+ {post.title} +

+

+ {post.excerpt} +

+ + {post.readingTime} read + +
+
+
+ ))} +
+
+
+ + {/* 页脚:一条流动彩虹装饰线 */} +
+
+
+ © {new Date().getFullYear()} cc + built with React · Rust · 🌈 +
+
+
+ ) +} diff --git a/Client/src/components/LoginForm.tsx b/Client/src/components/LoginForm.tsx index 198e45f..c55f634 100644 --- a/Client/src/components/LoginForm.tsx +++ b/Client/src/components/LoginForm.tsx @@ -1,14 +1,35 @@ +import { useState } from 'react' import type { FormEvent } from 'react' import { motion } from 'framer-motion' import { fadeUp, stagger } from './motion' import { TextField } from './ui/TextField' import { Button } from './ui/Button' -// 右侧登录表单:纯 UI,暂无后端逻辑。 -export function LoginForm() { - const handleSubmit = (e: FormEvent) => { +interface LoginFormProps { + /** 校验凭据;失败时抛错 */ + onSignIn: (username: string, password: string, remember: boolean) => Promise +} + +// 右侧登录表单:接 Rust 后端鉴权。无注册入口(用户由后端写死)。 +export function LoginForm({ onSignIn }: LoginFormProps) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [remember, setRemember] = useState(true) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = async (e: FormEvent) => { e.preventDefault() - // TODO: 接入 Rust 后端鉴权 + if (submitting) return + setError(null) + setSubmitting(true) + try { + await onSignIn(username.trim(), password, remember) + // 成功后由上层切换到博客页,本组件随之卸载,无需额外处理。 + } catch (err) { + setError(err instanceof Error ? err.message : '登录失败') + setSubmitting(false) + } } return ( @@ -25,7 +46,15 @@ export function LoginForm() {
- + setUsername(e.target.value)} + required + /> @@ -34,9 +63,23 @@ export function LoginForm() { type="password" name="password" autoComplete="current-password" + value={password} + onChange={(e) => setPassword(e.target.value)} + required /> + {error && ( + + {error} + + )} + setRemember(e.target.checked)} className="h-4 w-4 accent-[var(--color-accent)]" /> Remember me - - Forgot password? - - +
- - - New here?{' '} - - Create an account - - ) } diff --git a/Client/src/components/LoginPage.tsx b/Client/src/components/LoginPage.tsx index cb15ff1..bcf4284 100644 --- a/Client/src/components/LoginPage.tsx +++ b/Client/src/components/LoginPage.tsx @@ -1,9 +1,14 @@ import { useSystemStats } from '../hooks/useSystemStats' +import type { Auth } from '../hooks/useAuth' import { AnnouncementBar } from './AnnouncementBar' import { AtmospherePanel } from './AtmospherePanel' import { LoginForm } from './LoginForm' -export function LoginPage() { +interface LoginPageProps { + onSignIn: Auth['signIn'] +} + +export function LoginPage({ onSignIn }: LoginPageProps) { // 每秒轮询后端系统状态,展示在顶部栏。 const stats = useSystemStats() @@ -14,7 +19,7 @@ export function LoginPage() {
- +
diff --git a/Client/src/components/RainbowSpotlight.tsx b/Client/src/components/RainbowSpotlight.tsx new file mode 100644 index 0000000..454be93 --- /dev/null +++ b/Client/src/components/RainbowSpotlight.tsx @@ -0,0 +1,62 @@ +import { useRef } from 'react' +import { motion, useMotionValue, useReducedMotion, useSpring } from 'framer-motion' + +// 跟随鼠标的彩虹光斑(仿 joshwcomeau 的交互彩虹)。 +// 自身 pointer-events-none、置于内容下层,靠监听父元素的鼠标事件取坐标, +// 因此不会挡住 hero 里的文字交互。挂在一个 relative 容器内使用。 +export function RainbowSpotlight() { + const ref = useRef(null) + const reduce = useReducedMotion() + + const x = useMotionValue(0) + const y = useMotionValue(0) + const springX = useSpring(x, { stiffness: 180, damping: 28, mass: 0.6 }) + const springY = useSpring(y, { stiffness: 180, damping: 28, mass: 0.6 }) + const opacity = useMotionValue(0) + const springOpacity = useSpring(opacity, { stiffness: 120, damping: 26 }) + + // 在父元素上挂监听:pointer-events-none 收不到事件,故委托给父级。 + const attach = (node: HTMLDivElement | null) => { + ref.current = node + const parent = node?.parentElement + if (!parent) return + const onMove = (e: MouseEvent) => { + const rect = parent.getBoundingClientRect() + x.set(e.clientX - rect.left) + y.set(e.clientY - rect.top) + opacity.set(0.45) + } + const onLeave = () => opacity.set(0) + parent.addEventListener('mousemove', onMove) + parent.addEventListener('mouseleave', onLeave) + } + + const RAINBOW = + 'conic-gradient(from 0deg, #ff5f6d, #ffb13d, #ffe14d, #4ade80, #38bdf8, #a78bfa, #f472b6, #ff5f6d)' + + return ( +
+ +
+ ) +} diff --git a/Client/src/components/ui/Button.tsx b/Client/src/components/ui/Button.tsx index 784a8c5..3802aae 100644 --- a/Client/src/components/ui/Button.tsx +++ b/Client/src/components/ui/Button.tsx @@ -17,6 +17,7 @@ export function Button({ children, className = '', ...props }: ButtonProps) { 'group relative w-full overflow-hidden rounded-full bg-accent px-6 py-3.5 ' + 'text-sm font-medium uppercase tracking-[0.18em] text-bg ' + 'transition-colors hover:bg-accent-soft ' + + 'disabled:cursor-not-allowed disabled:opacity-60 ' + className } {...props} diff --git a/Client/src/hooks/useAuth.ts b/Client/src/hooks/useAuth.ts new file mode 100644 index 0000000..b047386 --- /dev/null +++ b/Client/src/hooks/useAuth.ts @@ -0,0 +1,62 @@ +import { useCallback, useState } from 'react' +import { login as loginRequest } from '../api/auth' + +// localStorage 键:记住登录态时把会话写在这里,刷新后仍处于登录态。 +const STORAGE_KEY = 'ip.auth' + +interface Session { + username: string + token: string +} + +function loadSession(): Session | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.username === 'string' && typeof parsed.token === 'string') { + return { username: parsed.username, token: parsed.token } + } + } catch { + // 解析失败(被手动改坏等)当作未登录 + } + return null +} + +export interface Auth { + /** 已登录则为当前用户名,否则为 null */ + username: string | null + /** 校验凭据;remember 为 true 时持久化到 localStorage */ + signIn: (username: string, password: string, remember: boolean) => Promise + /** 退出登录并清除持久化会话 */ + signOut: () => void +} + +/** + * 登录态管理。初始值从 localStorage 恢复(记住登录态), + * 因此刷新页面后仍停留在登录后界面。 + */ +export function useAuth(): Auth { + const [session, setSession] = useState(loadSession) + + const signIn = useCallback( + async (username: string, password: string, remember: boolean) => { + const result = await loginRequest(username, password) + const next: Session = { username: result.username, token: result.token } + setSession(next) + if (remember) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + } else { + localStorage.removeItem(STORAGE_KEY) + } + }, + [], + ) + + const signOut = useCallback(() => { + setSession(null) + localStorage.removeItem(STORAGE_KEY) + }, []) + + return { username: session?.username ?? null, signIn, signOut } +} diff --git a/Client/src/index.css b/Client/src/index.css index d0daedc..e3a12cd 100644 --- a/Client/src/index.css +++ b/Client/src/index.css @@ -99,10 +99,121 @@ body { animation: blink 1.15s steps(1, end) infinite; } +/* ── 彩虹(仿 joshwcomeau)───────────────────────────────────── + 一条横向流动的多色渐变。首尾同色,配合 200% 宽度做无缝平移。 + 既可裁切到文字,也可铺在装饰条上。 */ +@keyframes rainbow-pan { + to { + background-position: 200% center; + } +} + +.rainbow-gradient { + background-image: linear-gradient( + 90deg, + #ff5f6d, + #ffb13d, + #ffe14d, + #4ade80, + #38bdf8, + #a78bfa, + #f472b6, + #ff5f6d + ); + background-size: 200% auto; + animation: rainbow-pan 6s linear infinite; +} + +/* 把流动彩虹裁切进文字(始终显示彩虹色) */ +.rainbow-text { + background-image: linear-gradient( + 90deg, + #ff5f6d, + #ffb13d, + #ffe14d, + #4ade80, + #38bdf8, + #a78bfa, + #f472b6, + #ff5f6d + ); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: rainbow-pan 6s linear infinite; +} + +/* 把彩虹裁进文字但不强制颜色:默认用不透明文字色盖住, + hover 时把文字色设为 transparent(group-hover:text-transparent)即露出彩虹。 */ +.rainbow-clip { + background-image: linear-gradient( + 90deg, + #ff5f6d, + #ffb13d, + #ffe14d, + #4ade80, + #38bdf8, + #a78bfa, + #f472b6, + #ff5f6d + ); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + animation: rainbow-pan 6s linear infinite; +} + +/* ── 整页彩虹极光背景(玻璃栏透出的就是它)────────────────────── + 固定铺满视口、置于所有内容之下。多个彩色径向光斑缓慢漂移, + 再大幅模糊成柔和极光。底色为暗色,避免内容区透出时发灰。 */ +.aurora { + position: fixed; + inset: 0; + z-index: -10; + overflow: hidden; + background-color: var(--color-bg); +} + +.aurora::before { + content: ''; + position: absolute; + inset: -25%; + background: + radial-gradient(28% 30% at 18% 28%, rgba(255, 95, 109, 0.55), transparent 70%), + radial-gradient(26% 30% at 82% 26%, rgba(56, 189, 248, 0.50), transparent 70%), + radial-gradient(30% 34% at 72% 76%, rgba(167, 139, 250, 0.50), transparent 70%), + radial-gradient(28% 32% at 26% 80%, rgba(74, 222, 128, 0.45), transparent 70%), + radial-gradient(24% 28% at 50% 50%, rgba(255, 177, 61, 0.40), transparent 70%); + filter: blur(70px); + animation: drift 28s ease-in-out infinite; + will-change: transform; +} + +/* ── 两侧液态玻璃竖栏 ───────────────────────────────────────── + backdrop-filter 把背后的极光磨成柔光;内侧一道高光边模拟玻璃厚度。 */ +.glass-rail { + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + box-shadow: inset 0 0 60px rgba(255, 255, 255, 0.03); +} + +/* 中间内容纸张:也带轻微玻璃感(压暗 + 模糊背后极光保证可读) */ +.glass-sheet { + background: rgba(23, 19, 15, 0.82); + backdrop-filter: blur(22px) saturate(120%); + -webkit-backdrop-filter: blur(22px) saturate(120%); +} + /* ── reduced-motion 降级 ────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { .mesh, - .cursor-bar { + .cursor-bar, + .rainbow-gradient, + .rainbow-text, + .rainbow-clip, + .aurora::before { animation: none; } } diff --git a/Server/Cargo.lock b/Server/Cargo.lock index e604ff3..0df6558 100644 --- a/Server/Cargo.lock +++ b/Server/Cargo.lock @@ -66,6 +66,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.11.1" @@ -78,6 +87,35 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -146,6 +184,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "http" version = "1.4.2" @@ -517,11 +565,23 @@ dependencies = [ "axum", "serde", "serde_json", + "sha2", "sysinfo", "tokio", "tower-http", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -700,6 +760,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.9.0" @@ -712,6 +778,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Server/Cargo.toml b/Server/Cargo.toml index 38813b1..142568a 100644 --- a/Server/Cargo.toml +++ b/Server/Cargo.toml @@ -3,10 +3,15 @@ name = "server" version = "0.1.0" edition = "2024" +[[bin]] +name = "RustServer" +path = "src/main.rs" + [dependencies] axum = "0.8.9" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +sha2 = "0.10.9" sysinfo = "0.39.3" tokio = { version = "1.52.3", features = ["full"] } tower-http = { version = "0.7.0", features = ["cors", "fs"] } diff --git a/Server/src/auth.rs b/Server/src/auth.rs new file mode 100644 index 0000000..e396b2b --- /dev/null +++ b/Server/src/auth.rs @@ -0,0 +1,93 @@ +//! 用户存储与登录校验。 +//! +//! 设计取舍:用户数很少(≤ 20),所以**不上数据库**——一个进程内的 +//! `HashMap<用户名, 密码SHA256(hex)>` 足矣,启动时构建一次、之后只读。 +//! 数据库要付出的连接池、迁移、Schema 维护成本,在这个规模下并不划算。 +//! +//! 现在用户表是**写死**在 `UserStore::seed()` 里的(仅 `cc` 一个)。 +//! 将来用户变多时,迁移路径很短,且 handler 完全无需改动: +//! 1. 改成从 TOML/JSON 文件读:把 `seed()` 换成读文件 → 解析 → 填 map; +//! 2. 真要运行时增删用户 / 权限 / 审计,再考虑换 SQLite/Postgres。 +//! +//! 口令永不明文存储:表里存的是 SHA256(hex),登录时把传入明文同样 +//! 哈希后做**等长比较**。(注意:SHA256 仅用于“不落明文”,并非抗暴力破解的 +//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。) + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; + +/// 进程内用户表:用户名 → 密码的 SHA256(小写 hex)。 +pub struct UserStore { + users: HashMap, +} + +impl UserStore { + /// 构建写死的用户表。将来可替换为「从文件读取」。 + pub fn seed() -> Self { + let mut users = HashMap::new(); + + // cc / 192118Lht + // 下面这串是 SHA256("192118Lht") 的 hex,明文不进源码。 + users.insert( + "cc".to_string(), + "0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(), + ); + + Self { users } + } + + /// 校验用户名 + 明文密码是否匹配。 + /// + /// 用户名不存在与密码错误都返回 `false`,不向外区分两者 + /// (避免泄露“某用户名是否存在”)。 + pub fn verify(&self, username: &str, password: &str) -> bool { + match self.users.get(username) { + Some(expected_hex) => { + let actual_hex = sha256_hex(password); + constant_time_eq(actual_hex.as_bytes(), expected_hex.as_bytes()) + } + None => false, + } + } +} + +/// 签发一个登录 token。 +/// +/// 不引入 `rand`/`uuid`:用「纳秒时间戳 + 进程内单调计数器」拼出唯一输入, +/// 再 SHA256 成不可预测的 hex。够当登录态标识用;将来要做真正的会话校验, +/// 应换成签名 token(如 JWT)或服务端会话表。 +pub fn issue_token() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + sha256_hex(&format!("{nanos}-{n}")) +} + +/// 计算 SHA256 并返回小写 hex 字符串。 +fn sha256_hex(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// 等长定值时间比较,避免按字节短路造成的时序侧信道。 +/// 两串长度不同直接判否(hex 定长 64,正常等长)。 +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} diff --git a/Server/src/main.rs b/Server/src/main.rs index 4de2f0e..534f617 100644 --- a/Server/src/main.rs +++ b/Server/src/main.rs @@ -4,6 +4,7 @@ //! - `GET /api/web/stats` 给 React 前端(挂 CORS) //! - `GET /api/client/health` 给未来 .NET 客户端(不挂 CORS) +mod auth; mod monitor; mod routes; mod state; diff --git a/Server/src/routes/web.rs b/Server/src/routes/web.rs index af243a8..88de4e5 100644 --- a/Server/src/routes/web.rs +++ b/Server/src/routes/web.rs @@ -1,21 +1,31 @@ //! 前端(React)专用接口。挂 CORS,仅放行前端开发源。 -use axum::{Json, Router, extract::State, http::Method, routing::get}; +use axum::{ + Json, Router, + extract::State, + http::{Method, StatusCode, header}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::{monitor::Stats, state::AppState}; pub fn router(state: AppState) -> Router { - // CORS:只有浏览器会校验。放行前端开发源 + GET 方法即可。 + // CORS:只有浏览器会校验。放行前端开发源。 + // 登录是 POST + JSON,所以要额外放行 POST 方法与 Content-Type 请求头, + // 否则浏览器的预检(OPTIONS)会被拦下。 let cors = CorsLayer::new() .allow_origin(AllowOrigin::list([ "http://localhost:5173".parse().unwrap(), "http://127.0.0.1:5173".parse().unwrap(), ])) - .allow_methods([Method::GET]); + .allow_methods([Method::GET, Method::POST]) + .allow_headers([header::CONTENT_TYPE]); Router::new() .route("/api/web/stats", get(stats)) + .route("/api/web/login", post(login)) .layer(cors) .with_state(state) } @@ -25,3 +35,35 @@ async fn stats(State(state): State) -> Json { let snapshot = state.snapshot.read().await.clone(); Json(snapshot) } + +/// 登录请求体:用户名 + 明文密码(开发期走 HTTP;生产应套 HTTPS)。 +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +/// 登录成功响应。`token` 目前只是给前端持久化「登录态」用的标识, +/// 暂无受保护接口去校验它;将来加鉴权中间件时再赋予真实含义。 +#[derive(Serialize)] +struct LoginResponse { + ok: bool, + username: String, + token: String, +} + +/// 校验用户名/密码。成功 200 + token,失败 401。 +async fn login( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + if state.users.verify(&req.username, &req.password) { + Ok(Json(LoginResponse { + ok: true, + username: req.username, + token: crate::auth::issue_token(), + })) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} diff --git a/Server/src/state.rs b/Server/src/state.rs index 70eab70..acc5fda 100644 --- a/Server/src/state.rs +++ b/Server/src/state.rs @@ -7,18 +7,22 @@ use std::sync::Arc; use tokio::sync::RwLock; +use crate::auth::UserStore; use crate::monitor::Stats; #[derive(Clone)] pub struct AppState { /// 最新一次采样快照。 pub snapshot: Arc>, + /// 用户表:启动时构建一次、之后只读,故无需锁,用 `Arc` 共享即可。 + pub users: Arc, } impl AppState { pub fn new() -> Self { Self { snapshot: Arc::new(RwLock::new(Stats::default())), + users: Arc::new(UserStore::seed()), } } }