1. 用户登录验证Demo的设计初衷
在Web开发中,用户认证系统就像大楼的门禁系统——它决定了谁可以进入、以什么身份进入,以及能访问哪些区域。这个Demo的诞生源于我在实际项目中遇到的几个典型问题:
- 新入行的开发者常常对认证流程一知半解,把用户名密码直接存到localStorage就以为完成了"登录功能"
- 现有教程要么过于理论化(大谈JWT原理却不给具体实现),要么过于简单(忽略CSRF防护等关键环节)
- 企业面试中,90%的候选人在白板编码时无法完整实现带加密和会话管理的登录流程
这个Demo将展示一个生产级登录系统的最小可行实现,包含以下核心特性:
- 密码加盐哈希存储(使用bcrypt算法)
- 会话管理(JWT + HTTP-only Cookie双验证)
- 基础防护措施(CSRF令牌、请求限流)
- 前后端完整交互流程
提示:虽然称为"Demo",但代码结构完全遵循生产环境标准,只是省略了用户注册、密码重置等扩展功能。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术栈选型与项目结构
2.1 为什么选择这些技术?
后端选择Node.js + Express:
- 轻量快速,适合演示核心逻辑
- 中间件机制清晰展示请求处理流程
- 社区有完善的加密/安全类库支持
前端选择Vanilla JS:
- 避免React/Vue等框架的学习成本干扰核心逻辑理解
- 原生fetch API足够演示与后端的交互
- 便于聚焦安全实践而非框架特性
关键依赖库:
bash复制# 后端
bcryptjs # 密码哈希处理
jsonwebtoken # JWT生成验证
express-rate-limit # 登录尝试限流
csurf # CSRF防护
# 前端
js-cookie # Cookie操作
2.2 项目目录结构解析
code复制/login-demo/
├── server/
│ ├── middleware/ # 认证中间件
│ ├── models/ # 数据模型
│ ├── routes/ # 路由定义
│ ├── utils/ # 加密/验证工具
│ └── app.js # 主入口
├── client/
│ ├── css/
│ ├── js/
│ │ ├── auth.js # 认证相关逻辑
│ │ └── api.js # 接口调用封装
│ └── index.html
└── docker-compose.yml # 数据库容器配置
这种结构虽然简单,但已经体现了关注点分离原则。我在实际项目中发现,许多团队在初期就过度设计目录结构,反而增加了理解成本。
3. 核心实现流程详解
3.1 密码存储方案
错误示范:
javascript复制// 绝对禁止的存储方式!
users.push({
username: 'admin',
password: '123456' // 明文存储
});
正确实现:
javascript复制// 用户注册或密码变更时
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(password, salt);
// 存储到数据库
User.create({
username,
password: hashedPassword,
salt // 单独存储盐值
});
关键细节:
- 每个用户使用独立随机盐值,防止彩虹表攻击
- bcrypt的cost factor设为10,在安全性和性能间取得平衡
- 盐值需要和哈希结果一起存储(与常见误解相反)
3.2 登录验证流程
后端处理逻辑:
javascript复制app.post('/login', rateLimiter, async (req, res) => {
// 1. 查找用户
const user = await User.findOne({ username: req.body.username });
if (!user) return res.status(401).json({ error: '用户不存在' });
// 2. 验证密码
const isValid = bcrypt.compareSync(req.body.password, user.password);
if (!isValid) return res.status(401).json({ error: '密码错误' });
// 3. 生成令牌
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// 4. 设置HTTP-only Cookie
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
res.json({ success: true });
});
前端配合实现:
javascript复制// 登录表单提交
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': Cookies.get('XSRF-TOKEN') // CSRF防护
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value
})
});
if (response.ok) {
window.location.href = '/dashboard';
} else {
const error = await response.json();
showErrorMessage(error.error);
}
});
3.3 会话维持与验证
JWT验证中间件:
javascript复制const authenticate = (req, res, next) => {
// 1. 从Cookie获取令牌
const token = req.cookies.token;
if (!token) return res.sendStatus(401);
// 2. 验证令牌
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
clearAuthCookie(res);
return res.sendStatus(403);
}
// 3. 附加用户信息到请求对象
req.userId = decoded.userId;
next();
});
};
// 在需要认证的路由中使用
app.get('/profile', authenticate, (req, res) => {
// 只有验证通过的请求能执行到这里
});
前端令牌刷新机制:
javascript复制// 检查令牌有效期并自动刷新
setInterval(async () => {
const response = await fetch('/refresh-token', {
credentials: 'include' // 携带Cookie
});
if (!response.ok) {
// 跳转到登录页
}
}, 15 * 60 * 1000); // 每15分钟检查一次
4. 安全增强措施实战
4.1 CSRF双重防护方案
方案一:SameSite Cookie
javascript复制// 在设置认证Cookie时
res.cookie('token', token, {
httpOnly: true,
sameSite: 'strict' // 严格模式
});
方案二:CSRF令牌
javascript复制// 服务端中间件
app.use(csurf({
cookie: true // 令牌存储在Cookie中
}));
// 前端获取令牌
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
// 在所有修改状态的请求中携带
fetch('/update-profile', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken
}
});
4.2 速率限制实践
登录接口限流配置:
javascript复制const rateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 5, // 每个IP最多5次尝试
handler: (req, res) => {
res.status(429).json({
error: '尝试次数过多,请15分钟后再试'
});
}
});
app.post('/login', rateLimiter, loginHandler);
进阶方案:动态封禁
javascript复制const failedAttempts = {}; // 实际项目应使用Redis
app.post('/login', (req, res, next) => {
const ip = req.ip;
if (failedAttempts[ip] >= 10) {
return res.status(403).json({
error: '该IP已被暂时封禁'
});
}
next();
}, rateLimiter, loginHandler);
4.3 敏感操作二次验证
关键操作验证流程:
javascript复制// 前端
async function changePassword(newPassword) {
const currentPassword = prompt('请输入当前密码验证身份');
const response = await fetch('/verify-password', {
method: 'POST',
body: JSON.stringify({ password: currentPassword })
});
if (response.ok) {
// 执行实际密码修改
} else {
alert('验证失败,操作已取消');
}
}
5. 常见问题与调试技巧
5.1 Cookie无法设置的排查步骤
-
检查域名和路径:
- 前端访问的域名必须与后端设置的Cookie域名匹配
- 开发环境下常因localhost与127.0.0.1的差异导致问题
-
验证Secure标志:
javascript复制// 开发环境应设为false res.cookie('token', token, { secure: process.env.NODE_ENV === 'production' }); -
浏览器控制台检查:
- 在Application > Cookies中查看是否成功设置
- 检查是否有警告信息(如SameSite冲突)
5.2 JWT过期处理的最佳实践
前端自动刷新方案:
javascript复制// 拦截所有API响应
fetch('/api/data')
.then(response => {
if (response.status === 401) {
return refreshToken()
.then(() => fetch('/api/data'));
}
return response;
});
async function refreshToken() {
const response = await fetch('/refresh-token', {
credentials: 'include'
});
if (!response.ok) {
redirectToLogin();
}
}
后端黑名单处理(可选):
javascript复制const tokenBlacklist = new Set(); // 实际使用Redis
app.post('/logout', (req, res) => {
const token = req.cookies.token;
tokenBlacklist.add(token);
clearAuthCookie(res);
res.sendStatus(200);
});
// 在认证中间件中检查
if (tokenBlacklist.has(token)) {
return res.sendStatus(401);
}
5.3 密码强度检测实现
前端实时校验:
javascript复制passwordInput.addEventListener('input', (e) => {
const strength = calculatePasswordStrength(e.target.value);
updateStrengthIndicator(strength);
});
function calculatePasswordStrength(password) {
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
return score; // 0-4分
}
后端最终验证:
javascript复制function validatePassword(password) {
if (password.length < 8) {
throw new Error('密码至少8位');
}
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
throw new Error('必须包含大写字母和数字');
}
}
