03. 认证与授权
前端常把“登录”理解成拿到一个 token。后端必须再往前一步:你要分清用户是谁、他能做什么、这套机制怎么失效和续期。
先分清两个词
- 认证(Authentication):确认“你是谁”。
- 授权(Authorization):确认“你能做什么”。
一条登录链路最少包含什么
- 用户提交账号密码。
- 服务端校验密码是否正确。
- 服务端签发会话凭证。
- 后续请求携带凭证访问受保护接口。
- 服务端验证凭证并恢复用户身份。
- 鉴权逻辑再判断这个身份有没有权限访问当前资源。
必懂 6 件事
1. 密码不是直接比对明文
密码存储必须是“哈希后再存”,而不是明文。
import bcrypt from 'bcryptjs';
const passwordHash = await bcrypt.hash(plainPassword, 12);
await db.user.create({
data: {
email,
passwordHash,
},
});
const isValid = await bcrypt.compare(inputPassword, user.passwordHash);
if (!isValid) {
throw new Error('invalid credentials');
}
关键点:
- 用
bcrypt、argon2这类专门为密码设计的算法 - 不要自己发明“md5 + salt”这种方案
- 不要把密码写进日志
2. Session / Cookie
- Cookie 存在浏览器里,Session 一般存在服务端。
- 服务端可以随时让某个 Session 失效,控制力强。
- 缺点是服务端要保存状态,扩容时要考虑共享存储。
典型流程:
- 登录成功后,服务端生成
sessionId - 把
sessionId写到 Redis 或数据库 - 用 Cookie 把
sessionId发给浏览器 - 后续请求再从 Cookie 中读出
sessionId
Session 方案的优点是“可控”,例如退出登录时可以立即删除服务端会话。
3. JWT
- JWT 是服务端签发的令牌,服务端可直接验证,不必每次查 Session。
- 优点是简单、适合前后端分离和多端接入。
- 缺点是撤销和续期更麻烦,所以不要把 JWT 当成万能方案。
这里默认说的 JWT,通常指的是 access token,不是 refresh token。
先看一条最小链路:
- 用户提交账号密码
- 服务端校验密码
- 服务端签发
access token - 客户端把它放到
Authorization: Bearer ... - 服务端在受保护接口里校验 JWT
- JWT 过期后,客户端再用
refresh token换新的access token
一个最小的登录签发示例:
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
async function login(email: string, password: string) {
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
throw new UnauthorizedError('invalid credentials');
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
throw new UnauthorizedError('invalid credentials');
}
const accessToken = jwt.sign(
{
sub: user.id,
roles: user.roles,
tokenVersion: user.tokenVersion,
jti: crypto.randomUUID(),
},
process.env.JWT_SECRET!,
{
expiresIn: '15m',
issuer: 'tech-articles',
audience: 'tech-articles-web',
},
);
return { accessToken };
}
JWT 校验示例:
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export function authMiddleware(req: Request, _res: Response, next: NextFunction) {
try {
const authHeader = req.header('Authorization');
const token = authHeader?.replace('Bearer ', '');
if (!token) {
throw new Error('missing token');
}
const payload = jwt.verify(token, process.env.JWT_SECRET!);
req.user = payload;
next();
} catch {
next(new UnauthorizedError('invalid token'));
}
}
JWT 里应该放什么:
- 用户唯一标识,例如
sub - 极少量权限信息,例如
roles - 少量与登录态强相关的信息,例如
tokenVersion - 过期时间、签发方等标准字段
JWT 里不应该放什么:
- 密码
- 大量敏感业务数据
- 需要频繁变更的状态
常见字段解释:
sub:谁登录了exp:什么时候过期iss:谁签发的aud:给谁用的jti:这张 token 的唯一 ID
JWT 的优点是“无状态校验快”,但代价是“发出去后不好撤销”。所以它很适合短期 access token,不适合拿来当永久登录态。
4. Refresh Token 与退出登录
如果 access token 很短,就通常要配 refresh token。
要先明确一件事:
- refresh token 通常不是“写在 JWT 里面的一个字段”
- refresh token 是登录成功后另外签发的一张独立凭证
- 它可以是随机字符串,也可以本身也是一个 JWT,但它和 access token 不是同一个东西
推荐思路:
- access token:短期有效,例如 15 分钟
- refresh token:更长有效期,例如 7 天
- refresh token 存库或存 Redis,支持吊销
登录成功时,服务端通常会同时签发两样东西:
{
"accessToken": "eyJhbGciOi...",
"refreshToken": "a2d9b8c7-6e5f-4a3b-9c1d-123456789abc"
}
或者更常见一点:
- access token 放在响应体里,给前端带到
Authorization头里 - refresh token 放进
HttpOnly Cookie
刷新流程通常长这样:
- access token 过期
- 客户端调用
/auth/refresh - 服务端校验 refresh token
- 校验通过后重新签发新的 access token
- 必要时轮换 refresh token
登录时生成 refresh token:
const refreshToken = randomUUID();
await redis.set(
`refresh-token:${refreshToken}`,
JSON.stringify({ userId: user.id }),
'EX',
7 * 24 * 60 * 60,
);
刷新接口伪代码:
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
throw new UnauthorizedError('missing refresh token');
}
const session = await redis.get(`refresh-token:${refreshToken}`);
if (!session) {
throw new UnauthorizedError('invalid refresh token');
}
const { userId } = JSON.parse(session);
const accessToken = jwt.sign(
{ sub: userId },
process.env.JWT_SECRET!,
{ expiresIn: '15m' },
);
res.json({ accessToken });
});
更稳的做法通常还会“轮换 refresh token”:
- 校验旧 refresh token
- 删除旧 refresh token
- 生成新 refresh token
- 重新写入 Redis 或数据库
- 返回新的 access token 和 refresh token
这样可以降低 refresh token 泄露后的复用风险。
JWT 方案怎么退出登录,要分 3 层看:
- 普通退出登录
- 立即让当前 access token 失效
- 让所有设备全部下线
最小的退出登录一般是:
- 删除服务端保存的 refresh token
- 清掉客户端的 cookie 或本地存储
- 让现有 access token 自然过期
最小示例:
app.post('/auth/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
await redis.del(`refresh-token:${refreshToken}`);
}
res.clearCookie('refreshToken');
res.status(204).end();
});
如果你要“立即让老 access token 失效”,常见有两种办法:
- 黑名单:把
jti记到 Redis,直到 token 过期 tokenVersion:用户退出所有设备时,把数据库里的tokenVersion加 1
tokenVersion 思路:
await db.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
});
之后每次鉴权时,把 JWT 里的 tokenVersion 和数据库当前值比一下,不一致就视为失效。
一句话总结:
- Session 方案:删除 session 就能立刻失效
- JWT 方案:通常是删 refresh token,并让 access token 短期自然过期
- 高安全场景再叠加黑名单或
tokenVersion
5. OAuth 2.0
- 当你需要“用第三方身份登录”或“授权第三方访问资源”时,才需要理解它。
- 如果你只是做自己系统的账号登录,先把 Session 或 JWT 学明白更重要。
前端工程师最常遇到的是“用 GitHub / Google / 微信登录”这类场景。更准确地说,这通常是:
- OAuth 2.0:解决授权
- OpenID Connect:在 OAuth 2.0 上补“用户身份”能力
现代 Web / App 最常用的是 Authorization Code + PKCE。
先看参与方:
- 用户
- 你的应用
- 第三方授权服务器
- 第三方资源服务器,例如
/userinfo
完整流程是:
- 用户跳到第三方授权页
- 你的应用带上
client_id、redirect_uri、scope、state、code_challenge - 用户在第三方登录并同意授权
- 第三方回调你的服务,带回
code - 你的服务用
code + code_verifier去换 token - 第三方返回
access_token,OIDC 场景下还会返回id_token - 你的服务再拉取用户信息并建立本地账户
- 你的系统最后签发自己的 session 或 JWT
如果你只是做自家账号体系,不用先陷进 OAuth 2.0 细节。
授权跳转大概长这样:
GET https://provider.com/oauth/authorize?
response_type=code&
client_id=xxx&
redirect_uri=https://your-app.com/callback&
scope=openid profile email&
state=abc123&
code_challenge=yyy&
code_challenge_method=S256
回调后换 token:
POST https://provider.com/oauth/token
grant_type=authorization_code
code=returned_code
redirect_uri=https://your-app.com/callback
client_id=xxx
client_secret=yyy
code_verifier=original_verifier
回调处理伪代码:
async function oauthCallback(code: string, codeVerifier: string) {
const tokenSet = await exchangeCodeForToken(code, codeVerifier);
const profile = await fetchUserProfile(tokenSet.access_token);
let user = await db.user.findUnique({
where: {
provider_providerUserId: {
provider: 'google',
providerUserId: profile.id,
},
},
});
if (!user) {
user = await db.user.create({
data: {
email: profile.email,
provider: 'google',
providerUserId: profile.id,
},
});
}
return issueLocalTokens(user);
}
这里最重要的判断是:
- 第三方 token 是用来和第三方通信的
- 你的系统内部,最好还是签发你自己的 session 或 JWT
这样你的权限体系、退出登录逻辑和用户模型才不会被第三方绑死。
还要记住 4 个关键参数:
state:防 CSRF,回调时必须校验scope:最小权限原则redirect_uri:必须严格匹配code_verifier / code_challenge:PKCE 防授权码被截获
OAuth 2.0 的退出登录也要分两层:
- 退出你自己的系统
- 是否还要退出第三方登录状态
通常先做的是:
- 删除你本地的 session / refresh token
- 清掉本地 cookie
- 如有保存第三方 refresh token,则同时删除或撤销
但这不等于把 Google / GitHub 本身也登出。用户浏览器里如果第三方仍然在线,下次点“第三方登录”仍可能直接回来。
6. RBAC
- RBAC 是基于角色的授权控制。
- 常见做法是用户拥有角色,角色拥有权限,接口再校验权限。
- 不要把“是否登录”混同于“是否有权限”。
一个最小角色校验中间件:
export function requireRole(...roles: string[]) {
return (req, _res, next) => {
const userRoles = req.user?.roles ?? [];
const allowed = roles.some((role) => userRoles.includes(role));
if (!allowed) {
return next(new ForbiddenError('permission denied'));
}
next();
};
}
安全底线
- 不信任客户端传来的任何身份和角色字段。
- 在系统边界做输入校验,而不是只靠前端表单规则。
- 密码要哈希存储,不要明文保存。
- 敏感接口要有过期、续期、退出登录和审计意识。
- Cookie 方案要配合
HttpOnly、Secure、SameSite - JWT 方案要控制有效期,不要默认发超长生命周期 token
最小实践
- 做注册、登录、刷新 token、获取当前用户信息这 4 个接口。
- 登录后签发 access token;受保护接口必须校验 token。
- 给管理接口加一个最简单的角色判断,例如
admin。
常见误区
- 只验证 token,不验证用户状态。用户被封禁后,老 token 仍可能继续使用。
- 把整个用户对象塞进 JWT。令牌会过大,也不利于状态收敛。
- 用一个长期不失效的 JWT 试图解决所有登录问题。实际只会放大安全风险。
学会的标准
- 你能说明 Session 和 JWT 的主要取舍。
- 你能区分“没登录”和“没权限”。
- 你知道认证方案的核心不是 token 名字,而是状态管理和安全边界。
- 你能写出最小的登录、验 token、刷新 token、角色校验代码。