设计目标
Token 不进 localStorage ,降低 XSS 窃取风险
双 Token :access 短效(约 15 分钟)+ refresh 长效(约 7 天)
三角色 :admin / operator / viewer,写操作后端二次校验
演示账号(仅本地/测试):ops@demo.local / DemoPass123!(勿用于生产 )
链路 A:用户登录(完整) 流程图 1 2 3 4 5 6 7 8 9 10 11 12 13 sequenceDiagram participant Browser as 浏览器 LoginForm participant API as "POST login API" participant Auth as auth.ts participant DB as PostgreSQL users Browser->>API: { email, password, remember } API->>DB: findFirst by email DB-->>API: user + password_hash API->>API: bcrypt.compare API->>Auth: signAccessToken + signRefreshToken Auth->>Browser: Set-Cookie app_* Browser->>Browser: router.push(redirect)
阅读顺序
步骤
文件
关注点
1
src/app/login/LoginForm.tsx
fetch POST、router.push、router.refresh
2
src/app/api/v1/auth/login/route.ts
Zod loginSchema、bcrypt、调 setAuthCookies
3
src/lib/auth.ts
signAccessToken、setAuthCookies(cookies() from next/headers)
4
src/middleware.ts
下次请求带 Cookie 才放行
与 Spring 对照
步骤
Spring 典型
登录接口
AuthController.login()
密码校验
PasswordEncoder.matches()
发 Token
JwtUtil.generate() + Response.addCookie()
后续请求
JwtAuthenticationFilter
后续请求如何识别用户
middleware :只检查 Cookie 里有没有 access token(不解析 payload)
API 内 requireUser(request):用 jose 验证 JWT,得到 { id, role, email, ... }
文件:src/lib/require-user.ts
登录时序(Mermaid) 1 2 3 4 5 6 7 8 9 10 11 12 13 sequenceDiagram participant Browser as 浏览器 LoginForm participant API as "POST login API" participant Auth as auth.ts participant DB as PostgreSQL users Browser->>API: { email, password, remember } API->>DB: findFirst by email DB-->>API: user + password_hash API->>API: bcrypt.compare API->>Auth: signAccessToken + signRefreshToken Auth->>Browser: Set-Cookie app_* Browser->>Browser: router.push redirect
JWT 环境变量
环境变量
默认
说明
JWT_SECRET
必填 ≥32 字符
签名密钥
JWT_ACCESS_EXPIRES
15m
Access Token
JWT_REFRESH_EXPIRES
7d
Refresh Token
Cookie 名(脱敏后):
app_access_token
app_refresh_token
角色权限:
1 2 canWrite (role) canDelete (role)
两层守卫
层级
文件
行为
Edge
middleware.ts
无 access Cookie → 页面跳 /login,API 返回 401
API
require-user.ts
jose 验证 JWT,解析 { id, role, email }
1 2 3 4 5 const token = request.cookies .get ("app_access_token" )?.value ;if (!token && !isPublicPath (pathname)) { return NextResponse .redirect (new URL ("/login" , request.url )); }
1 2 3 4 5 6 7 export function canWrite (role : UserRole ) { return role === "admin" || role === "operator" ; }export function canDelete (role : UserRole ) { return role === "admin" ; }
与 Spring Security 对照
步骤
Spring 典型
本系统
登录接口
AuthController.login()
auth/login/route.ts
密码校验
PasswordEncoder.matches()
bcrypt.compare
发 Token
JwtUtil.generate() + Cookie
signAccessToken + setAuthCookies
后续请求
JwtAuthenticationFilter
middleware + requireUser
Gmail Token 与业务 JWT 分离 Gmail OAuth Token 使用 独立 Cookie (lib/gmail-tokens.ts),与业务登录态解耦:
Cookie
用途
app_access_token
系统登录
gmail_*
Google OAuth,解析邮件用
能登录 ≠ 能搜 Gmail;检索前必须完成 Gmail OAuth (见 Gmail 实战篇 )。
权限矩阵
操作
admin
operator
viewer
查看列表
✓
✓
✓
新建/编辑
✓
✓
✗
删除/批量删
✓
✗
✗
Gmail 检索解析
✓
✓
✗
重新解析
✓
✓
✗
前端如何获取角色 1 2 3 useEffect (() => { void fetch ("/api/v1/auth/me" ).then (); }, []);
后端仍必须用 canWrite() 二次校验,不可仅依赖前端隐藏按钮 。
调试技巧
现象
处理
401 Unauthorized
Cookie 过期,重新登录
403 权限不足
viewer 角色无法写操作
middleware 无限跳 login
检查 publicPaths 是否包含 /login
登出与当前用户 API
方法
路径
说明
POST
/api/v1/auth/logout
清除 app_access_token / app_refresh_token Cookie
GET
/api/v1/auth/me
返回当前用户 { id, email, role, full_name },前端控制按钮显隐
登出后 middleware 检测到无 Cookie,再次访问业务页会重定向到 /login?redirect=原路径。
系列导航
← 返回专栏首页