设计目标

  • 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.pushrouter.refresh
2 src/app/api/v1/auth/login/route.ts Zod loginSchema、bcrypt、调 setAuthCookies
3 src/lib/auth.ts signAccessTokensetAuthCookiescookies() from next/headers
4 src/middleware.ts 下次请求带 Cookie 才放行

与 Spring 对照

步骤 Spring 典型
登录接口 AuthController.login()
密码校验 PasswordEncoder.matches()
发 Token JwtUtil.generate() + Response.addCookie()
后续请求 JwtAuthenticationFilter

后续请求如何识别用户

  1. middleware:只检查 Cookie 里有没有 access token(不解析 payload)
  2. 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)   // admin | operator
canDelete(role) // admin | operator(删除视模块而定)

两层守卫

层级 文件 行为
Edge middleware.ts 无 access Cookie → 页面跳 /login,API 返回 401
API require-user.ts jose 验证 JWT,解析 { id, role, email }
1
2
3
4
5
// middleware.ts(示意)
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
// lib/auth.ts(示意)
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 使用 独立 Cookielib/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=原路径

系列导航

主题
上一篇 01 系统架构
本篇 JWT 双 Token 与 RBAC
下一篇 03 Gmail 检索
索引 专栏首页

← 返回专栏首页