我们是如何把登录系统从“一行JWT”升级成企业级SSO的?

用户刚在 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: 后续请求带
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系统太重了,要拆成微服务,方便各个团队独立开发部署。"

拆分后的架构

graph TB F[前端 Vue] --> G{问题来了:
前端怎么调用?} 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

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

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:
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个独立的系统:

用户的抱怨

"我每天要登录4次,每个系统都要输一遍密码?"

"用的是同一个账号,为什么不能登录一次就行?"

"这系统是不是有bug?"

老板发话了: "必须搞单点登录(SSO),用户体验太差了!"

问题分析

为什么不能共享登录状态?

graph TB A[用户在OA登录] --> B[Token存在
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.comcrm.net,Cookie完全没用
  • 我们收购的ERP系统用的是旧域名 erp-old.net

需要一个通用方案,不管什么域名都能用。

引入认证中心:OAuth2

解决方案: 搭建一个认证中心,用OAuth2协议。

graph TB subgraph "认证中心" Auth[OAuth2 Server
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
没有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自动登录

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
没有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

graph TB A[第一次在OA登录] --> B[认证中心创建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

三个关键点:

  1. 认证中心用Session记住"谁登录过"

    java 复制代码
    session.setAttribute("userId", 123);
  2. 浏览器用Cookie访问Session (Cookie是钥匙)

    ini 复制代码
    请求 auth.company.com 时,浏览器自动带上:
    Cookie: JSESSIONID=abc123
  3. 授权码是一次性通行证 (跨域传递)

    ini 复制代码
    通过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
(已登录) 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

后端的职责

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

graph TB A[验证身份] --> B{在哪验证?} B --> C[单体应用] B --> D[微服务] B --> E[多系统] C --> F[本地验证JWT] D --> G[网关验证JWT] E --> H[认证中心
发授权码换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验证:网关/本地完成
  • 权限验证:认证中心统一管理

架构演进全景图

graph TB subgraph "2020: 单体应用" S1[前端] --> S1B[Spring Boot + JWT] end subgraph "2021: 微服务" S2[前端] --> S2G[Gateway网关
统一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: 前端很复杂

不是!

前端永远只做三件事:

  1. 存Token
  2. 传Token
  3. 跳转(没Token或需要SSO)

复杂的逻辑在后端:

  • 网关验证
  • 认证中心
  • 权限管理

安全性与生产级实现说明

重要提示: 本文是概述性文章,为了让读者理解演进逻辑,简化了很多安全细节。

生产环境必须考虑:

  1. JWT安全:

    • 必须设置过期时间(exp、iat)
    • 密钥从配置文件读取,不能硬编码
    • 使用双Token机制(Access + Refresh Token)
    • 第2篇已详细讲解
  2. OAuth2安全:

    • 验证client_id和client_secret
    • 授权码必须绑定clientId和redirectUri
    • 使用PKCE防止授权码拦截
    • 使用state参数防CSRF攻击
    • 第4篇会完整实现
  3. 浏览器兼容性:

    • Safari/Chrome默认阻止第三方Cookie
    • 企业内网部署通常没问题
    • 公网部署建议:
      • 同域名 (*.company.com)
      • 或使用PKCE增强模式
    • 第4篇会详细讲解
  4. 微服务内部调用:

    • 服务间调用需要内部Token或服务账号
    • 网关只处理前端→后端的请求
    • 第3篇会讲服务间鉴权

后续实战篇会讲完整的生产级实现:

  • 第3篇: Gateway + JWT验证 + 内部调用
  • 第4篇: OAuth2完整安全实现 + PKCE
  • 第5篇: 第三方登录对接

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


最后说两句

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

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

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

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

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

相关推荐
上进小菜猪21 小时前
从人工目检到 AI 质检-YOLOv8 驱动的 PCB 缺陷检测系统【完整源码】
后端
南山安1 天前
JavaScript 函数柯里化:从入门到实战,一文搞定(面试可用)
javascript·面试·函数式编程
谢尔登1 天前
Monorepo 架构
前端·arcgis·架构
阿狸远翔1 天前
Protobuf 和 protoc-gen-go 详解
开发语言·后端·golang
间彧1 天前
Vert.x与Spring框架:开发效率与团队学习成本深度对比
后端
间彧1 天前
Vert.x与传统Spring框架在性能、并发处理方面有哪些差异
后端
间彧1 天前
Vert.x框架详解与项目实战:构建高性能异步应用
后端
间彧1 天前
Spring Boot 与 Disruptor 高性能并发实战
后端
想用offer打牌1 天前
如何开启第一次开源贡献之路?
java·后端·面试·开源·github