用户刚在 OA 登录完,打开 CRM 却又要重新输密码。
小程序一启动就弹出“登录失败”。
某天突然发现所有服务都拒绝 Token,原来 JWT 的签名算法改了,全系统都要改一遍……
这些事,我都经历过。
不是一次,是三次。
为了讲清楚这个过程,我虚构了一家公司——“踏浪科技”,它的认证系统,就是基于我亲身经历的痛点,一步步演进而来。
但如果你想理解:
为什么需要网关?为什么不能只靠 JWT 做 SSO?为什么 OAuth2 是必经之路?
请继续往下看。
第一阶段:单体应用 (2020)
业务背景
公司刚成立,开发了第一个内部系统:OA办公系统。
功能很简单:
- 员工考勤打卡
- 请假审批
- 报销流程
用户量:5个员工
技术架构
最简单的单体应用:
graph LR
A[前端 Vue] -->|HTTPS| B[后端 Spring Boot]
B -->|JDBC| C[MySQL]
style A fill:#87CEEB
style B fill:#98FB98
style C fill:#FFD700
登录流程:
sequenceDiagram
participant U as 用户
participant F as 前端
participant B as 后端
participant D as 数据库
U->>F: 输入工号、密码
F->>B: POST /login
B->>D: 验证密码(BCrypt)
D-->>B: 验证通过
B->>B: 生成JWT Token
B-->>F: 返回 {token: "xxx"}
F->>F: 存localStorage
Note over F: 后续请求带<br/>Authorization: Bearer token
核心代码:
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest req) {
// 1. 验证密码
User user = userService.checkPassword(req.getUsername(), req.getPassword());
if (user == null) {
throw new BusinessException("账号或密码错误");
}
// 2. 生成JWT Token
String token = Jwts.builder()
.setSubject(user.getUsername())
.claim("userId", user.getId())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // 15分钟
.signWith(SignatureAlgorithm.HS256, jwtSecret) // 从配置读取
.compact();
return new LoginResponse(token);
}
这个阶段的特点:
- ✅ 简单直接,开发快
- ✅ 单体部署,运维简单
- ✅ 用户量小,性能够用
没有问题,很稳定。
第二阶段:微服务拆分 (2021)
业务变化
公司拿到A轮融资,业务快速扩张:
- OA系统功能越来越多(考勤、审批、报销、绩效、培训...)
- 代码库膨胀到5万行
- 团队从5人扩张到20人
- 不同模块由不同小组开发
老板的要求: "OA系统太重了,要拆成微服务,方便各个团队独立开发部署。"
拆分后的架构
graph TB
F[前端 Vue] --> G{问题来了:<br/>前端怎么调用?}
G --> U[用户服务 :8001]
G --> A[考勤服务 :8002]
G --> P[审批服务 :8003]
G --> R[报销服务 :8004]
style F fill:#87CEEB
style G fill:#FFA07A
style U fill:#98FB98
style A fill:#98FB98
style P fill:#98FB98
style R fill:#98FB98
遇到的问题
问题1: 前端要记4个地址?
// 登录调用户服务
axios.post('http://localhost:8001/login')
// 查考勤调考勤服务
axios.get('http://localhost:8002/attendance')
// 提交审批调审批服务
axios.post('http://localhost:8003/approval')
前端要配置多个baseURL,维护成本高。
问题2: 每个服务都要验证JWT?
// 用户服务需要验证
@Component
public class JwtFilter { ... }
// 考勤服务也需要验证
@Component
public class JwtFilter { ... }
// 审批服务还需要验证
@Component
public class JwtFilter { ... }
验证逻辑复制4份,改个算法要改4个地方。
问题3: CORS跨域配置要配4次?
每个服务都要配一遍允许的前端域名。
引入Spring Cloud Gateway
解决方案: 加一个网关,统一入口。
graph TB
F[前端 Vue] -->|统一调用 :8000| G[Gateway 网关]
G -->|路由转发| U[用户服务 :8001]
G -->|路由转发| A[考勤服务 :8002]
G -->|路由转发| P[审批服务 :8003]
G -->|路由转发| R[报销服务 :8004]
style F fill:#87CEEB
style G fill:#FFD700
style U fill:#98FB98
style A fill:#98FB98
style P fill:#98FB98
style R fill:#98FB98
网关做的事:
flowchart LR
A[请求到达] --> B{验证JWT}
B -->|有效| C[提取userId]
C --> D[放入Header:<br/>X-User-Id]
D --> E[路由转发<br/>给下游服务]
B -->|无效| F[返回 401]
style A fill:#87CEEB
style B fill:#FFE4B5
style C fill:#98FB98
style D fill:#98FB98
style E fill:#FFD700
style F fill:#FFA07A
核心代码:
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 提取Token
String token = extractToken(exchange.getRequest());
if (token == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 2. 验证JWT (只在网关验证一次)
try {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
Long userId = claims.get("userId", Long.class);
// 3. 把userId放到Header,传给下游服务
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header("X-User-Id", userId.toString())
.header("X-Username", claims.getSubject())
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
} catch (JwtException e) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
@Override
public int getOrder() {
return -100; // 优先级最高
}
}
下游服务简化了:
// 考勤服务
@GetMapping("/my-attendance")
public List<Attendance> getMyAttendance(@RequestHeader("X-User-Id") Long userId) {
// 不需要验证JWT了,网关已经验证过
// 直接使用userId
return attendanceService.getByUserId(userId);
}
前端也简化了:
// 只需要配置一个baseURL
axios.defaults.baseURL = 'http://localhost:8000'
// 所有请求都走网关
axios.get('/user/info') // 网关路由到用户服务
axios.get('/attendance/list') // 网关路由到考勤服务
axios.post('/approval/submit') // 网关路由到审批服务
这个阶段的特点:
- ✅ 前端统一入口,配置简化
- ✅ JWT验证只在网关做一次
- ✅ 下游服务只管业务逻辑
- ✅ CORS配置只在网关配一次
微服务架构稳定运行,问题解决。
第三阶段:多系统SSO (2022)
业务变化
公司业务继续扩张:
- 开发了CRM客户管理系统
- 开发了财务系统
- 收购了一家小公司,整合了他们的ERP系统
现在有4个独立的系统:
- OA办公系统 (oa.company.com)
- CRM客户管理 (crm.company.com)
- 财务系统 (finance.company.com)
- ERP系统 (erp.company.com)
用户的抱怨
"我每天要登录4次,每个系统都要输一遍密码?"
"用的是同一个账号,为什么不能登录一次就行?"
"这系统是不是有bug?"
老板发话了: "必须搞单点登录(SSO),用户体验太差了!"
问题分析
为什么不能共享登录状态?
graph TB
A[用户在OA登录] --> B[Token存在<br/>oa.company.com<br/>的localStorage]
C[用户打开CRM] --> D[crm.company.com<br/>的localStorage<br/>是空的!]
style A fill:#98FB98
style B fill:#FFD700
style C fill:#FFA07A
style D fill:#FFA07A
问题核心:
- localStorage不能跨域共享
oa.company.com的数据,crm.company.com访问不到- 浏览器的安全策略,无法绕过
能用Cookie吗?
理论上可以:
如果设置: domain=.company.com
那么 oa.company.com、crm.company.com 都能访问
但这有局限:
- 只能同一个顶级域名
- 如果是
oa.com和crm.net,Cookie完全没用 - 我们收购的ERP系统用的是旧域名
erp-old.net
需要一个通用方案,不管什么域名都能用。
引入认证中心:OAuth2
解决方案: 搭建一个认证中心,用OAuth2协议。
graph TB
subgraph "认证中心"
Auth[OAuth2 Server<br/>auth.company.com]
AuthDB[(用户数据库)]
end
subgraph "OA系统"
OAF[前端] --> OAG[网关] --> OAS[微服务]
end
subgraph "CRM系统"
CRMF[前端] --> CRMG[网关] --> CRMS[微服务]
end
subgraph "财务系统"
FF[前端] --> FG[网关] --> FS[微服务]
end
OAF -.->|没登录跳转| Auth
CRMF -.->|没登录跳转| Auth
FF -.->|没登录跳转| Auth
Auth --> AuthDB
style Auth fill:#FFD700
完整流程:第一次登录OA
sequenceDiagram
participant U as 用户
participant OA as OA前端
participant Auth as 认证中心
participant OABE as OA后端
U->>OA: 1. 访问 oa.company.com
OA->>OA: 2. 检查localStorage<br/>没有token
OA->>Auth: 3. 跳转到认证中心<br/>auth.company.com/login?<br/>redirect=oa.company.com/callback
Auth->>U: 4. 显示登录页
U->>Auth: 5. 输入工号、密码
Auth->>Auth: 6. 验证通过<br/>创建Session(userId=123)<br/>设置Cookie
Auth->>Auth: 7. 生成授权码<br/>code=ABC123
Auth->>OA: 8. 跳转 oa.company.com/callback?code=ABC123
OA->>OABE: 9. 把code发给后端
OABE->>Auth: 10. 用code换Token<br/>(带client_id、client_secret)
Auth-->>OABE: 11. 返回Token=JWT-xxx
OABE-->>OA: 12. 返回Token给前端
OA->>OA: 13. 存localStorage
Note over OA: 登录成功!
关键代码:
// 认证中心:登录接口
@PostMapping("/login")
public void login(@RequestParam String username,
@RequestParam String password,
@RequestParam String redirect,
HttpSession session,
HttpServletResponse response) throws IOException {
// 1. 验证密码
User user = userService.checkPassword(username, password);
if (user == null) {
throw new BusinessException("账号或密码错误");
}
// 2. 创建Session (关键!)
session.setAttribute("userId", user.getId());
// 3. 生成授权码
String code = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"oauth:code:" + code,
user.getId(),
5,
TimeUnit.MINUTES
);
// 4. 跳转回OA,带上授权码
response.sendRedirect(redirect + "?code=" + code);
}
此时浏览器有了认证中心的Cookie:
Cookie: JSESSIONID=abc123; domain=auth.company.com; path=/
SSO生效:访问CRM自动登录
sequenceDiagram
participant U as 用户
participant CRM as CRM前端
participant Auth as 认证中心
participant CRMBE as CRM后端
U->>CRM: 1. 访问 crm.company.com
CRM->>CRM: 2. 检查localStorage<br/>没有token
CRM->>Auth: 3. 跳转到认证中心<br/>auth.company.com/oauth/authorize?<br/>client_id=crm&redirect_uri=crm.company.com/callback
Note over Auth: 浏览器自动带上<br/>Cookie: JSESSIONID=abc123
Auth->>Auth: 4. 从Cookie拿到Session
Auth->>Auth: 5. Session里有userId=123<br/>用户已登录!
Note over Auth: 不需要输密码!
Auth->>Auth: 6. 直接生成授权码<br/>code=XYZ789
Auth->>CRM: 7. 立即跳转<br/>crm.company.com/callback?code=XYZ789
CRM->>CRMBE: 8. 把code发给后端
CRMBE->>Auth: 9. 用code换Token
Auth-->>CRMBE: 10. 返回Token=JWT-yyy
CRMBE-->>CRM: 11. 返回Token给前端
CRM->>CRM: 12. 存localStorage
Note over CRM: 自动登录成功!<br/>用户无感知!
用户体验:
- 在OA登录时输入密码
- 访问CRM,浏览器地址栏闪了一下,直接进去了
- 全程没有看到登录页
- 只输了一次密码!
为什么能自动登录?
核心原理:认证中心的Session
graph TB
A[第一次在OA登录] --> B[认证中心创建Session<br/>userId=123]
B --> C[浏览器存Cookie<br/>JSESSIONID=abc123<br/>domain=auth.company.com]
D[访问CRM] --> E[CRM跳转到认证中心]
E --> F[浏览器自动带Cookie]
F --> G[认证中心从Cookie<br/>拿到JSESSIONID]
G --> H[根据sessionId<br/>找到Session]
H --> I[Session里有userId?]
I -->|有| J[直接发授权码<br/>不要密码]
I -->|没有| K[显示登录页]
style B fill:#FFD700
style C fill:#98FB98
style F fill:#87CEEB
style J fill:#FFD700
三个关键点:
-
认证中心用Session记住"谁登录过"
session.setAttribute("userId", 123); -
浏览器用Cookie访问Session (Cookie是钥匙)
请求 auth.company.com 时,浏览器自动带上: Cookie: JSESSIONID=abc123 -
授权码是一次性通行证 (跨域传递)
通过URL参数传递: ?code=XYZ789 不依赖Cookie,可以跨任何域名
这个方案的优势:
- ✅ 支持任意域名组合 (oa.com + crm.net + erp.org)
- ✅ 用户只登录一次
- ✅ 符合OAuth2标准,生态完善
浏览器兼容性提示: Safari/Chrome默认阻止第三方Cookie,可能影响跨域SSO。企业内网通常没问题,公网部署建议同域名或使用PKCE增强模式(详见第4篇)。
SSO成功上线,用户满意度大幅提升。
第四阶段:移动端登录 (2023)
业务变化
产品经理提需求:
- 要开发微信小程序,方便手机上审批
- 要开发钉钉小程序,和钉钉打通
- App端也要支持
遇到的问题
小程序/App没有Cookie,没有localStorage!
浏览器Web端:
- 有Cookie (浏览器自动管理)
- 有localStorage (手动存Token)
- 访问
auth.company.com自动带Cookie
小程序/App:
- 没有Cookie
- 有独立的存储 (wx.setStorageSync / SharedPreferences)
- 每个App是独立沙盒,完全隔离
传统的OAuth2 SSO在移动端失效!
因为SSO依赖:
- 浏览器自动带Cookie
- 认证中心通过Cookie识别"已登录"
移动端没Cookie,认证中心无法识别。
移动端的解决方案
方案1: 扫码登录
sequenceDiagram
participant App as 手机App<br/>(已登录)
participant Mini as 小程序<br/>(未登录)
participant Auth as 认证中心
Mini->>Auth: 1. 请求生成二维码
Auth->>Auth: 2. 生成临时码<br/>QRCODE_123
Auth-->>Mini: 3. 返回二维码
Mini->>Mini: 4. 显示二维码
App->>App: 5. 扫描二维码
App->>Auth: 6. 确认授权<br/>(带App的Token)
Auth->>Auth: 7. 验证Token<br/>绑定QRCODE_123
Mini->>Auth: 8. 轮询检查<br/>是否已授权
Auth-->>Mini: 9. 已授权,返回Token
Mini->>Mini: 10. 存本地
Note over Mini: 登录成功!
适合:已经有一个已登录设备的场景。
方案2: 手机号验证码
最简单直接:
@PostMapping("/sms/login")
public LoginResponse smsLogin(@RequestParam String phone,
@RequestParam String code) {
// 1. 验证验证码
String savedCode = redisTemplate.opsForValue().get("sms:" + phone);
if (!code.equals(savedCode)) {
throw new BusinessException("验证码错误");
}
// 2. 查询或创建用户
User user = userService.findOrCreateByPhone(phone);
// 3. 生成Token
String token = jwtService.createToken(user);
return new LoginResponse(token);
}
方案3: 第三方登录(微信/钉钉)
// 微信小程序
wx.login({
success: res => {
const code = res.code
// 发给后端
wx.request({
url: 'https://api.company.com/wechat/login',
data: { code },
success: res => {
wx.setStorageSync('token', res.data.token)
}
})
}
})
// 后端
@PostMapping("/wechat/login")
public LoginResponse wechatLogin(@RequestParam String code) {
// 1. 用code换openid
WechatSession session = wechatService.code2Session(code);
// 2. 查询或创建用户
User user = userService.findOrCreateByOpenId(session.getOpenid());
// 3. 生成Token
String token = jwtService.createToken(user);
return new LoginResponse(token);
}
这个阶段的特点:
- ✅ 移动端不能用传统SSO
- ✅ 需要多种登录方式:扫码/验证码/第三方
- ✅ Token存储方式不同,但验证逻辑相同
抓住本质
前端的职责
无论是Web、小程序还是App,前端只做三件事:
Web端能做的:
- Cookie自动管理
- localStorage存Token
- 跳转实现SSO
移动端做不到的:
- 没有Cookie (做不了传统SSO)
- 手动存Token
- 手动传Token
后端的职责
后端只做一件事:验证"你是谁"
graph TB
A[验证身份] --> B{在哪验证?}
B --> C[单体应用]
B --> D[微服务]
B --> E[多系统]
C --> F[本地验证JWT]
D --> G[网关验证JWT]
E --> H[认证中心<br/>发授权码换Token]
style A fill:#FFD700
style F fill:#98FB98
style G fill:#98FB98
style H fill:#FFB6C1
JWT验证 vs 权限验证:
// JWT验证:验证"你是谁" (本地验证,快)
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
Long userId = claims.get("userId", Long.class);
// 权限验证:验证"你能干什么" (调认证中心,实时)
boolean hasPermission = authClient.checkPermission(userId, "user:delete");
分工明确:
- JWT验证:网关/本地完成
- 权限验证:认证中心统一管理
架构演进全景图
graph TB
subgraph "2020: 单体应用"
S1[前端] --> S1B[Spring Boot + JWT]
end
subgraph "2021: 微服务"
S2[前端] --> S2G[Gateway网关<br/>统一JWT验证]
S2G --> S2S1[用户服务]
S2G --> S2S2[考勤服务]
S2G --> S2S3[审批服务]
end
subgraph "2022: 多系统SSO"
S3OA[OA前端] -.跳转.-> S3Auth[认证中心<br/>OAuth2]
S3CRM[CRM前端] -.跳转.-> S3Auth
S3F[财务前端] -.跳转.-> S3Auth
S3Auth -.授权码.-> S3OA
S3Auth -.授权码.-> S3CRM
S3Auth -.授权码.-> S3F
end
subgraph "2023: 移动端"
S4Mini[小程序] --> S4Auth[认证中心]
S4App[App] --> S4Auth
Note4[扫码/验证码/第三方登录]
end
style S1B fill:#98FB98
style S2G fill:#FFD700
style S3Auth fill:#FFD700
style S4Auth fill:#FFD700
每一步都是解决新问题:
| 年份 | 业务场景 | 技术挑战 | 解决方案 | 核心技术 |
|---|---|---|---|---|
| 2020 | 10人的OA系统 | 基础登录 | Spring Security + JWT | JWT本地验证 |
| 2021 | OA拆成微服务 | 重复验证JWT | Gateway统一鉴权 | Global Filter |
| 2022 | 4个独立系统 | 重复登录 | 单点登录SSO | OAuth2授权码 |
| 2023 | 小程序/App | 没有Cookie | 多种登录方式 | 扫码/验证码/第三方 |
常见误解澄清
误解1: 网关 = SSO
不是!
网关:
- 一个系统内部的微服务
- 统一入口,统一验证
- JWT验证一次,下游不用管
SSO:
- 多个独立系统之间
- 每个系统有自己的前端、后端
- 登录一次,所有系统免登录
误解2: OAuth2 = 微信登录
不是!
OAuth2是协议,有两种用法:
1. 企业内部SSO:
- 认证中心是公司自己搭建
- 用户用工号密码登录
- 多个系统之间免登录
2. 第三方登录:
- 认证中心是微信/GitHub/QQ
- 用户用第三方账号登录
- 拿到第三方用户信息
本质相同,都是OAuth2授权码模式。
误解3: 前端很复杂
不是!
前端永远只做三件事:
- 存Token
- 传Token
- 跳转(没Token或需要SSO)
复杂的逻辑在后端:
- 网关验证
- 认证中心
- 权限管理
安全性与生产级实现说明
重要提示: 本文是概述性文章,为了让读者理解演进逻辑,简化了很多安全细节。
生产环境必须考虑:
-
JWT安全:
- 必须设置过期时间(exp、iat)
- 密钥从配置文件读取,不能硬编码
- 使用双Token机制(Access + Refresh Token)
- 第2篇已详细讲解
-
OAuth2安全:
- 验证client_id和client_secret
- 授权码必须绑定clientId和redirectUri
- 使用PKCE防止授权码拦截
- 使用state参数防CSRF攻击
- 第4篇会完整实现
-
浏览器兼容性:
- Safari/Chrome默认阻止第三方Cookie
- 企业内网部署通常没问题
- 公网部署建议:
- 同域名 (*.company.com)
- 或使用PKCE增强模式
- 第4篇会详细讲解
-
微服务内部调用:
- 服务间调用需要内部Token或服务账号
- 网关只处理前端→后端的请求
- 第3篇会讲服务间鉴权
后续实战篇会讲完整的生产级实现:
- 第3篇: Gateway + JWT验证 + 内部调用
- 第4篇: OAuth2完整安全实现 + PKCE
- 第5篇: 第三方登录对接
这篇先抓住核心思路,建立全局观。
最后说两句
这个虚拟的故事,串联了我过去几年在不同公司遇到的真实场景。
从单体到微服务,从一个系统到多个系统,每次演进都是业务驱动的,不是为了炫技。
不要为了用技术而用技术,先理解为什么需要,再学怎么实现。
如果这篇文章帮你建立了全局观,欢迎关注,后续实战篇会手把手一起实现。
下一篇,我们撸起袖子写代码!