学习目标:掌握使用 Claude Code 从零构建完整的认证鉴权系统——涵盖密码哈希、JWT 签发验证、Refresh Token 轮转、OAuth2.0 授权码流程,以及 RBAC 权限控制。读完即可在项目中直接复用。
适用人群:有 Node.js 基础、正在使用 Claude Code 进行后端开发的中级开发者。
| 项目 | 要求 |
|---|---|
| Node.js | ≥ 20.x |
| Claude Code | 最新版(claude update) |
| 数据库 | SQLite(教程内使用,生产换 PostgreSQL) |
| 浏览器 | 用于 OAuth2 回调测试 |
在项目根目录准备好 CLAUDE.md,写入以下内容让 Claude Code 了解项目上下文:
# 项目说明
- 技术栈:Node.js + Express + SQLite
- 认证方案:JWT + OAuth2.0
- 密码哈希:argon2
- Token 存储:httpOnly cookie
- 项目结构遵循 MVC 分层
在终端中启动 Claude Code,输入以下提示词:
帮我初始化一个 Express 项目,要求:
1. 使用 ES Module(type: module)
2. 安装 argon2、jsonwebtoken、cookie-parser、cors、dotenv
3. 创建以下目录结构:
src/
├── routes/auth.js
├── controllers/authController.js
├── services/authService.js
├── middleware/auth.js
├── models/user.js
└── utils/token.js
4. 生成 .env 文件,包含 JWT_SECRET 和 PORT
5. 创建 server.js 入口文件
Claude Code 会一次性生成完整的项目骨架。核心的密码注册逻辑如下:
// src/services/authService.js
import argon2 from 'argon2';
import db from '../models/user.js';
export async function register({ email, password, name }) {
// 检查邮箱唯一性
const existing = db.findByEmail(email);
if (existing) {
throw new Error('EMAIL_DUPLICATE');
}
// argon2id 是 OWASP 推荐的密码哈希算法
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 次迭代
parallelism: 4 // 4 线程
});
const user = db.create({ email, passwordHash: hash, name });
return { id: user.id, email: user.email, name: user.name };
}
export async function verifyPassword(hash, plain) {
return argon2.verify(hash, plain);
}
运行 npm run dev 后,服务器在 localhost:3000 启动。用 curl 测试注册:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"Str0ng!Pass","name":"Test"}'
返回:
{"user":{"id":1,"email":"[email protected]","name":"Test"}}
| 问题 | 原因 | 解决 |
|---|---|---|
argon2 安装报错 |
缺少编译工具链 | macOS: xcode-select --install; Linux: apt install build-essential |
EMAIL_DUPLICATE |
邮箱已注册 | 前端做邮箱格式校验 + 后端捕获错误返回 409 |
| 密码明文入库 | 忘记调用 hash | 永远不要存明文,用 argon2.hash 处理 |
这一步是整个认证系统的核心。向 Claude Code 输入:
实现 JWT 认证,要求:
1. Access Token: 15分钟过期,存在 httpOnly cookie
2. Refresh Token: 7天过期,存在 httpOnly cookie,同时存入数据库
3. 登录接口 POST /api/auth/login
4. Token 刷新接口 POST /api/auth/refresh
5. 登出接口 POST /api/auth/logout(清除 cookie + 吊销 refresh token)
6. 认证中间件,从 cookie 取 token 验证,将 user 信息挂载到 req.user
Claude Code 生成的 Token 工具模块:
// src/utils/token.js
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_EXPIRY = '15m';
const REFRESH_EXPIRY = '7d';
export function signAccessToken(payload) {
return jwt.sign(payload, ACCESS_SECRET, {
expiresIn: ACCESS_EXPIRY,
issuer: 'myapp',
audience: 'myapp-users'
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, REFRESH_SECRET, {
expiresIn: REFRESH_EXPIRY,
issuer: 'myapp',
audience: 'myapp-refresh'
});
}
export function verifyAccessToken(token) {
return jwt.verify(token, ACCESS_SECRET, {
issuer: 'myapp',
audience: 'myapp-users'
});
}
export function verifyRefreshToken(token) {
return jwt.verify(token, REFRESH_SECRET, {
issuer: 'myapp',
audience: 'myapp-refresh'
});
}
认证中间件:
// src/middleware/auth.js
import { verifyAccessToken } from '../utils/token.js';
export function requireAuth(req, res, next) {
const token = req.cookies?.access_token;
if (!token) {
return res.status(401).json({ error: 'NO_TOKEN', message: '未登录' });
}
try {
const decoded = verifyAccessToken(token);
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'TOKEN_EXPIRED', message: 'Token 已过期' });
}
return res.status(401).json({ error: 'INVALID_TOKEN', message: '无效 Token' });
}
}
// RBAC 权限检查
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'UNAUTHORIZED' });
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'FORBIDDEN', message: '权限不足' });
}
next();
};
}
Cookie 设置逻辑(在 Controller 中):
// src/controllers/authController.js
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
};
const ACCESS_COOKIE = { ...COOKIE_OPTIONS, maxAge: 15 * 60 * 1000 }; // 15 分钟
const REFRESH_COOKIE = { ...COOKIE_OPTIONS, maxAge: 7 * 24 * 60 * 60 * 1000, path: '/api/auth/refresh' };
function setTokenCookies(res, { accessToken, refreshToken }) {
res.cookie('access_token', accessToken, ACCESS_COOKIE);
res.cookie('refresh_token', refreshToken, REFRESH_COOKIE);
}
function clearTokenCookies(res) {
res.clearCookie('access_token', { path: '/' });
res.clearCookie('refresh_token', { path: '/api/auth/refresh' });
}
# 登录
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"email":"[email protected]","password":"Str0ng!Pass"}'
# 访问受保护资源
curl http://localhost:3000/api/me -b cookies.txt
# 刷新 Token
curl -X POST http://localhost:3000/api/auth/refresh -b cookies.txt -c cookies.txt
| 问题 | 原因 | 解决 |
|---|---|---|
| Cookie 不生效 | 前后端跨域 | CORS 配置 credentials: true,前端 fetch 加 credentials: 'include' |
| Token 过期后无限重定向 | 前端没有区分 401 错误类型 | 检查 TOKEN_EXPIRED 错误码,自动触发 refresh |
| Refresh Token 被盗用 | 无绑定机制 | 加入设备指纹或 IP 绑定,异常时吊销全部 refresh token |
向 Claude Code 输入:
实现 OAuth2.0 授权码流程(GitHub OAuth),要求:
1. GET /api/auth/github — 重定向到 GitHub 授权页
2. GET /api/auth/github/callback — 处理回调,换取 access_token,获取用户信息
3. 如果用户已注册,直接登录;如果未注册,自动创建账号
4. 用 state 参数防 CSRF
5. 在 .env 中添加 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET
Claude Code 生成的 OAuth 路由:
// src/routes/oauth.js
import { Router } from 'express';
import crypto from 'crypto';
import { getGithubUser, findOrCreateUser } from '../services/oauthService.js';
import { signAccessToken, signRefreshToken } from '../utils/token.js';
const router = Router();
// 内存存储 state(生产环境用 Redis)
const states = new Map();
router.get('/github', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
states.set(state, { createdAt: Date.now() });
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID,
redirect_uri: `${process.env.BASE_URL}/api/auth/github/callback`,
scope: 'user:email',
state
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
router.get('/github/callback', async (req, res) => {
const { code, state } = req.query;
// 验证 state
if (!state || !states.has(state)) {
return res.status(400).json({ error: 'INVALID_STATE' });
}
states.delete(state);
try {
// 用 code 换 access_token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code
})
});
const { access_token } = await tokenRes.json();
// 用 access_token 获取用户信息
const ghUser = await getGithubUser(access_token);
const user = await findOrCreateUser(ghUser);
// 签发 JWT
const accessToken = signAccessToken({ sub: user.id, email: user.email, role: user.role });
const refreshToken = signRefreshToken({ sub: user.id });
setTokenCookies(res, { accessToken, refreshToken });
res.redirect(process.env.FRONTEND_URL || '/');
} catch (err) {
res.redirect(`${process.env.FRONTEND_URL}/login?error=oauth_failed`);
}
});
export default router;
OAuth 服务层:
// src/services/oauthService.js
import db from '../models/user.js';
export async function getGithubUser(accessToken) {
const [profileRes, emailsRes] = await Promise.all([
fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}` }
}),
fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${accessToken}` }
})
]);
const profile = await profileRes.json();
const emails = await emailsRes.json();
const primaryEmail = emails.find(e => e.primary)?.email;
return {
githubId: profile.id,
name: profile.name || profile.login,
email: primaryEmail,
avatar: profile.avatar_url
};
}
export async function findOrCreateUser(ghUser) {
// 先按 GitHub ID 查找
let user = db.findByGithubId(ghUser.githubId);
if (user) return user;
// 再按邮箱查找(已有账号绑定)
user = db.findByEmail(ghUser.email);
if (user) {
db.updateGithubId(user.id, ghUser.githubId);
return user;
}
// 新用户注册
return db.create({
email: ghUser.email,
name: ghUser.name,
avatar: ghUser.avatar,
githubId: ghUser.githubId,
passwordHash: null, // OAuth 用户无密码
role: 'user'
});
}
http://localhost:3000/api/auth/github,重定向到 GitHub 授权页/api/auth/github/callback?code=xxx&state=yyy| 问题 | 原因 | 解决 |
|---|---|---|
redirect_uri_mismatch |
GitHub App 配置的回调地址与代码不一致 | 在 GitHub Settings → Developer settings 中检查 |
| 用户邮箱为 null | GitHub 默认不暴露私有邮箱 | 请求 user:email scope 并调用 emails API |
| state 验证失败 | 用户刷新页面或多次回调 | state 加过期时间,5 分钟后自动失效 |
向 Claude Code 输入:
实现 RBAC 权限系统,要求:
1. 用户角色:admin / editor / user
2. 权限表:
- admin: 所有操作
- editor: 读写自己创建的内容
- user: 只读
3. 数据库 users 表增加 role 字段,默认 'user'
4. 新增中间件 requireOwnership(model, paramIdField)
用于检查资源是否属于当前用户
5. 管理接口 PUT /api/admin/users/:id/role — 修改角色
仅 admin 可访问
资源归属检查中间件:
// src/middleware/ownership.js
export function requireOwnership(getOwner) {
return async (req, res, next) => {
if (req.user.role === 'admin') return next(); // admin 跳过检查
const ownerId = await getOwner(req);
if (req.user.id !== ownerId) {
return res.status(403).json({ error: 'NOT_OWNER', message: '只能操作自己的资源' });
}
next();
};
}
// 使用示例
import { requireAuth, requireRole } from '../middleware/auth.js';
import { requireOwnership } from '../middleware/ownership.js';
// 只有 admin 能修改角色
router.put('/admin/users/:id/role',
requireAuth,
requireRole('admin'),
adminController.updateRole
);
// 编辑者只能修改自己的文章
router.put('/articles/:id',
requireAuth,
requireRole('admin', 'editor'),
requireOwnership((req) => Article.getOwnerId(req.params.id)),
articleController.update
);
安全响应头中间件:
// src/middleware/security.js
export function securityHeaders(req, res, next) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '0'); // 现代浏览器不推荐开启
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.removeHeader('X-Powered-By');
next();
}
# 普通用户尝试修改角色 → 403
curl -X PUT http://localhost:3000/api/admin/users/2/role \
-b cookies.txt \
-H "Content-Type: application/json" \
-d '{"role":"admin"}'
# {"error":"FORBIDDEN","message":"权限不足"}
# admin 修改角色 → 200
curl -X PUT http://localhost:3000/api/admin/users/2/role \
-b admin-cookies.txt \
-H "Content-Type: application/json" \
-d '{"role":"editor"}'
# {"user":{"id":2,"role":"editor"}}
把以上四步整合为一个可运行的项目:
auth-demo/
├── .env
├── package.json
├── server.js
├── src/
│ ├── routes/
│ │ ├── auth.js # 注册、登录、登出、刷新
│ │ └── oauth.js # GitHub OAuth 回调
│ ├── controllers/
│ │ └── authController.js
│ ├── services/
│ │ ├── authService.js # 密码哈希、用户查找
│ │ └── oauthService.js # GitHub API 调用
│ ├── middleware/
│ │ ├── auth.js # requireAuth, requireRole
│ │ ├── ownership.js # 资源归属检查
│ │ └── security.js # 安全头
│ ├── models/
│ │ └── user.js # SQLite 用户模型
│ ├── utils/
│ │ └── token.js # JWT 签发/验证
│ └── db/
│ └── init.js # 建表脚本
└── CLAUDE.md
数据库建表脚本:
// src/db/init.js
import Database from 'better-sqlite3';
const db = new Database('auth.db');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT, -- OAuth 用户为 NULL
name TEXT NOT NULL,
avatar TEXT,
github_id INTEGER UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
`);
export default db;
启动命令:
# 安装依赖
npm install express argon2 jsonwebtoken cookie-parser cors dotenv better-sqlite3
# 启动开发
node server.js
整个认证系统的请求流转如下:
sequenceDiagram
participant C as Client
participant S as Server
participant DB as SQLite
participant GH as GitHub OAuth
Note over C,GH: 场景1:邮箱密码注册登录
C->>S: POST /api/auth/register {email, password}
S->>DB: argon2.hash(password) → 存储
S-->>C: {user}
C->>S: POST /api/auth/login {email, password}
S->>DB: 查找用户,argon2.verify
S->>S: signAccessToken + signRefreshToken
S-->>C: Set-Cookie: access_token, refresh_token
C->>S: GET /api/me (带 cookie)
S->>S: verifyAccessToken → req.user
S-->>C: {user profile}
Note over C,GH: 场景2:Token 刷新轮转
C->>S: POST /api/auth/refresh (带 refresh_token cookie)
S->>DB: 查找 token_hash,检查未吊销
S->>DB: 吊销旧 refresh token
S->>S: 签发新 access + refresh token
S-->>C: Set-Cookie: 新 token pair
Note over C,GH: 场景3:GitHub OAuth 登录
C->>S: GET /api/auth/github
S-->>C: 302 → GitHub 授权页
C->>GH: 授权确认
GH-->>C: 302 → /callback?code=xxx&state=yyy
C->>S: GET /api/auth/github/callback
S->>GH: POST access_token (换 code)
S->>GH: GET /api/github/user
S->>DB: findOrCreateUser
S->>S: 签发 JWT
S-->>C: 302 → 前端首页 (带 cookie)
Note over C,GH: 场景4:RBAC 权限检查
C->>S: PUT /api/admin/users/:id/role
S->>S: requireAuth → requireRole('admin')
alt role = admin
S->>DB: UPDATE role
S-->>C: 200 {user}
else role ≠ admin
S-->>C: 403 FORBIDDEN
end
Token 生命周期管理:
stateDiagram-v2
[*] --> Issued: 用户登录
Issued --> Active: 首次使用
Active --> Renewing: access_token 过期
Renewing --> Active: refresh 成功,新 token pair
Renewing --> Expired: refresh token 也过期
Active --> Revoked: 用户登出 / 安全事件
Expired --> [*]: 需重新登录
Revoked --> [*]: 需重新登录
在构建认证系统时,以下 Claude Code 用法特别有效:
不要一次性让 Claude Code 生成所有代码。按层生成:
第一步:生成 models/user.js 和 db/init.js → 运行 node -e "require('./src/db/init.js')" 验证建表
第二步:生成 services/authService.js → 写测试验证注册逻辑
第三步:生成 routes + controller → 用 curl 集成测试
第四步:生成中间件 → 接入路由验证权限
随着代码增长,在 CLAUDE.md 中追加:
## 已完成的模块
- src/db/init.js: 用户表 + refresh_tokens 表
- src/services/authService.js: register(), verifyPassword()
- src/utils/token.js: signAccessToken, verifyAccessToken
## 当前安全约定
- Access Token 15分钟过期
- Refresh Token 7天过期 + 数据库存储 + 轮转
- Cookie: httpOnly, secure, sameSite=strict
Claude Code 每次对话都会读取这个文件,保持与已有代码的一致性。
为 src/services/authService.js 编写 Vitest 单元测试:
1. 测试注册成功返回用户信息
2. 测试重复邮箱抛出 EMAIL_DUPLICATE
3. 测试密码验证正确和错误两种情况
4. 使用内存 SQLite 数据库,测试完自动清理
认证系统写完后,让 Claude Code 做一次安全审查:
审查当前认证系统的安全性,检查以下方面:
1. 密码存储是否安全(哈希算法、加盐)
2. JWT 是否有可能被伪造(密钥强度、算法限制)
3. 是否存在时序攻击(timing attack)风险
4. OAuth state 是否防 CSRF
5. 是否有信息泄露(错误消息过于详细)
输出发现的问题,按严重程度排序
| 检查项 | 状态 | 说明 |
|---|---|---|
| JWT_SECRET ≥ 256 位随机 | ☐ | openssl rand -hex 32 |
| argon2 参数 OWASP 合规 | ☐ | memoryCost≥64MB, timeCost≥3 |
| httpOnly + secure + sameSite | ☐ | 生产必须 secure: true |
| Refresh Token 轮转 | ☐ | 每次刷新生成新 pair,吊销旧的 |
| Rate Limiting | ☐ | 登录接口 5次/分钟/IP |
| CORS 白名单 | ☐ | 禁止 *,明确列出域名 |
| 数据库连接加密 | ☐ | 生产用 PostgreSQL + SSL |
| 日志脱敏 | ☐ | 不记录密码、token |
| OAuth state 过期 | ☐ | 5 分钟自动失效 |
| 管理接口二次验证 | ☐ | 改角色需重新输入密码 |
| 维度 | 评估 |
|---|---|
| 适合训练营类型 | 全栈训练营、安全训练营、Node.js 专项营 |
| 预计教学时长 | 4-6 小时(含实操) |
| 难度曲线 | 中等,需要异步编程和 HTTP 基础 |
| 实操占比 | 70%(大量代码生成和 curl 测试) |
| 延伸方向 | 添加 Google OAuth、多因子认证、密码重置流程 |
[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
