关于 auth-security
auth-security 是面向开发者的安全技能,梳理 OAuth 2.1 与 JWT 的实现实践,涵盖令牌管理及常见 Web 身份验证攻击的防护。
主要功能
- 01要求使用 PKCE 的 OAuth 2.1
- 02JWT 算法选择与验证
- 03安全令牌存储指导
- 04刷新令牌轮换
- 05CSRF 与 XSS 防护
- 06令牌撤销与 DPoP 指导
适用场景
- 01设计 OAuth 2.1 登录流程
- 02检查 JWT 签名与验证
- 03选择浏览器令牌存储方式
- 04添加令牌轮换与撤销
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ majiayu000/ spellbook --skill auth-security安装后在 Claude Code 中输入/auth-security确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
reference/attacks.mdjwt.mdoauth2.1.mdtemplates/typescript/auth.service.ts详细介绍
身份验证安全
核心原则
- OAuth 2.1 — 遵循 RFC 9700(2025 年 1 月)
- 必须使用 PKCE — 所有客户端都必须使用 PKCE
- 短期令牌 — 访问令牌在 5-15 分钟后过期
- 令牌轮换 — 刷新令牌只能使用一次
- HttpOnly 存储 — 浏览器令牌存储在 HttpOnly Cookie 中
- 明确指定算法 — 绝不信任 JWT 标头中的算法
- 不向后兼容 — 删除已弃用的身份验证流程
OAuth 2.1 关键变更
已弃用的流程(请勿使用)
| 流程 | 状态 | 替代方案 |
|---|---|---|
| 隐式授权 | 已移除 | 授权码 + PKCE |
| 密码授权 | 已移除 | 授权码 + PKCE |
| 不使用 PKCE 的授权码 | 已移除 | 必须使用 PKCE |
必需:授权码 + PKCE
import crypto from 'crypto';
// 1. Generate code verifier (43-128 chars)
function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
// 2. Generate code challenge
function generateCodeChallenge(verifier: string): string {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// 3. Authorization request
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateState());
// 4. Token exchange (after redirect)
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier, // Prove we initiated the request
}),
});
JWT 最佳实践
算法选择(2025)
| 优先级 | 算法 | 说明 |
|---|---|---|
| 1 | EdDSA (Ed25519) | 最安全,具有抗量子特性 |
| 2 | ES256 (ECDSA P-256) | 支持广泛,签名紧凑 |
| 3 | PS256 (RSA-PSS) | 比 RS256 更安全 |
| 4 | RS256 (RSA PKCS#1) | 兼容性最佳 |
// Recommended: ES256
import { SignJWT, jwtVerify } from 'jose';
const privateKey = await importPKCS8(PRIVATE_KEY_PEM, 'ES256');
const publicKey = await importSPKI(PUBLIC_KEY_PEM, 'ES256');
// Sign
const token = await new SignJWT({ sub: userId, scope: 'read write' })
.setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid: keyId })
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.setExpirationTime('15m')
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(privateKey);
令牌结构
interface AccessTokenPayload {
// Standard claims
iss: string; // Issuer
sub: string; // Subject (user ID)
aud: string; // Audience
exp: number; // Expiration (Unix timestamp)
iat: number; // Issued at
jti: string; // JWT ID (unique identifier)
// Custom claims
scope: string; // Permissions
email?: string; // User email
roles?: string[]; // User roles
}
验证(关键)
import { jwtVerify, errors } from 'jose';
async function verifyAccessToken(token: string): Promise<AccessTokenPayload> {
try {
const { payload } = await jwtVerify(token, publicKey, {
// CRITICAL: Explicitly specify allowed algorithms
algorithms: ['ES256'],
// Validate standard claims
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
// Clock tolerance for sync issues
clockTolerance: 30,
});
// Additional validation
if (!payload.scope?.includes('read')) {
throw new Error('Insufficient scope');
}
return payload as AccessTokenPayload;
} catch (err) {
if (err instanceof errors.JWTExpired) {
throw new AuthError('Token expired', 'TOKEN_EXPIRED');
}
if (err instanceof errors.JWTClaimValidationFailed) {
throw new AuthError('Invalid token claims', 'INVALID_CLAIMS');
}
throw new AuthError('Invalid token', 'INVALID_TOKEN');
}
}
令牌存储
Web 应用
// Set token in HttpOnly cookie (server-side)
function setAuthCookie(res: Response, token: string) {
res.cookie('access_token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/api', // Only sent to API routes
});
}
// Refresh token (longer-lived)
function setRefreshCookie(res: Response, token: string) {
res.cookie('refresh_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only for refresh endpoint
});
}
单页应用(SPA)
// Store in memory (NOT localStorage/sessionStorage)
class TokenManager {
private accessToken: string | null = null;
setToken(token: string) {
this.accessToken = token;
}
getToken(): string | null {
return this.accessToken;
}
clearToken() {
this.accessToken = null;
}
}
// Use with Refresh Token Rotation
// Refresh token in HttpOnly cookie
// Access token in memory
存储方式对比
| 存储方式 | 防 XSS | 防 CSRF | 持久性 |
|---|---|---|---|
| HttpOnly Cookie | 是 | 需要 SameSite | 是 |
| 内存 | 是 | 是 | 否(重新加载后丢失) |
| localStorage | 否 | 是 | 是 |
| sessionStorage | 否 | 是 | 仅当前标签页 |
刷新令牌轮换
流程
1. Client sends refresh_token
2. Server validates refresh_token
3. Server generates NEW access_token + NEW refresh_token
4. Server INVALIDATES old refresh_token
5. Server returns new tokens
6. Client stores new tokens
实现
async function refreshTokens(refreshToken: string) {
// Find token in database
const stored = await db.refreshToken.findUnique({
where: { token: hashToken(refreshToken) },
include: { user: true },
});
if (!stored) {
throw new AuthError('Invalid refresh token', 'INVALID_TOKEN');
}
// Check if already used (reuse detection)
if (stored.usedAt) {
// Potential token theft - revoke ALL user tokens
await db.refreshToken.deleteMany({
where: { userId: stored.userId },
});
// Alert security team
await alertSecurityTeam({
event: 'REFRESH_TOKEN_REUSE',
userId: stored.userId,
tokenId: stored.id,
});
throw new AuthError('Token reuse detected', 'TOKEN_REUSE');
}
// Check expiration
if (stored.expiresAt < new Date()) {
throw new AuthError('Refresh token expired', 'TOKEN_EXPIRED');
}
// Mark as used (but keep for reuse detection)
await db.refreshToken.update({
where: { id: stored.id },
data: { usedAt: new Date() },
});
// Generate new tokens
const newAccessToken = await generateAccessToken(stored.user);
const newRefreshToken = await generateRefreshToken(stored.user);
// Store new refresh token
await db.refreshToken.create({
data: {
token: hashToken(newRefreshToken),
userId: stored.userId,
expiresAt: addDays(new Date(), 7),
previousTokenId: stored.id, // Chain for audit
},
});
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
攻击防护
算法混淆
// WRONG: Trusts header algorithm
jwt.verify(token, key); // Uses alg from header
// CORRECT: Explicit algorithm
jwt.verify(token, key, { algorithms: ['ES256'] });
CSRF 防护
// Use SameSite cookies
res.cookie('session', token, {
sameSite: 'strict', // or 'lax' for cross-site links
});
// Or double-submit cookie pattern
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf', csrfToken, { httpOnly: false });
// Client sends csrf token in header
XSS 防护
// Content Security Policy
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
].join('; '));
// Use HttpOnly cookies for tokens
// Never store tokens in localStorage
令牌绑定(DPoP)
// Demonstration of Proof of Possession
// Bind token to client's key pair
const dpopProof = await new SignJWT({
htm: 'POST',
htu: 'https://api.example.com/resource',
ath: await hashAccessToken(accessToken), // Access token hash
})
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicKey })
.setJti(crypto.randomUUID())
.setIssuedAt()
.sign(privateKey);
// Send with request
fetch('https://api.example.com/resource', {
headers: {
Authorization: `DPoP ${accessToken}`,
DPoP: dpopProof,
},
});
令牌撤销
// Revoke all user tokens (e.g., password change, logout all)
async function revokeAllUserTokens(userId: string) {
await db.refreshToken.deleteMany({
where: { userId },
});
// If using token blacklist for access tokens
await redis.sadd(`revoked:${userId}`, Date.now());
await redis.expire(`revoked:${userId}`, 15 * 60); // 15 min (access token lifetime)
}
// Check blacklist during verification
async function isTokenRevoked(userId: string, iat: number): Promise<boolean> {
const revokedAt = await redis.get(`revoked:${userId}`);
return revokedAt && parseInt(revokedAt) > iat * 1000;
}
检查清单
## OAuth 2.1
- [ ] Using Authorization Code flow
- [ ] PKCE enabled for all clients
- [ ] No implicit or password grants
- [ ] Redirect URI exact matching
## JWT
- [ ] Using ES256 or EdDSA algorithm
- [ ] Explicit algorithm verification
- [ ] Short expiration (≤15 min)
- [ ] Unique jti for each token
- [ ] Issuer and audience validation
## Tokens
- [ ] HttpOnly cookies for web apps
- [ ] Refresh token rotation enabled
- [ ] Reuse detection implemented
- [ ] Token revocation mechanism
## Security
- [ ] HTTPS everywhere
- [ ] SameSite cookies
- [ ] CSP headers configured
- [ ] Rate limiting on auth endpoints
- [ ] Brute force protection
另请参阅
- reference/oauth2.1.md — OAuth 2.1 深入解析
- reference/jwt.md — JWT 模式
- reference/attacks.md — 攻击防护
- templates/typescript/auth.service.ts — TypeScript 身份验证服务入门模板
评论
支持 Markdown