登录鉴权与安全
"你是谁"(认证)和"你能干什么"(授权)是每个 Web 系统的地基。Node 后端常见的做法是 Session 或 JWT,再配合密码哈希、token 刷新、OAuth2 第三方登录,构成完整的安全体系。本文从概念到代码逐一拆解。
一、认证与授权概念
| 术语 | 英文 | 回答的问题 | 例子 |
|---|---|---|---|
| 认证 | Authentication | 你是谁? | 登录验证用户名密码 |
| 授权 | Authorization | 你能做什么? | 只有管理员能删除用户 |
| 凭证 | Credential | 证明身份的东西 | 密码、token、Cookie |
流程固定为:先认证(登录),拿到凭证;每次请求带凭证,服务端鉴权;再按角色/权限决定放行或拒绝。
二、Session 认证流程
Session 是"服务端记状态"方案:
1. 用户提交账号密码
2. 服务端校验通过,生成 sessionId,把用户数据存入服务端存储(内存/Redis)
3. 响应 Set-Cookie: sessionId=xxx
4. 浏览器后续请求自动携带 Cookie
5. 服务端按 sessionId 查存储,得到用户信息
6. 退出登录时删除服务端 session 并清除 Cookiejavascript
const session = require("express-session");
const RedisStore = require("connect-redis")(session);
app.use(session({
store: new RedisStore({ client: redisClient }), // session 存 Redis,多实例共享
secret: process.env.SESSION_SECRET, // 签名 Cookie 的密钥
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // 禁止 JS 读取,防 XSS 窃取
secure: true, // 仅 HTTPS 下传输
maxAge: 7 * 24 * 3600 * 1000, // 7 天
},
}));
// 登录成功
req.session.userId = user.id;
req.session.role = user.role;
// 鉴权中间件
function requireLogin(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: "未登录" });
}
next();
}| 优点 | 缺点 |
|---|---|
| 服务端可控,可随时强制下线 | 服务端需要存状态,扩容要考虑共享存储 |
| 天然支持注销(删 session) | 每次请求要查存储,有开销 |
三、JWT 概念
JWT(JSON Web Token)是"客户端持票"方案:token 自带用户信息与签名,服务端无状态验证。
3.1 结构:header.payload.signature
JWT 由三部分组成,用点分隔:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.rK9...签名
└── header ─┘ └─── payload ───┘ └── signature ─┘| 部分 | 内容 | 说明 |
|---|---|---|
| Header | 算法与类型:{"alg":"HS256","typ":"JWT"} | Base64Url 编码 |
| Payload | 声明(claim):sub、exp、iat、自定义字段 | Base64Url 编码,可被解码,勿放敏感信息 |
| Signature | HMAC(header.payload, secret) 或 RSA 私钥签名 | 防篡改的核心 |
3.2 签发与验证
javascript
const jwt = require("jsonwebtoken");
// 签发:登录成功后生成
const token = jwt.sign(
{ userId: 1, role: "admin" }, // payload
process.env.JWT_SECRET, // 密钥
{ expiresIn: "2h" } // 过期时间
);
// 验证:鉴权中间件中调用
function authMiddleware(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: "缺少 token" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // 挂载到请求对象
next();
} catch (error) {
// TokenExpiredError:过期;JsonWebTokenError:伪造/篡改
return res.status(401).json({ error: "token 无效或已过期" });
}
}过期时间写在 payload 的 exp 字段,验证时 jwt.verify 自动检查。密钥必须足够随机,泄露即可伪造任意身份。
3.3 JWT vs Session 对比
| 维度 | Session | JWT |
|---|---|---|
| 服务端状态 | 需要存储(内存/Redis) | 无状态,token 自带数据 |
| 横向扩展 | 需要共享 session 存储 | 天然支持多实例 |
| 强制下线 | 删除 session 即可 | 签发后难回收,需黑名单 |
| 大小 | 只有 sessionId(小) | 携带 payload(较大) |
| 泄露风险 | Cookie 由浏览器管理 | 前端需自行安全存储 |
| 适用场景 | 传统 Web 应用 | 前后端分离、移动端、微服务 |
四、密码加密:bcrypt
密码绝不能明文存储,也不应使用可逆加密。正确做法是加盐哈希:每个密码配随机盐,生成不可逆的哈希值。
bash
npm install bcryptjs # 纯 JS 实现;bcrypt 为原生版本,性能更高javascript
const bcrypt = require("bcryptjs");
// 注册:生成盐并哈希,cost 越高越慢(推荐 10-12)
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash("用户输入的密码", salt);
// 数据库只存 hash,形如 $2a$10$....(含盐信息)
// 登录:比对输入与存储的 hash
const ok = await bcrypt.compare("用户输入的密码", user.passwordHash);
if (ok) {
// 签发 token 或建立 session
} else {
// 密码错误
}| 做法 | 是否安全 | 说明 |
|---|---|---|
| 明文存储 | 危险 | 库一泄露全部泄露 |
| MD5 / SHA1 | 危险 | 无盐且可用彩虹表破解 |
| MD5 + 固定盐 | 不推荐 | 相同密码哈希相同,仍可撞库 |
| bcrypt(随机盐) | 安全 | 每个密码独立盐,自带慢速设计(抗暴力破解) |
| argon2 / scrypt | 安全 | 内存硬函数,更抗 GPU 攻击 |
五、Token 刷新机制
access token 过期时间短(15 分钟 ~ 2 小时),防止泄露后长期可用;refresh token 有效期长(7 ~ 30 天),专门用来换新 access token:
javascript
// 登录成功:同时签发两个 token
const accessToken = jwt.sign({ userId: 1 }, SECRET, { expiresIn: "15m" });
const refreshToken = jwt.sign({ userId: 1 }, REFRESH_SECRET, { expiresIn: "7d" });
// 刷新接口:access token 过期后,用 refresh token 换新
app.post("/api/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
// 可校验 refresh token 是否已被吊销(存 Redis 黑名单 / 白名单)
const newAccess = jwt.sign({ userId: payload.userId }, SECRET, { expiresIn: "15m" });
res.json({ accessToken: newAccess });
} catch {
res.status(401).json({ error: "refresh token 无效,请重新登录" });
}
});
// 前端拦截器:401 时静默刷新,成功则重放原请求| 设计要点 | 说明 |
|---|---|
| 双 token 分离 | access 短命、refresh 长命,密钥独立 |
| refresh 轮换 | 每次刷新签发新 refresh token,旧的一律作废 |
| 吊销能力 | 把 refresh token 存入 Redis,注销时删除即可强制下线 |
| 存储位置 | refresh token 存 HttpOnly Cookie,防 XSS 窃取 |
六、OAuth2 简介
OAuth2 让第三方应用在不拿到用户密码的情况下访问用户在某个平台的资源("使用 GitHub 登录")。
6.1 授权码流程(Authorization Code)
1. 用户点击"用 GitHub 登录" → 跳转 GitHub 授权页
GET https://github.com/authorize?client_id=xxx&redirect_uri=xxx&scope=user
2. 用户同意后,GitHub 302 跳回回调地址,带授权码 ?code=abc
3. 后端用 code + client_secret 换取 access_token
POST https://github.com/access_token body: { code, client_secret, redirect_uri }
4. 用 access_token 调用用户信息接口
5. 后端据此注册/登录本站用户,建立会话或签发 JWTjavascript
app.get("/auth/github/callback", async (req, res) => {
const { code } = req.query;
// 1. 换 token
const tokenRes = await axios.post(
"https://github.com/login/oauth/access_token",
{ client_id, client_secret, code },
{ headers: { Accept: "application/json" } }
);
const accessToken = tokenRes.data.access_token;
// 2. 拿用户信息
const userRes = await axios.get("https://api.github.com/user", {
headers: { Authorization: `Bearer ${accessToken}` },
});
// 3. 绑定或创建本站账号,然后建立会话
res.redirect("/login-success");
});| Token 类型 | 用途 | 生命周期 |
|---|---|---|
| Authorization Code | 换取 access token | 一次性,5-10 分钟 |
| Access Token | 调用资源接口 | 短(1 小时左右) |
| Refresh Token | 刷新 access token | 长(天/月级) |
七、Node 登录接口完整示例
以 Express + JWT + bcrypt 为例,实现注册、登录与鉴权中间件:
javascript
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const app = express();
app.use(express.json());
// 注册
app.post("/api/register", async (req, res) => {
const { username, password } = req.body;
if (!username || !password || password.length < 8) {
return res.status(400).json({ error: "参数不合法" });
}
const exists = await db.users.findByUsername(username);
if (exists) return res.status(409).json({ error: "用户名已存在" });
const passwordHash = await bcrypt.hash(password, 10);
const user = await db.users.create({ username, passwordHash });
res.status(201).json({ id: user.id, username: user.username });
});
// 登录
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
const user = await db.users.findByUsername(username);
// 统一错误提示,不暴露"用户是否存在"
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: "用户名或密码错误" });
}
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: "2h",
});
res.json({ token, user: { id: user.id, username: user.username } });
});
// 鉴权中间件
function auth(req, res, next) {
const token = (req.headers.authorization || "").replace(/^Bearer\s/, "");
if (!token) return res.status(401).json({ error: "未登录" });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "token 无效或已过期" });
}
}
// 授权:角色校验
function requireRole(role) {
return (req, res, next) =>
req.user.role === role ? next() : res.status(403).json({ error: "无权限" });
}
app.get("/api/me", auth, (req, res) => {
res.json({ userId: req.user.userId, role: req.user.role });
});
app.delete("/api/users/:id", auth, requireRole("admin"), (req, res) => {
// 仅管理员可删除
res.json({ ok: true });
});
app.listen(3000);八、常见安全实践
| 措施 | 做法 | 防什么 |
|---|---|---|
| HTTPS 全站启用 | 证书 + 强制跳转 | 中间人窃听、Cookie 被截 |
| HttpOnly + Secure Cookie | 会话 Cookie 加 httpOnly、secure、SameSite | XSS 窃取、CSRF |
| 密码策略 | 最小长度、强度校验、bcrypt 哈希 | 弱口令被撞库 |
| 登录限流 | 失败次数 + IP 维度限流、验证码 | 暴力破解 |
| 防暴力破解 | Redis 计数:连续失败 N 次锁定一段时间 | 撞库攻击 |
| 输入校验 | 参数白名单校验(如 zod、express-validator) | 注入、畸形请求 |
| 安全响应头 | helmet 中间件一键设置 | XSS、点击劫持等 |
javascript
const rateLimit = require("express-rate-limit");
const helmet = require("helmet");
app.use(helmet());
// 登录接口限流:每 IP 15 分钟最多 10 次尝试
app.use("/api/login", rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: "尝试次数过多,请稍后再试" },
}));安全底线:密码只存哈希;密钥走环境变量不进代码库;token 的过期时间要短;所有敏感操作(改密、支付)要求重新认证;记录关键审计日志。