用户刚在 OA 登录完,打开 CRM 却又要重新输密码。
小程序一启动就弹出"登录失败"。
某天突然发现所有服务都拒绝 Token,原来 JWT 的签名算法改了,全系统都要改一遍......
这些事,我都经历过。
不是一次,是三次。
为了讲清楚这个过程,我虚构了一家公司------"踏浪科技",它的认证系统,就是基于我亲身经历的痛点,一步步演进而来。
但如果你想理解:
为什么需要网关?为什么不能只靠 JWT 做 SSO?为什么 OAuth2 是必经之路?
请继续往下看。
第一阶段:单体应用 (2020)
业务背景
公司刚成立,开发了第一个内部系统:OA办公系统。
功能很简单:
- 员工考勤打卡
- 请假审批
- 报销流程
用户量:5个员工
技术架构
最简单的单体应用:
登录流程:
Authorization: Bearer token
核心代码:
java
@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系统太重了,要拆成微服务,方便各个团队独立开发部署。"
拆分后的架构
前端怎么调用?} 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个地址?
javascript
// 登录调用户服务
axios.post('http://localhost:8001/login')
// 查考勤调考勤服务
axios.get('http://localhost:8002/attendance')
// 提交审批调审批服务
axios.post('http://localhost:8003/approval')
前端要配置多个baseURL,维护成本高。
问题2: 每个服务都要验证JWT?
java
// 用户服务需要验证
@Component
public class JwtFilter { ... }
// 考勤服务也需要验证
@Component
public class JwtFilter { ... }
// 审批服务还需要验证
@Component
public class JwtFilter { ... }
验证逻辑复制4份,改个算法要改4个地方。
问题3: CORS跨域配置要配4次?
每个服务都要配一遍允许的前端域名。
引入Spring Cloud Gateway
解决方案: 加一个网关,统一入口。
网关做的事:
X-User-Id] D --> E[路由转发
给下游服务] 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
核心代码:
java
@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; // 优先级最高
}
}
下游服务简化了:
java
// 考勤服务
@GetMapping("/my-attendance")
public List<Attendance> getMyAttendance(@RequestHeader("X-User-Id") Long userId) {
// 不需要验证JWT了,网关已经验证过
// 直接使用userId
return attendanceService.getByUserId(userId);
}
前端也简化了:
javascript
// 只需要配置一个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),用户体验太差了!"
问题分析
为什么不能共享登录状态?
oa.company.com
的localStorage] C[用户打开CRM] --> D[crm.company.com
的localStorage
是空的!] style A fill:#98FB98 style B fill:#FFD700 style C fill:#FFA07A style D fill:#FFA07A
问题核心:
- localStorage不能跨域共享
oa.company.com的数据,crm.company.com访问不到- 浏览器的安全策略,无法绕过
能用Cookie吗?
理论上可以:
ini
如果设置: domain=.company.com
那么 oa.company.com、crm.company.com 都能访问
但这有局限:
- 只能同一个顶级域名
- 如果是
oa.com和crm.net,Cookie完全没用 - 我们收购的ERP系统用的是旧域名
erp-old.net
需要一个通用方案,不管什么域名都能用。
引入认证中心:OAuth2
解决方案: 搭建一个认证中心,用OAuth2协议。
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
没有token OA->>Auth: 3. 跳转到认证中心
auth.company.com/login?
redirect=oa.company.com/callback Auth->>U: 4. 显示登录页 U->>Auth: 5. 输入工号、密码 Auth->>Auth: 6. 验证通过
创建Session(userId=123)
设置Cookie Auth->>Auth: 7. 生成授权码
code=ABC123 Auth->>OA: 8. 跳转 oa.company.com/callback?code=ABC123 OA->>OABE: 9. 把code发给后端 OABE->>Auth: 10. 用code换Token
(带client_id、client_secret) Auth-->>OABE: 11. 返回Token=JWT-xxx OABE-->>OA: 12. 返回Token给前端 OA->>OA: 13. 存localStorage Note over OA: 登录成功!
关键代码:
java
// 认证中心:登录接口
@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:
ini
Cookie: JSESSIONID=abc123; domain=auth.company.com; path=/
SSO生效:访问CRM自动登录
没有token CRM->>Auth: 3. 跳转到认证中心
auth.company.com/oauth/authorize?
client_id=crm&redirect_uri=crm.company.com/callback Note over Auth: 浏览器自动带上
Cookie: JSESSIONID=abc123 Auth->>Auth: 4. 从Cookie拿到Session Auth->>Auth: 5. Session里有userId=123
用户已登录! Note over Auth: 不需要输密码! Auth->>Auth: 6. 直接生成授权码
code=XYZ789 Auth->>CRM: 7. 立即跳转
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: 自动登录成功!
用户无感知!
用户体验:
- 在OA登录时输入密码
- 访问CRM,浏览器地址栏闪了一下,直接进去了
- 全程没有看到登录页
- 只输了一次密码!
为什么能自动登录?
核心原理:认证中心的Session
userId=123] B --> C[浏览器存Cookie
JSESSIONID=abc123
domain=auth.company.com] D[访问CRM] --> E[CRM跳转到认证中心] E --> F[浏览器自动带Cookie] F --> G[认证中心从Cookie
拿到JSESSIONID] G --> H[根据sessionId
找到Session] H --> I[Session里有userId?] I -->|有| J[直接发授权码
不要密码] I -->|没有| K[显示登录页] style B fill:#FFD700 style C fill:#98FB98 style F fill:#87CEEB style J fill:#FFD700
三个关键点:
-
认证中心用Session记住"谁登录过"
javasession.setAttribute("userId", 123); -
浏览器用Cookie访问Session (Cookie是钥匙)
ini请求 auth.company.com 时,浏览器自动带上: Cookie: JSESSIONID=abc123 -
授权码是一次性通行证 (跨域传递)
ini通过URL参数传递: ?code=XYZ789 不依赖Cookie,可以跨任何域名
这个方案的优势:
浏览器兼容性提示: 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: 扫码登录
(已登录) participant Mini as 小程序
(未登录) participant Auth as 认证中心 Mini->>Auth: 1. 请求生成二维码 Auth->>Auth: 2. 生成临时码
QRCODE_123 Auth-->>Mini: 3. 返回二维码 Mini->>Mini: 4. 显示二维码 App->>App: 5. 扫描二维码 App->>Auth: 6. 确认授权
(带App的Token) Auth->>Auth: 7. 验证Token
绑定QRCODE_123 Mini->>Auth: 8. 轮询检查
是否已授权 Auth-->>Mini: 9. 已授权,返回Token Mini->>Mini: 10. 存本地 Note over Mini: 登录成功!
适合:已经有一个已登录设备的场景。
方案2: 手机号验证码
最简单直接:
java
@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: 第三方登录(微信/钉钉)
javascript
// 微信小程序
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)
}
})
}
})
java
// 后端
@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
后端的职责
后端只做一件事:验证"你是谁"
发授权码换Token] style A fill:#FFD700 style F fill:#98FB98 style G fill:#98FB98 style H fill:#FFB6C1
JWT验证 vs 权限验证:
java
// 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验证:网关/本地完成
- 权限验证:认证中心统一管理
架构演进全景图
统一JWT验证] S2G --> S2S1[用户服务] S2G --> S2S2[考勤服务] S2G --> S2S3[审批服务] end subgraph "2022: 多系统SSO" S3OA[OA前端] -.跳转.-> S3Auth[认证中心
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篇: 第三方登录对接
这篇先抓住核心思路,建立全局观。
最后说两句
这个虚拟的故事,串联了我过去几年在不同公司遇到的真实场景。
从单体到微服务,从一个系统到多个系统,每次演进都是业务驱动的,不是为了炫技。
不要为了用技术而用技术,先理解为什么需要,再学怎么实现。
如果这篇文章帮你建立了全局观,欢迎关注,后续实战篇会手把手一起实现。
下一篇,我们撸起袖子写代码!