401鉴权问题完全指南:从"门卫拦人"到"畅通无阻"
一句话总结 :不同域名服务之间的401问题,本质是**"身份证在跨区通行时失效了"。最佳解法是在API网关层**统一处理,让业务服务"无感通行"。
一、先搞懂:401到底是什么?
1.1 401 vs 403:别再把"没进门"和"没权限"搞混了
| 状态码 | 通俗解释 | 谁的问题 | 该谁处理 |
|---|---|---|---|
| 401 Unauthorized | "请出示证件!"------你没带身份证,或身份证是假的/过期的 | 认证问题(你是谁?) | 网关层统一处理 |
| 403 Forbidden | "证件是真的,但你不准进这个房间" | 授权问题(你能做什么?) | 应用层业务处理 |
关键区别:401是"不认识你",403是"认识你不让你进"。
1.2 401的底层逻辑
┌─────────┐ 请求带凭证 ┌─────────┐ 校验凭证 ┌─────────┐
│ 客户端 │ ───────────────→ │ 服务端 │ ─────────────→ │ 认证中心 │
│ │ │ │ │ │
│ │ ←──── 401 ─────── │ │ ←── 凭证无效 ─── │ │
└─────────┘ "我不认识你" └─────────┘ └─────────┘
服务端返回401,只有三种可能:
- 没带凭证(请求头里没有Token/Cookie)
- 凭证格式错误 (有Token但拼写错了、漏了
Bearer前缀) - 凭证无效(过期了、被吊销了、签名对不上)
二、为什么多域名场景下401特别频发?
2.1 浏览器的"安全洁癖"
浏览器有个核心安全机制叫同源策略(Same-Origin Policy):
同源 = 协议 + 域名 + 端口 完全相同
| 前端页面 | 请求API | 是否同源 | 结果 |
|---|---|---|---|
https://a.com |
https://a.com/api |
✅ 同源 | 正常通行 |
https://a.com |
https://b.com/api |
❌ 跨域 | 被浏览器拦截或限制 |
https://a.com |
https://api.a.com |
❌ 跨域(子域不同) | 被浏览器拦截或限制 |
跨域时浏览器会做什么?
前端(a.com) ──→ 请求API(b.com)
│
▼
浏览器:"等等!这是跨域请求!"
│
├── 先发送 OPTIONS 预检请求(不带凭证)
│ └── 服务端:"我允许a.com访问,允许带凭证"
│
└── 再发送真实请求(此时才带Token)
└── 但如果CORS配置错了,Token根本发不出去!
2.2 凭证的"地域限制"
Cookie的域限制
javascript
// 在 a.com 登录后,服务端设置Cookie
document.cookie = "token=abc123; domain=.a.com; path=/";
// ↑ 关键!必须显式设置 domain
// 如果不设置domain,默认只在当前域名生效,子域读不到!
| Cookie配置 | a.com能否读取 |
api.a.com能否读取 |
b.com能否读取 |
|---|---|---|---|
| 无domain(默认) | ✅ | ❌ | ❌ |
domain=.a.com |
✅ | ✅ | ❌ |
domain=.parent.com |
❌ | ❌ | ✅(如果b.com是子域) |
localStorage/sessionStorage 的隔离
javascript
// 在 a.com 执行
localStorage.setItem('token', 'abc123');
// 在 b.com 执行
localStorage.getItem('token'); // null!完全读不到!
结论 :localStorage是严格同源的,不同域名之间完全隔离,无法共享。
2.3 Token本身的"生命周期"
Token生命周期:
签发 ──→ 有效 ──→ 即将过期 ──→ 过期 ──→ 被吊销
│ │ │ │ │
│ │ │ │ └── 加入Redis黑名单
│ │ │ └────────────── 返回401,需刷新
│ │ └────────────────────────── 提前刷新(推荐)
│ └────────────────────────────────────── 正常使用
└──────────────────────────────────────────────── 登录时签发
三、四层架构:每层能做什么?
3.1 各层能力总览
| 层级 | 典型组件 | 能否处理401 | 主要职责 | 处理401的优缺点 |
|---|---|---|---|---|
| 网络层 | Nginx、F5、ELB、Envoy | ❌ 不能 | 负载均衡、SSL卸载、TCP转发 | 不感知HTTP状态码,只能做简单header存在性检查 |
| 网关层 | Spring Cloud Gateway、Kong、APISIX、Traefik | ✅ 最佳位置 | 统一认证、JWT校验、路由、限流 | 无侵入、集中管理;增加一跳网络延迟 |
| 应用层 | 各业务服务 | ✅ 可以 | 业务逻辑、二次鉴权 | 灵活精细,但重复实现、侵入性强 |
| 数据层 | MySQL、Redis、ES | ❌ 不能 | 存储认证数据(Token黑名单、权限表) | 只提供数据,不处理鉴权逻辑 |
3.2 网络层:只能"看门",不能"验人"
nginx
# Nginx 只能做最简单的存在性检查
location /api/ {
# ❌ 无法解析JWT内容
# ❌ 无法验证签名
# ❌ 无法处理Token刷新
if ($http_authorization = "") {
return 401; # 只能判断"有没有",不能判断"对不对"
}
proxy_pass http://backend;
}
网络层的局限:
- 只能看到TCP/UDP包,不解析HTTP内容
- 无法验证JWT签名、过期时间
- 无法处理复杂的认证逻辑(如OAuth2、OIDC)
3.3 网关层:最推荐的"统一检查站"
为什么网关是最佳位置?
| 优势 | 说明 |
|---|---|
| 无侵入 | 业务服务零改动,专注业务逻辑 |
| 统一性 | 所有跨域请求必经网关,一处配置全局生效 |
| 解耦 | 认证逻辑与业务分离,升级不影响业务 |
| 可观测 | 集中记录鉴权日志、监控401频率、统一告警 |
| 安全 | 无效请求在网关层就被拦截,不穿透到业务服务 |
网关处理跨域认证的完整流程:
┌─────────┐ 1.请求带Token ┌─────────┐ 2.校验Token ┌─────────┐
│ 前端A │ ───────────────────→ │ 网关 │ ───────────────────→ │ 认证中心 │
│ (a.com) │ Authorization: │ (网关) │ 验证签名/过期时间 │ (Redis) │
│ │ Bearer xxx │ │ 查黑名单 │ │
└─────────┘ └────┬────┘ └─────────┘
│
│ 3.校验通过,注入用户信息
↓
┌─────────┐
│ 业务服务 │
│(b.com) │ ← 收到的请求已带X-User-Id
└─────────┘ 无需再处理认证
网关配置示例(Kong/APISIX风格):
yaml
# 网关路由配置
routes:
- name: api_route
paths: ["/api/*"]
upstream: "http://backend-service"
plugins:
# 1. CORS插件:解决跨域预检
- name: cors
config:
allow_origins: ["http://a.com", "http://b.com"] # 明确域名,不能用*
allow_credentials: true # 允许携带凭证
allow_headers: ["Authorization", "Content-Type", "X-Request-Id"]
allow_methods: ["GET", "POST", "PUT", "DELETE"]
max_age: 86400 # 预检缓存24小时
# 2. JWT插件:统一校验Token
- name: jwt
config:
secret: "${JWT_SECRET}"
header_names: ["Authorization"]
# 校验失败直接返回401,请求不会转发到业务服务
# 3. 限流插件(防暴力破解)
- name: rate-limiting
config:
minute: 100
policy: redis
# 4. 日志插件(可观测性)
- name: file-log
config:
path: "/var/log/kong/auth.log"
3.4 应用层:做"细粒度权限"的守门员
应用层处理的典型场景:
| 场景 | 示例 | 返回码 |
|---|---|---|
| 资源归属校验 | 用户A只能看自己的订单 | 403 |
| 动态权限 | 上班时间可访问,下班不可 | 403 |
| 多租户隔离 | 租户A的数据租户B看不到 | 403 |
| 特殊业务规则 | VIP用户才能下载报告 | 403 |
java
// Spring Boot 应用层示例
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id,
@RequestHeader("X-User-Id") Long userId) {
// 网关已校验Token有效性,这里只需校验"能不能看"
Order order = orderService.findById(id);
if (!order.getUserId().equals(userId)) {
// ❌ 不要返回401!凭证是有效的,只是没权限
throw new AccessDeniedException("无权查看该订单"); // 返回403
}
return order;
}
}
3.5 数据层:只做"档案室",不做"门卫"
redis
# Redis 只存储认证相关数据,不执行鉴权逻辑
# 1. Token黑名单(用户登出、Token被吊销)
SET "blacklist:token_abc123" "revoked" EX 7200
# 2. 用户权限缓存(网关查询用)
HSET "user:1001:permissions" "order:read" "true"
HSET "user:1001:permissions" "order:delete" "false"
# 3. Token刷新记录(防止并发刷新导致Token混乱)
SET "refresh:token_abc123" "new_token_xyz789" EX 300
# 网关或应用层查询Redis做二次校验,但逻辑在网关层
四、实战:多域名场景的典型架构
4.1 推荐架构:统一网关 + SSO
┌─────────────────────────────┐
│ 统一认证中心 (SSO) │
│ auth.company.com │
│ • 签发Token │
│ • 刷新Token │
│ • 管理用户/权限 │
└──────────────┬──────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ 前端项目A │ │ API网关 │ │ 前端项目B │
│ a.com │──────────→│ gateway.com │←─────────│ b.com │
│ │ │ │ │ │
│ Vue项目 │ │ • 统一CORS │ │ React项目 │
└──────────┘ │ • JWT校验 │ └──────────┘
│ • 限流/降级 │
│ • 日志/监控 │
└──────┬───────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 订单服务 │ │ 用户服务 │ │ 商品服务 │
│ order │ │ user │ │ product │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└──────────────────────┼──────────────────────┘
│
┌──────┴──────┐
│ Redis │
│ • Token黑名单│
│ • 权限缓存 │
│ • 限流计数 │
└─────────────┘
4.2 前端请求完整流程
javascript
// 前端A (a.com) 请求后端API
const api = axios.create({
baseURL: 'https://gateway.company.com', // 统一走网关
withCredentials: true, // 允许携带Cookie
timeout: 10000
});
// 请求拦截器:自动加Token
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:统一处理401
api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 调用刷新Token接口
const { data } = await axios.post('https://auth.company.com/refresh', {
refreshToken: localStorage.getItem('refreshToken')
});
// 更新Token
localStorage.setItem('token', data.token);
originalRequest.headers.Authorization = `Bearer ${data.token}`;
// 重试原请求
return api(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录页
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
五、排查401的标准SOP(由外到内)
5.1 排查流程图
遇到401错误
│
├── Step 1: 检查浏览器Network面板
│ ├── Request Headers里有Authorization吗?
│ │ ├── ❌ 没有 → 检查前端拦截器/存储逻辑
│ │ └── ✅ 有 → 继续
│ └── 查看Cookie是否携带(如果是Cookie方案)
│ ├── ❌ 没有 → 检查withCredentials + CORS配置
│ └── ✅ 有 → 继续
│
├── Step 2: 用Postman/Apifox模拟请求
│ ├── 能通 → 问题在浏览器/CORS/前端
│ └── 不能通 → 问题在后端
│
├── Step 3: 检查服务端日志
│ ├── 收到Token了吗?
│ │ ├── ❌ 没有 → 网络层/网关层丢了
│ │ └── ✅ 有 → 继续
│ └── 认证服务日志:为什么校验失败?
│ ├── "Token expired" → 过期,检查刷新机制
│ ├── "Signature verification failed" → 密钥/算法不匹配
│ ├── "Token not found in cache" → Redis/缓存问题
│ └── "Token revoked" → 被吊销,检查黑名单逻辑
│
└── Step 4: 检查网关配置
├── CORS配置是否正确?
├── JWT密钥/算法是否一致?
└── 路由是否匹配?
5.2 排查检查清单
| 检查项 | 工具/方法 | 期望结果 |
|---|---|---|
| 前端是否携带Token | 浏览器F12 → Network → Request Headers | Authorization: Bearer xxx |
| Cookie是否跨域 | 浏览器F12 → Application → Cookies | Domain设置正确,Secure/HttpOnly符合场景 |
| CORS预检是否通过 | 浏览器F12 → Network → OPTIONS请求 | Status 200,响应头包含正确CORS配置 |
| 后端是否收到Token | 后端网关/应用日志 | 日志中显示Token内容或长度 |
| Token是否有效 | 认证服务日志/调试 | 签名验证通过,未过期,不在黑名单 |
| 网络层是否丢头 | Nginx/网关访问日志 | 原始请求头完整传递 |
六、解决方案决策树
是否所有服务都经过统一入口(网关)?
│
├─ ✅ 是 → 使用网关层统一处理(强烈推荐)
│ │
│ ├─ 前端请求 → 网关统一校验JWT → 注入用户信息 → 转发到后端服务
│ │
│ ├─ 网关统一配置CORS,业务服务无需关心
│ │
│ └─ 网关统一拦截401,前端只需对接网关
│
└─ ❌ 否(直连模式)→ 只能在应用层各自处理
│
├─ 每个服务都实现JWT校验(代码冗余,维护困难)
│
└─ 建议:引入通用认证SDK,减少重复代码
└─ 例如:封装一个`@RequireAuth`注解或中间件
七、核心原则总结
| 维度 | 推荐做法 | 原因 |
|---|---|---|
| 跨域Token校验 | 统一在网关层完成 | 避免每个服务重复实现,集中管理 |
| CORS跨域配置 | 也在网关层统一配置 | 业务服务零侵入,一处修改全局生效 |
| Token刷新逻辑 | 网关层处理401时统一拦截 | 前端只需对接网关,无需关心后端细节 |
| 业务权限校验 | 应用层处理 | 返回403而非401,语义准确 |
| 认证数据存储 | 数据层只存凭证状态 | Redis存黑名单/权限缓存,不处理逻辑 |
| 前端存储方案 | 同父域用Cookie共享,跨父域用统一登录页 | localStorage无法跨域共享 |
| 开发环境跨域 | 配置反向代理(devServer proxy) | 彻底绕开浏览器CORS限制 |
八、一句话结论
不同域名服务之间的401问题,应在「API网关层」统一处理。 这是架构上的最佳位置------既能统一管理跨域认证,又能让业务服务无感,同时保持扩展性和可观测性。
如果架构中没有网关,则只能在「应用层」各自处理,但建议通过引入通用认证SDK 来减少重复代码。记住:401是"不认识你"(网关管),403是"不让你进"(业务管),别把两者搞混!