用户刚在 OA 登录完,打开 CRM 却又要重新输密码。小程序一启动就弹出“登录失败”。某天突然发现所有服务都拒绝 Token,原来 JWT 的签名算法改了,全系统都要改一遍……

  这些事,我都经历过。

  不是一次,是三次。

  为了讲清楚这个过程,我虚构了一家公司——“踏浪科技”,它的认证系统,就是基于我亲身经历的痛点,一步步演进而来。

  但如果你想理解:为什么需要网关?为什么不能只靠 JWT 做 SSO?为什么 OAuth2 是必经之路?

  请继续往下看。

第一阶段:单体应用 (2020) #后端 #面试 #架构业务背景

  公司刚成立,开发了第一个内部系统:OA 办公系统。

  功能很简单:

员工考勤打卡请假审批报销流程

  用户量:5个员工

技术架构

  最简单的单体应用:

  HTTPS

  JDBC

  前端 Vue

  后端 Spring Boot

  MySQL

  登录流程:

  用户 前端 后端 数据库 后续请求带 Authorization: Bearer token 输入工号、密码 POST /login 验证密码(BCrypt) 验证通过 生成 JWT Token 返回 {token: "xxx"} 存 localStorage 用户 前端 后端 数据库

  核心代码:

  @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 系统太重了,要拆成微服务,方便各个团队独立开发部署。"

拆分后的架构

  前端 Vue

  问题来了: 前端怎么调用

  用户服务 :8001

  考勤服务 :8002

  审批服务 :8003

  报销服务 :8004

遇到的问题

  问题1: 前端要记4个地址

  // 登录调用户服务axios.post('')// 查考勤调考勤服务 axios.get('')// 提交审批调审批服务 axios.post('')

  前端要配置多个 baseURL,维护成本高。

  问题2: 每个服务都要验证 JWT

  // 用户服务需要验证@Componentpublic class JwtFilter { ... }// 考勤服务也需要验证 @Component public class JwtFilter { ... }// 审批服务还需要验证 @Component public class JwtFilter { ... }

  验证逻辑复制4份,改个算法要改4个地方。

  问题3: CORS 跨域配置要配4次

  每个服务都要配一遍允许的前端域名。

C 引入Spring Cloud Gateway

  解决方案: 加一个网关,统一入口。

  统一调用 :8000

  路由转发

  路由转发

  路由转发

  路由转发

  前端 Vue

  Gateway 网关

  用户服务 :8001

  考勤服务 :8002

  审批服务 :8003

  报销服务 :8004

  网关做的事:

  有效

  无效

  请求到达

  验证 JWT

  提取 userId

  放入 Header: X-User-Id

  路由转发 给下游服务

  返回 401

  核心代码:

  @Componentpublic class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono 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 getMyAttendance(@RequestHeader("X-User-Id") Long userId) { // 不需要验证JWT了,网关已经验证过 // 直接使用userId return attendanceService.getByUserId(userId);}

  前端也简化了:

  // 只需要配置一个baseURLaxios.defaults.baseURL = ''// 所有请求都走网关 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 登录

  Token 存在 oa.company.com 的 localStorage

  用户打开 CRM

  crm.company.com 的 localStorage 是空的!

  问题核心:

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协议。

  财务系统

  CRM 系统

  OA 系统

  认证中心

  没登录跳转

  没登录跳转

  没登录跳转

  微服务

  网关

  前端

  微服务

  网关

  前端

  微服务

  网关

  前端

  OAuth2 Server auth.company.com

  用户数据库

完整流程:第一次登录OA

  用户 OA 前端 认证中心 OA 后端 登录成功! 1. 访问 oa.company.com 2. 检查 localStorage 没有 token 3. 跳转到认证中心 auth.company.com/login redirect=oa.company.com/callback 4. 显示登录页 5. 输入工号、密码 6. 验证通过 创建 Session(userId=123) 设置 Cookie 7. 生成授权码 code=ABC123 8. 跳转 oa.company.com/callbackcode=ABC123 9. 把 code 发给后端 10. 用 code 换 Token (带 client_id、client_secret) 11. 返回 Token=JWT-xxx 12. 返回 Token 给前端 13. 存 localStorage 用户 OA 前端 认证中心 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 +}

  此时浏览器有了认证中心的 Cookie:

  Cookie: JSESSIONID=abc123; domain=auth.company.com; path=/C SSO生效:访问CRM自动登录

  用户 CRM 前端 认证中心 CRM 后端 浏览器自动带上 Cookie: JSESSIONID=abc123 不需要输密码! 自动登录成功! 用户无感知! 1. 访问 crm.company.com 2. 检查 localStorage 没有 token 3. 跳转到认证中心 auth.company.com/oauth/authorize client_id=crm&redirect_uri=crm.company.com/callback 4. 从 Cookie 拿到 Session 5. Session 里有 userId=123 用户已登录! 6. 直接生成授权码 code=XYZ789 7. 立即跳转 crm.company.com/callbackcode=XYZ789 8. 把 code 发给后端 9. 用 code 换 Token 10. 返回 Token=JWT-yyy 11. 返回 Token 给前端 12. 存 localStorage 用户 CRM 前端 认证中心 CRM 后端

  用户体验:

在OA登录时输入密码访问CRM,浏览器地址栏闪了一下,直接进去了全程没有看到登录页只输了一次密码!为什么能自动登录

  核心原理:认证中心的 Session

  有

  没有

  第一次在 OA 登录

  认证中心创建 Session userId=123

  浏览器存 Cookie JSESSIONID=abc123 domain=auth.company.com

  访问 CRM

  CRM 跳转到认证中心

  浏览器自动带 Cookie

  认证中心从 Cookie 拿到 JSESSIONID

  根据 sessionId 找到 Session

  Session 里有 userId

  直接发授权码 不要密码

  显示登录页

  三个关键点:

    认证中心用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: 扫码登录

  手机 App (已登录) 小程序 (未登录) 认证中心 登录成功! 1. 请求生成二维码 2. 生成临时码 QRCODE_123 3. 返回二维码 4. 显示二维码 5. 扫描二维码 6. 确认授权 (带 App 的 Token) 7. 验证 Token 绑定 QRCODE_123 8. 轮询检查 是否已授权 9. 已授权,返回 Token 10. 存本地 手机 App (已登录) 小程序 (未登录) 认证中心

  适合:已经有一个已登录设备的场景。

  方案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: '', 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后端的职责

  后端只做一件事: 验证"你是谁"

  验证身份

  在哪验证

  单体应用

  微服务

  多系统

  本地验证 JWT

  网关验证 JWT

  认证中心 发授权码换 Token

  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验证:网关/本地完成权限验证:认证中心统一管理架构演进全景图

  2023: 移动端

  认证中心

  小程序

  App

  扫码/验证码/第三方登录

  2022: 多系统 SSO

  跳转

  跳转

  跳转

  授权码

  授权码

  授权码

  认证中心 OAuth2

  OA 前端

  CRM 前端

  财务前端

  2021: 微服务

  Gateway 网关 统一 JWT 验证

  前端

  用户服务

  考勤服务

  审批服务

  2020: 单体应用

  Spring Boot + JWT

  前端

  每一步都是解决新问题:

  | 年份 | 业务场景 | 技术挑战 | 解决方案 | 核心技术 | | ---

  | 2020 | 10人的 OA 系统 | 基础登录 | Spring Security +

  | 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篇: 第三方登录对接

  这篇先抓住核心思路,建立全局观。

最后说两句

  这个虚拟的故事,串联了我过去几年在不同公司遇到的真实场景。

  从单体到微服务,从一个系统到多个系统,每次演进都是业务驱动的,不是为了炫技。

  不要为了用技术而用技术,先理解为什么需要,再学怎么实现。

  如果这篇文章帮你建立了全局观,欢迎关注,后续实战篇会手把手一起实现。

  下一篇,我们撸起袖子写代码!