Skip to content

Frontend Auth And Admin

longsizhuo edited this page Apr 17, 2026 · 1 revision

前端 Auth 与 Admin 体系

登录、会话、权限、管理员后台的前端侧设计。对应 PR:

配合的后端运维文档在后端仓 docs/database.md(DB 自托管 + pgAdmin + forward_auth)。

快速心智模型

  • 登录:GitHub OAuth → 后端 /oauth/render/github → GitHub → 后端 /api/auth/callback/github → 302 回 /#token=xxx
  • 会话:satoken(UUID)存两份 —— localStorage.satoken 给同源 fetch;cookie satoken; Domain=.involutionhell.com 给跨子域直连(pgAdmin)
  • 权限:后端 /auth/me 返回 roles: string[]user / admin / superadmin),前端据此决定 UI / 路由可见性;真正的安全兜底在后端 @SaCheckRole
  • 数据库:与前端组件关系是"管理员 UI 入口",不是"前端直连" —— 从 2026-04-17 起前端运行时零 Prisma 依赖

satoken 生命周期

lib/use-auth.tsxAuthProvider 里。

1. 获取

登录回跳时 URL 带 #token=xxxfragment 而不是 query,不会进 server log / Referer):

https://involutionhell.com/#token=abc123...

AuthProvider 首屏解析 fragment → localStorage.setItem("satoken", urlToken)replaceState 清掉 fragment。

2. 双写 cookie

紧跟着调 syncTokenCookie(urlToken),把同一个 token 写到 cookie:

satoken=<token>; Path=/; Domain=.involutionhell.com; Max-Age=2592000; SameSite=Lax; Secure

为什么需要 cookie 这份副本:业务 API 走 Next.js rewrite,同源 fetch 可以手动附 satoken header;但用户直连 api.involutionhell.com/admin/pgadmin/*(新标签页点 pgAdmin)时浏览器不会主动发自定义 header,只能靠 cookie 自动携带。Caddy 在 api 子域前放了 forward_auth 钩子调后端校验这个 cookie,不带就 401。

Domain 属性关键.involutionhell.com(主域通配,所有子域共享)。localhost 开发要留空——浏览器默认绑当前 host,写 .localhost 会被拒绝。

SameSite=Lax:顶层导航 + GET 子资源都会带,跨站 POST 不带。够用——pgAdmin 自己的 CSRF 有独立 cookie,我们不依赖 satoken 处理 POST。

为什么 30 天 TTL:与 sa-token.timeout=2592000 后端配置保持一致,到期浏览器自动弃。

3. 使用

const { user, status, logout } = useAuth();
// status: "loading" | "authenticated" | "unauthenticated"
// user: UserView | null

Server-side(API Route / Server Component)从 request header 取:

import { resolveUserId } from "@/lib/server-auth";
const userId = await resolveUserId(req); // bigint | null,走 /auth/me 校验

4. 清除

logout() 或 token 无效时:

localStorage.removeItem("satoken");
syncTokenCookie(null); // Max-Age=0 + 同 Domain/Path,浏览器才认是"同一条"才能清掉

两边必须都清干净——单清 localStorage 会留下 stale cookie 让 forward_auth 误判,反过来也一样。

角色与 AdminGuard

角色来源

后端 user_accounts.rolesTEXT 逗号分隔,例 admin,user)。这张表不是 users —— users 是 2026-03-25 之前 NextAuth 时代留下的历史数据,当前系统不写。

给某人发 admin 角色唯一的正规姿势:

  1. 该人已经 GitHub OAuth 登录过一次(user_accounts 里有 github_<gh_id> 行)
  2. superadmin 在 /admin/users 页面勾 checkbox(调 PUT /api/admin/users/{id}/admin

不要手工 INSERT 一行 username=longsizhuo 挂 admin —— OAuth 登录按 github_<id> 查找,找不到人类 username 的行。这条坑踩过一次,参考 git history 里的事后数据迁移。

AdminGuard 组件

app/admin/events/AdminGuard.tsx

<AdminGuard required="admin">   {/* 或 "superadmin" */}
  <AdminOnlyContent />
</AdminGuard>

行为:

  • status === "unauthenticated" → 跳 /login
  • 已登录但 roles 不含 required → 403 提示页
  • 通过 → 渲染 children

这只是 UX 保护。真正的安全在后端 @SaCheckRole 注解,绕 UI 也拿不到数据。

AdminLinkIfOwnerAdmin

个人主页 /u/[username] 的"管理员界面"按钮:只有当前用户是 admin 且正在看自己主页时渲染。避免其他人看到这个按钮产生社交困惑。

/admin 页面体系

路径 最低权限 后端对应
/admin admin
/admin/events + /admin/events/[id]/edit + /admin/events/new admin /api/admin/events/**
/admin/users superadmin /api/admin/users/**
/admin/database admin /api/admin/pgadmin-check(Caddy forward_auth 目标)

数据库管理页(/admin/database

不 iframe。历史上试过两种 iframe 嵌法都不行:

  • 跨域嵌(iframe src = api.involutionhell.com/admin/pgadmin/):pgAdmin 的 session / CSRF cookie 走 SameSite=Lax,子域 iframe 发 POST 不带 cookie,登录永远报 CSRF session token is missing
  • 同源代理嵌(Next.js rewrite 把 pgAdmin 代到 localhost:3010 下):pgAdmin 返回绝对 URL 的重定向(host 是容器自己以为的值),浏览器跟着跳 http://localhost:8082ERR_CONNECTION_REFUSED

现在的实现是一个按钮 target="_blank" 打开 pgAdmin,让 pgAdmin 在自己的 origin 里管自己的 session / CSRF,省心。管理员不高频用 DB,视觉一致性排最后(用户原话"管理员不配享受好 UI")。

覆盖 URL 给本地开发:

NEXT_PUBLIC_PGADMIN_URL=http://localhost:8082/admin/pgadmin/ pnpm dev -p 3010
# 需要先 ssh -L 8082:127.0.0.1:8082 <server>

Chat 历史持久化(Prisma 退场)

app/api/chat/route.tsstreamText.onFinish 早期直连 Prisma,在 Neon 上写 Chat / Message 表。2026-04-17 起不再

  • 后端自建 Docker PG 替代 Neon 以后,Prisma 还指向 Neon 会写到旧库,和后端读的自建 PG 分叉出脏数据
  • 改调后端 POST /api/chat/sessions/save,单次请求带 { chatId, userMessage, assistantMessage } 由后端 @Transactional 原子落库

Vercel AI SDK 的流式路径完全没动streamText / convertToModelMessages / result.toUIMessageStreamResponse() 一字没改,UX 无感知。改动只替换 onFinish 里的 3 次 prisma 调用为 1 次 fetch

scripts/ 下的三个 Prisma 脚本(test.mjs / backfill-contributors.mjs / generate-leaderboard.mjs)暂未迁移——它们是离线 CI 脚本,需要重定向 DATABASE_URL 到自建 PG 或改走后端 API,属于 P2 工作。

Next.js rewrite 与跨域

大部分业务 API 通过 next.config.mjsrewrites() 代理到后端,浏览器只见同源 localhost:3000 / involutionhell.com,Next.js 服务端再转发给后端 BACKEND_URL

/auth/:path*                 → ${backendUrl}/auth/:path*
/oauth/:path*                → ${backendUrl}/oauth/:path*
/analytics/:path*            → ${backendUrl}/analytics/:path*
/api/user-center/:path*      → ${backendUrl}/api/user-center/:path*
/api/docs/history            → ${backendUrl}/api/docs/history
/api/events + /api/events/*  → ${backendUrl}/...
/api/admin/events + /:path*  → ${backendUrl}/...
/api/admin/users + /:path*   → ${backendUrl}/...
/api/auth/callback/github    → ${backendUrl}/...

特意不 rewrite 的/admin/pgadmin/*。原因见"数据库管理页"一节——同源代理嵌踩坑。pgAdmin 入口就用绝对 URL 打到 api.involutionhell.com/admin/pgadmin/,让它留在自己的 origin。

开发者常见问题

Q:我新加了一个后端接口,为什么前端 fetch 直接用 /api/xxx 报 404?
A:Next.js rewrites 是白名单制——没列进去的路径就走 Next.js 自己的 route,不会转发给后端。去 next.config.mjs 加一条 rewrite。

Q:本地跑 pnpm dev,登录后刷新页面又退出了?
A:检查 BACKEND_URL env 是否指向你起着的后端(默认 http://localhost:8080;memory 里 IH 约定是 8081)。前端 fetchCurrentUser/auth/me 失败就会清掉 token。

Q:我想加一个新的 admin 页面,权限控制怎么做?
A:前端包 <AdminGuard>,路径前缀 /admin/*;后端对应的 controller 方法加 @SaCheckRole("admin")next.config.mjs/api/admin/your-feature/:path* rewrite 到后端。三件套。

Q:为什么个人主页 URL 是 /u/<github_id> 而不是 /u/<username>
A:username 可能带特殊字符 / 后期改名;GitHub ID 是数字稳定的。AdminLinkIfOwnerAdmin 同时接受 user.githubId === ownerGithubIduser.username === ownerUsername 做匹配兜底。