登录与鉴权

20 分钟

认证、授权与鉴权

这三个概念经常被混用,但它们描述的是不同阶段的事情:

  • 认证(Authentication):确认"你是谁"。用户输入账号密码、扫码、指纹识别,都是认证行为。
  • 授权(Authorization):确认"你能做什么"。认证通过后,系统根据角色或权限决定你能访问哪些资源。
  • 鉴权(Access Control):每次请求时,系统验证你携带的凭证是否合法、是否有权访问目标资源。

一个完整的登录流程:用户输入密码完成认证 → 服务端签发凭证并附带授权信息 → 后续每次请求携带凭证进行鉴权

核心原理

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-SessionJWT 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),所有子系统的登录都委托给它。

如果所有子系统在同一个主域下(如 a.company.comb.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=StrictSameSite=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 窃取 TokenCSP、输入转义、避免 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