登录与鉴权
认证、授权与鉴权
这三个概念经常被混用,但它们描述的是不同阶段的事情:
- 认证(Authentication):确认"你是谁"。用户输入账号密码、扫码、指纹识别,都是认证行为。
- 授权(Authorization):确认"你能做什么"。认证通过后,系统根据角色或权限决定你能访问哪些资源。
- 鉴权(Access Control):每次请求时,系统验证你携带的凭证是否合法、是否有权访问目标资源。
一个完整的登录流程:用户输入密码完成认证 → 服务端签发凭证并附带授权信息 → 后续每次请求携带凭证进行鉴权。
Cookie-Session 方案
核心原理
HTTP 是无状态协议,服务端无法识别两次请求来自同一个用户。Cookie-Session 的思路是:服务端为每个用户创建一个 Session 对象存储登录状态,通过 Cookie 将 Session ID 传递给浏览器,后续请求自动携带该 Cookie 完成身份识别。
完整流程
1. 用户提交账号密码
2. 服务端验证通过,创建 Session 对象(存储用户信息、权限等)
3. 服务端将 Session ID 写入 Response 的 Set-Cookie 头
4. 浏览器存储 Cookie,后续同域请求自动携带
5. 服务端根据 Cookie 中的 Session ID 查找对应 Session
6. 找到则认为已登录,找不到则返回 401
服务端代码示意:
// 登录接口
app.post('/login', (req, res) => {
const { username, password } = req.body
const user = verifyCredentials(username, password)
if (!user) return res.status(401).json({ message: '认证失败' })
// 创建 Session
req.session.userId = user.id
req.session.role = user.role
res.cookie('sessionId', req.sessionID, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 30 * 60 * 1000 // 30 分钟
})
res.json({ message: '登录成功' })
})
// 鉴权中间件
function authMiddleware(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ message: '未登录' })
}
next()
}
分布式 Session 问题
单机部署时 Session 存在服务器内存里没问题。但集群部署时,用户第一次请求到 A 机器创建了 Session,第二次请求被负载均衡到 B 机器,B 上没有这个 Session,就会判断为未登录。
常见解决方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| Session 粘滞 | Nginx 根据 IP 或 Cookie 固定转发到同一台机器 | 简单,但机器挂了 Session 丢失 |
| Session 复制 | 多台机器间同步 Session 数据 | 延迟高,机器越多同步开销越大 |
| 集中存储 | Session 存 Redis/Memcached,所有机器共享 | 主流方案,依赖 Redis 高可用 |
生产环境基本都选集中存储,用 Redis 存 Session,配合哨兵或集群保证可用性。
JWT 方案
为什么需要 JWT
Cookie-Session 依赖服务端存储状态,集群环境需要额外的共享存储。JWT(JSON Web Token)的思路是把用户信息直接编码在 Token 中,服务端不存储任何会话状态,只需验证 Token 的签名是否合法。
JWT 结构
一个 JWT 由三部分组成,用 . 连接:
Header.Payload.Signature
// Header - 声明类型和签名算法
{
"alg": "HS256",
"typ": "JWT"
}
// Payload - 用户数据(Claims)
{
"sub": "1234567890", // 用户 ID
"name": "John", // 自定义字段
"role": "admin", // 权限信息
"iat": 1716000000, // 签发时间
"exp": 1716003600 // 过期时间
}
// Signature - 签名
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
注意:Header 和 Payload 只是 Base64Url 编码,不是加密。任何人都能解码看到内容,所以 Payload 中不要放敏感信息(密码、手机号等)。
签发与验证流程
签发:
1. 用户提交凭证,服务端验证通过
2. 服务端用密钥对 Header + Payload 计算签名
3. 拼接三部分生成 Token,返回给客户端
验证:
1. 客户端请求携带 Token(通常放 Authorization 头)
2. 服务端取出 Header 和 Payload,用同一密钥重新计算签名
3. 对比计算出的签名和 Token 中的签名是否一致
4. 校验 exp 是否过期
5. 一致且未过期则鉴权通过
// 签发
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '2h' }
)
// 验证中间件
function jwtAuth(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ message: '缺少 Token' })
}
const token = authHeader.slice(7)
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = decoded
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token 已过期' })
}
return res.status(401).json({ message: 'Token 无效' })
}
}
无状态特性
JWT 最大的特点是无状态——服务端不需要存储任何会话信息,只要持有密钥就能验证 Token 合法性。这带来的好处是天然支持水平扩展,任何一台服务器都能独立完成鉴权。
但无状态也有代价:Token 一旦签发,在过期前无法主动让它失效。用户修改密码、管理员封禁账号,已签发的 Token 仍然有效。解决这个问题通常需要引入黑名单机制(Redis 存储已废弃的 Token ID),这又部分回到了有状态模式。
刷新 Token 机制
Access Token 设置较短过期时间(如 15 分钟到 2 小时),Refresh Token 设置较长过期时间(如 7 天到 30 天)。
1. 登录成功,服务端返回 Access Token + Refresh Token
2. 客户端用 Access Token 访问业务接口
3. Access Token 过期,客户端用 Refresh Token 请求刷新接口
4. 服务端验证 Refresh Token 有效,签发新的 Access Token
5. Refresh Token 也过期,用户需重新登录
// 刷新接口
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET)
// 检查 Refresh Token 是否在黑名单
if (isTokenRevoked(decoded.jti)) {
return res.status(401).json({ message: '令牌已失效' })
}
const newAccessToken = jwt.sign(
{ userId: decoded.userId, role: decoded.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
)
res.json({ accessToken: newAccessToken })
} catch {
res.status(401).json({ message: '请重新登录' })
}
})
Token 存储位置选择
| 存储位置 | XSS 风险 | CSRF 风险 | 说明 |
|---|---|---|---|
| localStorage | 高,JS 可直接读取 | 无(不自动携带) | 方便但不安全 |
| sessionStorage | 高,JS 可直接读取 | 无 | 关闭标签页即丢失 |
| Cookie(httpOnly) | 低,JS 无法读取 | 有(自动携带) | 需配合 CSRF 防护 |
| 内存变量 | 低 | 无 | 刷新页面丢失,需配合 Refresh Token |
推荐方案:Access Token 存内存变量,Refresh Token 存 httpOnly Cookie。页面刷新时通过 Refresh Token 静默获取新的 Access Token。
Session vs Token 对比
| 维度 | Cookie-Session | JWT Token |
|---|---|---|
| 状态存储 | 服务端存储 Session | 客户端持有 Token,服务端无状态 |
| 扩展性 | 需要共享 Session(Redis) | 天然支持水平扩展 |
| 跨域支持 | Cookie 受同源策略限制 | Token 放 Header,不受限 |
| 主动失效 | 服务端删除 Session 即可 | 需要额外黑名单机制 |
| 性能 | 每次请求查 Redis | 每次请求做签名计算(CPU 密集) |
| 移动端适配 | Cookie 在移动端支持不佳 | Header 携带 Token,天然适配 |
| 安全性 | 依赖 Cookie 安全配置 | 依赖 Token 存储和传输安全 |
| 信息承载 | Session 可存任意数据 | Payload 大小有限制(建议 < 1KB) |
选型建议:
- 传统 Web 应用、单体架构 → Cookie-Session
- 前后端分离、微服务架构、移动端 → JWT
- 对安全要求极高(银行、支付) → Session + 短有效期 + 多因素认证
OAuth 2.0
解决什么问题
用户想用微信账号登录你的网站,但你的网站不应该知道用户的微信密码。OAuth 2.0 解决的就是第三方授权问题:让用户在不暴露密码的前提下,授权第三方应用访问自己在某个平台上的资源。
四种授权模式
| 模式 | 适用场景 | 安全性 |
|---|---|---|
| 授权码模式(Authorization Code) | 有后端的 Web 应用 | 最高 |
| 隐式模式(Implicit) | 纯前端 SPA(已不推荐) | 低 |
| 密码模式(Resource Owner Password) | 高度信任的第一方应用 | 中 |
| 客户端凭证模式(Client Credentials) | 服务间通信,无用户参与 | 中 |
现代应用推荐授权码模式 + PKCE(纯前端应用也适用),隐式模式因安全性问题已被 OAuth 2.1 草案废弃。
授权码模式详细流程
以"用 GitHub 登录某博客平台"为例:
角色说明:
- Resource Owner:用户
- Client:博客平台(第三方应用)
- Authorization Server:GitHub 授权服务器
- Resource Server:GitHub API 服务器
流程:
1. 用户点击"GitHub 登录"
2. 博客平台将用户重定向到 GitHub 授权页:
GET https://github.com/login/oauth/authorize?
response_type=code&
client_id=xxx&
redirect_uri=https://blog.com/callback&
scope=read:user&
state=随机字符串(防CSRF)
3. 用户在 GitHub 页面确认授权
4. GitHub 将用户重定向回博客平台:
GET https://blog.com/callback?code=授权码&state=随机字符串
5. 博客平台后端用授权码换 Access Token(服务端间通信,不经过浏览器):
POST https://github.com/login/oauth/access_token
Body: { client_id, client_secret, code, redirect_uri }
6. GitHub 返回 Access Token
7. 博客平台用 Access Token 调用 GitHub API 获取用户信息
8. 博客平台根据用户信息完成注册/登录,建立自己的会话
关键安全设计:
- 授权码通过前端传递,但它本身无法直接获取资源,必须配合
client_secret换 Token - Access Token 的交换在服务端间完成,不暴露给浏览器
- state 参数防止 CSRF 攻击,确保回调确实是用户发起的
// 后端用授权码换 Token
app.get('/callback', async (req, res) => {
const { code, state } = req.query
// 验证 state 防 CSRF
if (state !== req.session.oauthState) {
return res.status(403).json({ message: 'state 校验失败' })
}
// 用授权码换 Access Token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: 'https://blog.com/callback'
})
})
const { access_token } = await tokenResponse.json()
// 用 Token 获取用户信息
const userResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` }
})
const githubUser = await userResponse.json()
// 根据 GitHub 用户信息创建或查找本站用户,建立会话
const localUser = await findOrCreateUser(githubUser)
req.session.userId = localUser.id
res.redirect('/dashboard')
})
SSO 单点登录
核心思路
SSO(Single Sign-On)解决的是同一公司内多个系统间的登录状态共享。用户在 A 系统登录后,访问 B 系统不需要再次登录。
实现原理是引入一个独立的认证中心(SSO Server),所有子系统的登录都委托给它。
基于 Cookie 的同域 SSO
如果所有子系统在同一个主域下(如 a.company.com、b.company.com),可以将 Cookie 的 domain 设为 .company.com,所有子域都能读到这个 Cookie。
这是最简单的 SSO 方案,但要求所有系统同域。
基于认证中心的跨域 SSO(CAS 模式)
1. 用户访问 A 系统,A 发现未登录
2. A 将用户重定向到 SSO 认证中心
3. 用户在认证中心登录,认证中心创建全局 Session,写入认证中心域的 Cookie
4. 认证中心将用户重定向回 A,URL 中带上 Ticket
5. A 的后端拿 Ticket 去认证中心验证,验证通过后建立 A 的局部 Session
6. 用户访问 B 系统,B 发现未登录,重定向到认证中心
7. 认证中心发现已有全局 Session(Cookie 还在),直接签发 Ticket 重定向回 B
8. B 验证 Ticket,建立局部 Session
9. 用户无感完成 B 的登录
关键点:认证中心维护全局登录状态,各子系统维护各自的局部会话,通过 Ticket 机制完成信任传递。
Token 安全实践
过期策略
- Access Token:15 分钟 ~ 2 小时,越短越安全
- Refresh Token:7 天 ~ 30 天,根据业务安全等级调整
- 敏感操作(支付、改密码)即使 Token 未过期也应要求重新认证
刷新机制要点
- Refresh Token 使用一次后立即失效,签发新的 Refresh Token(Rotation)
- 检测到 Refresh Token 被复用时,立即废弃该用户所有 Token(可能被盗用)
- 刷新请求失败时的前端处理:清除本地凭证,跳转登录页
// 前端 Axios 拦截器实现无感刷新
let isRefreshing = false
let pendingRequests = []
axios.interceptors.response.use(null, async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 正在刷新,把请求存入队列
return new Promise((resolve) => {
pendingRequests.push((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(axios(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { data } = await axios.post('/refresh')
const newToken = data.accessToken
setAccessToken(newToken)
// 重发队列中的请求
pendingRequests.forEach(callback => callback(newToken))
pendingRequests = []
originalRequest.headers.Authorization = `Bearer ${newToken}`
return axios(originalRequest)
} catch {
// 刷新失败,清除凭证跳转登录
clearTokens()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
})
XSS 防护
XSS 攻击能通过注入脚本窃取存储在 JavaScript 可访问位置的 Token。
防护措施:
- Token 不要存 localStorage,优先使用 httpOnly Cookie 或内存变量
- 对所有用户输入做转义(输出编码)
- 配置 Content-Security-Policy 头限制脚本来源
- 使用框架内置的 XSS 防护(React 默认转义、Vue 的 v-text)
CSRF 防护
当 Token 存在 Cookie 中时,浏览器会在跨站请求时自动携带,攻击者可以借此伪造请求。
防护措施:
- Cookie 设置
SameSite=Strict或SameSite=Lax - 关键操作接口校验自定义请求头(如
X-CSRF-Token) - 验证
Origin/Referer头 - 使用 Double Submit Cookie 模式
// 服务端设置 Cookie 安全属性
res.cookie('token', value, {
httpOnly: true, // JS 无法读取,防 XSS
secure: true, // 仅 HTTPS 传输
sameSite: 'Strict', // 禁止跨站携带,防 CSRF
maxAge: 900000, // 15 分钟
path: '/',
domain: '.example.com'
})
综合安全清单
| 威胁 | 防护手段 |
|---|---|
| Token 泄露 | httpOnly Cookie、短过期时间、HTTPS |
| XSS 窃取 Token | CSP、输入转义、避免 localStorage |
| CSRF 伪造请求 | SameSite、CSRF Token、验证 Origin |
| Token 被重放 | 绑定设备指纹、IP 异常检测 |
| Refresh Token 被盗 | Token Rotation、复用检测 |
面试高频追问
Q:JWT Token 被盗了怎么办?
无法直接让已签发的 JWT 失效。应对方案:维护 Token 黑名单(Redis)、缩短 Token 有效期、检测异常登录(IP / 设备变化)主动废弃 Refresh Token。
Q:为什么不把 Token 存 localStorage?
localStorage 对同源下所有 JavaScript 代码可见,一旦页面存在 XSS 漏洞,攻击者可以轻松读取 Token。httpOnly Cookie 对 JS 不可见,安全性更高。
Q:OAuth 中为什么要先发授权码再换 Token,不能直接返回 Token 吗?
授权码通过浏览器重定向传递,可能被中间人或浏览器历史记录截获。授权码本身无法直接获取资源,必须配合 client_secret 在服务端换取 Token。这样即使授权码泄露,攻击者没有 client_secret 也无法获取 Access Token。
Q:Session 和 JWT 如何选?
不是非此即彼。看场景:需要即时踢人下线、权限实时变更 → Session;微服务架构、需要跨域跨平台 → JWT;两者也可以组合使用,例如用 JWT 做服务间认证,用 Session 管理面向用户的 Web 会话。
Q:刷新 Token 时如何避免并发请求问题?
当 Access Token 过期时可能多个请求同时触发刷新。前端需要用一个锁变量(isRefreshing),第一个 401 响应触发刷新,后续请求进入等待队列,刷新完成后统一重发。上面的 Axios 拦截器代码就是这个思路。
总结
- 认证确认身份,授权分配权限,鉴权验证凭证
- Cookie-Session 是有状态方案,简单可靠但扩展需要共享存储
- JWT 是无状态方案,天然支持分布式但无法主动失效
- OAuth 2.0 解决第三方授权问题,授权码模式最安全
- SSO 通过认证中心实现多系统登录状态共享
- 安全实践的核心:缩短 Token 有效期、httpOnly 存储、HTTPS 传输、防 XSS/CSRF