Node系列 · Express:登录与认证
登录与认证是后端最常见的需求。本章从登录接口设计到鉴权中间件,把完整流程串起来——session 与 JWT 两种方案都会讲,后续章节单独展开。
一、登录与认证的核心流程
二、登录接口设计
2.1 接口签名
http
POST /api/auth/login
Content-Type: application/json
{
"email": "alice@example.com",
"password": "plain-password"
}成功响应:
http
HTTP/1.1 200 OK
Set-Cookie: sessionId=xxx; HttpOnly; Secure; SameSite=Lax
{
"user": { "id": 1, "name": "Alice", "email": "alice@example.com" }
}失败响应:
http
HTTP/1.1 401 Unauthorized
{ "error": "邮箱或密码错误" }TIP
错误信息要模糊——"邮箱或密码错误"而不是"邮箱不存在"或"密码错误"。后者会泄露账号是否存在。
2.2 Express 实现
javascript
const express = require('express');
const bcrypt = require('bcrypt');
const router = express.Router();
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
// 1. 验证请求体
if (!email || !password) {
return res.status(400).json({ error: '邮箱和密码必填' });
}
// 2. 查用户
const user = await User.findOne({ where: { email } });
if (!user) {
// 用同一条错误信息,避免泄露账号是否存在
return res.status(401).json({ error: '邮箱或密码错误' });
}
// 3. 验证密码
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: '邮箱或密码错误' });
}
// 4. 设置会话(这里用 sessionId,详细见 session 章节)
req.session.userId = user.id;
// 5. 返回用户信息(不含密码)
res.json({
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
} catch (err) {
next(err);
}
});
module.exports = router;三、注册接口(配套)
javascript
router.post('/register', async (req, res, next) => {
try {
const { email, password, name } = req.body;
// 1. 验证
if (!email || !password) {
return res.status(400).json({ error: '邮箱和密码必填' });
}
if (password.length < 8) {
return res.status(400).json({ error: '密码至少 8 位' });
}
// 2. 查重
const exists = await User.findOne({ where: { email } });
if (exists) {
return res.status(409).json({ error: '邮箱已被注册' });
}
// 3. 密码哈希(bcrypt 自动加盐)
const passwordHash = await bcrypt.hash(password, 10);
// 4. 创建用户
const user = await User.create({ email, name, passwordHash });
// 5. 自动登录
req.session.userId = user.id;
res.status(201).json({
user: { id: user.id, name: user.name, email: user.email },
});
} catch (err) {
next(err);
}
});四、鉴权中间件
登录后访问受保护接口需要"我已登录"的凭证:
javascript
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: '未登录' });
}
next();
}
// 保护接口
app.use('/api/profile', requireAuth);
app.use('/api/orders', requireAuth);
// 公开接口
app.use('/api/auth/login', /* 不加 requireAuth */);
app.use('/api/public', /* 不加 requireAuth */);4.1 加载当前用户
每次请求加载用户信息(避免在 req.user 上缓存过期数据):
javascript
async function loadUser(req, res, next) {
if (!req.session.userId) return next();
try {
const user = await User.findByPk(req.session.userId, {
attributes: ['id', 'name', 'email', 'avatar'], // 不取 password
});
if (user) req.user = user;
next();
} catch (err) {
next(err);
}
}
app.use(loadUser);
// 业务路由里直接用
router.get('/profile', (req, res) => {
if (!req.user) return res.status(401).json({ error: '未登录' });
res.json(req.user);
});4.2 角色鉴权
javascript
function requireRole(role) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: '未登录' });
if (req.user.role !== role) {
return res.status(403).json({ error: '权限不足' });
}
next();
};
}
router.delete('/users/:id', requireRole('admin'), deleteUser);五、退出登录
javascript
router.post('/logout', (req, res) => {
// 销毁 session
req.session.destroy((err) => {
if (err) return next(err);
res.clearCookie('connect.sid'); // 清除 session cookie
res.json({ ok: true });
});
});六、密码安全规范
6.1 必须做的事
| 规则 | 理由 |
|---|---|
| 用 bcrypt 哈希 | 自动加盐 + 慢哈希 |
| 至少 8 位 | 太短会被秒破 |
| 不限制字符集 | 强密码规则反而让用户用 Password1! |
| 不存明文 | 数据库泄露 = 用户全完 |
| 登录失败通用提示 | 不泄露账号是否存在 |
| 限流登录接口 | 防暴力破解 |
6.2 不要做的事
- ❌ 用 MD5 / SHA1 哈希密码(已被攻破)
- ❌ 自己"加盐"然后存 hash(直接用 bcrypt)
- ❌ 在日志里记录密码
- ❌ 通过 URL query 传密码
- ❌ 把 token 存 LocalStorage(防 XSS 偷)
七、常见漏洞
| 漏洞 | 攻击方式 | 防护 |
|---|---|---|
| 暴力破解 | 反复试密码 | 登录限流 + 验证码 |
| 凭据填充 | 用泄露密码批量试 | 检测异常 IP / 异地登录 |
| 会话固定 | 攻击者预设 sessionId | 登录成功后重置 sessionId |
| 会话劫持 | XSS 偷 Cookie | HttpOnly + Secure |
| CSRF | 跨站请求 | SameSite=Lax + CSRF Token |
八、最佳实践
| 场景 | 推荐 |
|---|---|
| 密码存储 | bcrypt(cost=10-12) |
| 会话方案 | Session(express-session)或 JWT |
| Cookie 属性 | HttpOnly + Secure + SameSite=Lax |
| 失败提示 | 通用"邮箱或密码错误" |
| 接口限流 | express-rate-limit |
| 日志脱敏 | 不记密码、token |
| 退出 | req.session.destroy() + res.clearCookie() |
九、小结
- 登录流程:验证请求体 → 查用户 → bcrypt 比对 → 设置会话 → 返回用户信息
- 鉴权中间件
requireAuth:检查req.session.userId,未登录返回 401 - 角色鉴权
requireRole(role):在 requireAuth 基础上再校验角色 - 失败提示要通用("邮箱或密码错误"),不泄露账号是否存在
- 密码用 bcrypt 哈希;Cookie 用 HttpOnly + Secure + SameSite=Lax
- session 与 JWT 两种会话方案,下两章分别讲
