name: secure-auth description: 安全认证实现模式。在实现用户登录、注册、密码重置、会话管理、JWT认证或OAuth集成时使用。提供生产就绪的模式,避免常见的教程陷阱,如不安全的令牌存储、弱密码哈希和会话固定。
安全认证
生产就绪的认证模式。这些不是最简单的实现——它们是那些不会让你被起诉的实现。
认证架构决策
会话 vs JWTs
使用会话当:
- 服务器渲染的应用程序
- 需要立即注销/撤销
- 单域名
- 更易于正确实现
使用JWTs当:
- 多个服务需要验证认证
- 需要无状态架构
- 移动应用 + API
- 第三方集成
常见错误: 因为教程使用了JWTs,所以使用它们,然后将它们存储在localStorage(XSS易受攻击)并且没有撤销策略。
基于会话的认证
完整的Express.js实现
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const app = express();
// Redis客户端用于会话存储
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
// 会话配置
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // 至少32随机字节
name: 'sessionId', // 不要使用默认的'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // 仅在生产环境中使用HTTPS
httpOnly: true, // 不能通过JavaScript访问
sameSite: 'lax', // CSRF保护
maxAge: 24 * 60 * 60 * 1000 // 24小时
}
}));
// 认证端点的速率限制
const loginAttempts = new Map();
function checkRateLimit(ip) {
const attempts = loginAttempts.get(ip) || { count: 0, resetAt: Date.now() + 900000 };
if (Date.now() > attempts.resetAt) {
attempts.count = 0;
attempts.resetAt = Date.now() + 900000; // 15分钟窗口
}
if (attempts.count >= 5) {
return false;
}
attempts.count++;
loginAttempts.set(ip, attempts);
return true;
}
// 注册
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
// 验证输入
if (!email || !password) {
return res.status(400).json({ error: '需要邮箱和密码' });
}
if (password.length < 12) {
return res.status(400).json({ error: '密码必须至少12个字符' });
}
// 检查用户是否存在
const existingUser = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
// 不揭示邮箱是否存在 - 使用相同的消息/时间
return res.status(400).json({ error: '注册失败' });
}
// 哈希密码
const hashedPassword = await bcrypt.hash(password, 12);
// 创建用户
const result = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
[email.toLowerCase(), hashedPassword]
);
// 创建会话
req.session.userId = result.rows[0].id;
req.session.createdAt = Date.now();
res.json({ success: true });
});
// 登录
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const clientIp = req.ip;
// 速率限制
if (!checkRateLimit(clientIp)) {
return res.status(429).json({ error: '尝试过多。请稍后再试。' });
}
// 验证输入
if (!email || !password) {
return res.status(400).json({ error: '需要邮箱和密码' });
}
// 查找用户
const result = await db.query(
'SELECT id, password_hash FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
// 定时攻击预防:仍然进行bcrypt比较
await bcrypt.compare(password, '$2b$12$invalidhashtopreventtiming');
return res.status(401).json({ error: '无效凭证' });
}
const user = result.rows[0];
// 验证密码
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: '无效凭证' });
}
// 重新生成会话以防止固定
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: '会话错误' });
}
req.session.userId = user.id;
req.session.createdAt = Date.now();
// 成功登录后清除速率限制
loginAttempts.delete(clientIp);
res.json({ success: true });
});
});
// 注销
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: '注销失败' });
}
res.clearCookie('sessionId');
res.json({ success: true });
});
});
// 认证中间件
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: '需要认证' });
}
// 可选:检查会话年龄
const maxAge = 24 * 60 * 60 * 1000; // 24小时
if (Date.now() - req.session.createdAt > maxAge) {
req.session.destroy();
return res.status(401).json({ error: '会话已过期' });
}
next();
}
// 受保护的路由
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.query(
'SELECT id, email, created_at FROM users WHERE id = $1',
[req.session.userId]
);
res.json(user.rows[0]);
});
JWT认证
带有刷新令牌的完整实现
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// 令牌配置
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// 存储刷新令牌(在生产中使用Redis)
const refreshTokens = new Map();
function generateAccessToken(userId) {
return jwt.sign(
{ userId, type: 'access' },
ACCESS_TOKEN_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
}
function generateRefreshToken(userId) {
const tokenId = crypto.randomBytes(32).toString('hex');
const token = jwt.sign(
{ userId, tokenId, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// 存储令牌ID用于撤销
refreshTokens.set(tokenId, {
userId,
createdAt: Date.now(),
revoked: false
});
return token;
}
// 登录 - 返回两个令牌
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// ... 验证和密码检查 ...
const accessToken = generateAccessToken(user.id);
const refreshToken = generateRefreshToken(user.id);
// 将刷新令牌设置为httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
});
// 在响应体中返回访问令牌
res.json({ accessToken });
});
// 刷新端点
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: '需要刷新令牌' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
// 检查令牌是否被撤销
const storedToken = refreshTokens.get(decoded.tokenId);
if (!storedToken || storedToken.revoked) {
return res.status(401).json({ error: '令牌已撤销' });
}
// 生成新的访问令牌
const accessToken = generateAccessToken(decoded.userId);
res.json({ accessToken });
} catch (err) {
return res.status(401).json({ error: '无效刷新令牌' });
}
});
// 注销 - 撤销刷新令牌
app.post('/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
const storedToken = refreshTokens.get(decoded.tokenId);
if (storedToken) {
storedToken.revoked = true;
}
} catch (err) {
// 令牌无效,无需操作
}
}
res.clearCookie('refreshToken');
res.json({ success: true });
});
// 受保护路由的认证中间件
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '需要访问令牌' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
if (decoded.type !== 'access') {
return res.status(401).json({ error: '无效令牌类型' });
}
req.userId = decoded.userId;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: '令牌已过期', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: '无效令牌' });
}
}
前端令牌处理
// auth.js - 前端令牌管理
class AuthManager {
constructor() {
this.accessToken = null;
}
async login(email, password) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // 对于cookie很重要
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('登录失败');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return true;
}
async refreshToken() {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
this.accessToken = null;
throw new Error('会话已过期');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return accessToken;
}
async fetchWithAuth(url, options = {}) {
if (!this.accessToken) {
throw new Error('未认证');
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
// 如果令牌过期,尝试刷新并重试
if (response.status === 401) {
const body = await response.json();
if (body.code === 'TOKEN_EXPIRED') {
await this.refreshToken();
// 重试原始请求
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
}
}
return response;
}
async logout() {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include'
});
this.accessToken = null;
}
}
export const auth = new AuthManager();
密码重置流程
安全实现
const crypto = require('crypto');
// 请求密码重置
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
// 总是返回成功以防止邮箱枚举
res.json({ message: '如果账户存在,重置链接已发送。' });
// 查找用户(异步,在响应后)
const result = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
return; // 用户不存在,但不揭示
}
const user = result.rows[0];
// 生成安全令牌
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 3600000); // 1小时
// 存储哈希令牌(不是明文令牌)
await db.query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
);
// 发送带有明文令牌的邮箱
const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
await sendEmail(email, '密码重置', `重置您的密码:${resetUrl}`);
});
// 重置密码
app.post('/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
return res.status(400).json({ error: '需要令牌和新密码' });
}
if (newPassword.length < 12) {
return res.status(400).json({ error: '密码必须至少12个字符' });
}
// 哈希提供的令牌以与存储的哈希比较
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// 查找有效重置令牌
const result = await db.query(
`SELECT user_id FROM password_resets
WHERE token_hash = $1 AND expires_at > NOW() AND used = false`,
[tokenHash]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: '无效或已过期令牌' });
}
const userId = result.rows[0].user_id;
// 哈希新密码
const hashedPassword = await bcrypt.hash(newPassword, 12);
// 更新密码并使令牌无效
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hashedPassword, userId]);
await db.query('UPDATE password_resets SET used = true WHERE token_hash = $1', [tokenHash]);
// 使此用户的所有现有会话无效
await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
res.json({ success: true });
});
OAuth集成(Google示例)
服务器端流程(推荐)
const { OAuth2Client } = require('google-auth-library');
const oauth2Client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
);
// 步骤1:重定向到Google
app.get('/auth/google', (req, res) => {
// 生成状态用于CSRF保护
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['email', 'profile'],
state: state,
prompt: 'consent'
});
res.redirect(authUrl);
});
// 步骤2:处理回调
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
// 验证状态以防止CSRF
if (state !== req.session.oauthState) {
return res.status(400).send('无效状态参数');
}
delete req.session.oauthState;
try {
// 交换代码为令牌
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
// 获取用户信息
const ticket = await oauth2Client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload();
const { sub: googleId, email, name, picture } = payload;
// 查找或创建用户
let user = await db.query(
'SELECT id FROM users WHERE google_id = $1',
[googleId]
);
if (user.rows.length === 0) {
// 创建新用户
user = await db.query(
`INSERT INTO users (google_id, email, name, avatar_url)
VALUES ($1, $2, $3, $4) RETURNING id`,
[googleId, email, name, picture]
);
}
// 创建会话
req.session.regenerate((err) => {
if (err) {
return res.status(500).send('会话错误');
}
req.session.userId = user.rows[0].id;
res.redirect('/dashboard');
});
} catch (error) {
console.error('OAuth错误:', error);
res.status(400).send('认证失败');
}
});
多因素认证(TOTP)
服务器实现
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// 为用户启用MFA
app.post('/auth/mfa/enable', requireAuth, async (req, res) => {
// 生成密钥
const secret = speakeasy.generateSecret({
name: `YourApp:${req.user.email}`,
issuer: 'YourApp'
});
// 存储密钥(加密)直到验证
await db.query(
'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
[encrypt(secret.base32), req.userId]
);
// 生成QR码
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32, // 显示此作为备份
qrCode: qrCode
});
});
// 验证并激活MFA
app.post('/auth/mfa/verify', requireAuth, async (req, res) => {
const { code } = req.body;
const result = await db.query(
'SELECT mfa_secret_temp FROM users WHERE id = $1',
[req.userId]
);
const secret = decrypt(result.rows[0].mfa_secret_temp);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // 允许1步容差
});
if (!verified) {
return res.status(400).json({ error: '无效代码' });
}
// 将密钥从临时移动到永久
await db.query(
'UPDATE users SET mfa_secret = mfa_secret_temp, mfa_secret_temp = NULL, mfa_enabled = true WHERE id = $1',
[req.userId]
);
res.json({ success: true });
});
// 使用MFA登录
app.post('/auth/login', async (req, res) => {
const { email, password, mfaCode } = req.body;
// ... 先验证邮箱/密码 ...
if (user.mfa_enabled) {
if (!mfaCode) {
return res.status(401).json({
error: '需要MFA代码',
requiresMfa: true
});
}
const verified = speakeasy.totp.verify({
secret: decrypt(user.mfa_secret),
encoding: 'base32',
token: mfaCode,
window: 1
});
if (!verified) {
return res.status(401).json({ error: '无效MFA代码' });
}
}
// ... 创建会话/令牌 ...
});
安全考虑清单
密码存储
- [ ] 使用bcrypt/scrypt/Argon2,成本因子12+
- [ ] 从不存储明文密码
- [ ] 从不记录密码
会话管理
- [ ] 会话存储在服务器端(不仅是在cookie中)
- [ ] 会话ID是密码学随机的
- [ ] 登录时重新生成会话(防止固定)
- [ ] 注销时使会话无效
- [ ] 会话有最大生命周期
JWT安全
- [ ] 短期访问令牌生命周期(15分钟或更短)
- [ ] 刷新令牌存储为httpOnly cookie
- [ ] 实现刷新令牌轮换
- [ ] 存在令牌撤销机制
- [ ] 密钥至少256位
速率限制
- [ ] 每个IP的登录尝试有限制
- [ ] N次失败后账户锁定
- [ ] 密码重置请求有限制
- [ ] MFA验证尝试有限制
CSRF保护
- [ ] 设置SameSite cookie属性
- [ ] 状态更改操作使用CSRF令牌
- [ ] 验证OAuth状态参数
信息泄露
- [ ] 有效/无效用户的相同错误消息
- [ ] 缓解定时攻击
- [ ] 没有通过注册/重置的用户枚举