业务背景
大家好 我是卡卡,在我之前公司的时候,很多业务都需要做私有化部署,每个业务基本上都会有自己的后台,而且还要区分测试后台、正式后台、日志监控、运维工具、发布系统之类的。时间长了,各种子系统越建越多,部署的时候也全是靠我们自己手动搞,所以后期基本就是一堆系统散落在不同服务器上。
特别是我负责项目多的时候,手上要维护的后台实在太多了。用的时候只能把常用的先固定在浏览器标签栏里,但只要换个设备、重装个浏览器,或者清理一下缓存,这些标签全没了,结果又得去聊天记录、文档里一条条翻,才能把后台入口找回来。
更烦的是老板。这个大忙人明明后台地址他自己可以收藏一下,但从来不存,每次要看点数据就来问我要后台地址和账号密码。问得多了我自己都烦了。
所以我后来抽空把这些零散的后台统一起来,做了一个"统一门户管理中心"。所有子系统的入口都放在一个页面里,登录一次之后,用户就能进入自己有权限的所有后台,不用再满世界找链接,也不用每个系统都重新登录。对老板、对运营、对我们开发来说,都省事很多,对后期扩系统的管理也轻松不少。
设计思路
在开始设计这套东西之前,我首先想到的是用单点登录(SSO)机制。传统 SSO 的流程是这样的:用户访问 A 系统,发现没登录,被重定向到认证中心;登录成功后再带着 ticket/code 回来。之后用户访问 B 系统时,因为已经登录过认证中心,就不需要再登录一遍。
这个机制本身没问题,但和我想做的东西不完全一样。传统 SSO 是"先访问系统,再登录"。而我们的内部后台更多是"先登录门户,再从门户进去"。用户不会一上来就直接访问某个子系统,而是先打开统一入口,然后从入口进入对应的后台。
所以我就在想,既然我们的使用场景不是"从子系统开始",那完全照搬传统 SSO 的那套流程,其实就有点不对路。传统 SSO 更像是面向对外的业务系统,比如用户是直接访问 A、B、C 这些系统的,需要 SSO 去帮他们统一一次登录。但我们这种内部后台恰好相反------大家习惯是先打开一个入口页面,然后从这个入口去点各个后台,这一点和传统 SSO 的默认前提完全不一样。
同时我们公司里面的后台系统挺多,而且技术栈也不统一。有 Java 的、有 PHP 的、有 Go 的,还有一些是第三方的自带后台。要让这些系统全部按照 SSO 的协议来改造一遍,不但成本高,而且每个系统都要对接 SSO 的重定向、ticket 验证、登录态同步,工作量特别大,也容易出问题。
所以我最后的思路是:不用大厂那套很重的 SSO,也不用让所有子系统都去支持单点登录,而是做一个轻量一点、贴合我们内部使用习惯的方式。既然大家是从门户进的,那就干脆以门户为中心,把登录这件事情都集中放在门户完成;进入子系统的时候再给子系统一段临时授权码,用这段授权码去认证中心换用户信息,然后子系统再生成自己的 token 就好了。
这样设计我觉得有这样几个好处:
- 对用户来说还是单点登录体验,登录一次就够了
- 子系统不用改造太多,不需要支持完整的 SSO 协议
- 门户只负责入口,认证中心只负责验证,两者职责划分很清楚
- 不同技术栈的系统都能接入,成本比传统 SSO 低很多
说白了,就是把"统一入口 + 简化授权"的方式结合起来,比传统 SSO 更轻,更适合我们这种内部私有化部署、多后台的场景。
方案选择
在确定整体方向之前,我参考过好几种常见的授权模式。毕竟我们内部的后台系统数量多、技术栈也不统一,Java、PHP、Go 都有,而且部署环境分散,想做统一登录的话,前期选哪种方案基本上决定了后面接入会不会痛苦。
最开始想的是"要不要直接统一 token?"。也就是说,门户登录之后生成一个 JWT,然后所有子系统都用同一个 token 验证。这个做法看起来简单粗暴,但问题也很明显:所有子系统必须共享同一套密钥,一旦某个子系统泄露密钥,所有后台的登录安全就全完了。而且不同系统可能对 token 的字段、时效、校验逻辑都有自己的需求,统一 token 反而会增加耦合。
后来也想过"要不要走 OAuth2 授权码模式?"。这种是大厂常用的做法,流程很标准,但也太重了。要让每个子系统都遵循 OAuth2 的协议、处理 redirect、处理 state、处理 token 交换等等,对我们这种内部后台来说有点过度设计了,而且成本高、实现复杂,不太现实。
再往后就是传统的单点登录(SSO 或 CAS)那类方案。但前面也说了,那一套是"从子系统出发"的:系统 A → 认证中心 → 回 A,再访问 B 又是另一套流程。我们的使用路径是从门户进入,而不是从某个子系统开始,这种场景下传统的 SSO 流程套上去并不贴合,还会让子系统改造量特别大。
综合对比下来,最终我选择了"统一门户 + 一次性 code"的组合方式:
门户负责展示所有系统入口,用户先在门户登录;进入子系统时由门户生成一个一次性 code,子系统拿 code 去认证中心换用户信息,然后再在本地生成自己的 token。
这个模式兼顾了安全性、扩展性和接入成本,也不依赖某种技术栈,所有语言的后台都能很轻松接入,非常适合我们这种内部私有化、多后台的环境。
整个流程大概是这样的:

用户不会直接进入某个子系统,而是先打开统一门户并在门户登录。登录成功后,门户会把用户有权限的后台系统都展示出来。用户点击某个系统入口时,门户会向认证中心申请一个一次性 code,然后带着这个 code 把用户重定向到对应的子系统。
子系统拿到 code 之后,会再去找认证中心验证这个 code 并换取用户信息。拿到 userInfo 后,子系统自己在本地生成一个 token,后面用户在这个子系统里所有的请求,都会通过这个本地 token 来做校验,而不再依赖门户。
这种模式的好处是比较明显的:用户体验上等同于单点登录,登录一次即可进入多个后台;实现上又比传统 SSO 要轻得多,不需要每个子系统都对接完整的重定向流程,也不用统一 token 格式,更适合内部系统多、语言杂、环境分散的情况。
后台设计思路
在搭这个统一门户之前,我先想清楚一个问题:既然它要承载公司所有内部后台,那它本身必须也得是一个标准的后台系统,有自己的模块、有自己的管理逻辑,不能只是把链接拼一拼就完了。
所以我给它拆了几个核心板块,每个板块都是围绕我们内部真实的使用场景来的。
1. 主页(系统入口总览)
这个门户的核心就是"把所有内部系统展示出来"。同事登录之后,首页看到的就是自己有权限访问的所有子系统,按业务分类排好。想进哪个后台,点一下就能跳过去,不用再到处找链接、记书签。
2. 用户管理
所有公司的员工账号都在这里统一管理,不再由各个系统自己造一套。新同事入职的时候,从这个门户注册或录入一次账户,后面所有子系统都能通过它来识别用户,不用每个后台再各自维护一份账号体系。
3. 系统管理(子系统注册)
公司内部每多一个新的私有化服务,比如日志后台、监控平台、部署系统,都要在这里注册一遍。注册之后门户才能展示入口,才能给用户授权,也方便我们后面做跳转、做健康检查。
4. 系统健康检查
既然门户把所有子系统都集中展示了,那系统是否在线、地址是否失效、是否宕机,这些都得有地方看。所以我加了健康检查模块,定时去子系统的 /health 或指定心跳接口探活,一旦某个系统挂了可以第一时间看到,避免同事点进去才发现打不开。
5. 用户授权(哪个员工能进哪些后台)
每个同事不可能看到全部系统,有的人负责数据,有的人负责审核,有的人负责活动。所以这里需要一个授权模块,根据员工的角色或岗位,把哪些子系统能用、哪些不能用,一次性配置好。不用每个系统内部都再做一套权限判断。
6. IP 白名单控制
因为这个后台等于是公司所有内部系统的"总入口",安全性必须要做。除了账号密码之外,我还加了 IP 白名单,限制只有公司网络或特定来源的机器才能访问,避免被外部恶意撞库。
7. 操作日志
作为一个完整的后台,操作审计必须有。谁登录了、谁改了子系统配置、给谁授权了什么权限,这些都得记录下来,出了问题也能快速追踪。
整体拆下来,这几个模块基本能覆盖企业里一个统一门户该做的事:集中入口、统一账户、系统注册、权限分发、安全防护和审计。后面再结合单点登录的流程,就能构成一套完整的后台管理中心。
后台设计解读
前面我主要讲的是整体的架构和思路,这里我们就结合后台页面,一块一块讲我这个门户到底是怎么设计的。
1. 主页(系统入口总览)
我们登录进来之后看到的就是首页,也是除了管理员之外的员工主要使用的页面。基本功能就是把对应员工有权限的所有子系统按业务分类展示出来。我们公司内部子系统比较多,有研发相关的、有审核相关的、有监控相关的,我就按业务模块分了栏目,这样不至于全部堆一起太乱。如下图所示:

每个系统都是一张独立的卡片,卡片上会展示系统名称和一句简介,让同事一眼能看出是谁的后台、干什么的。如果同事要进入某个后台,直接点这张卡片就行了。
点击后,门户会去帮他生成一个 code,再带着 code 跳转到对应子系统,子系统再用这个 code 完成登录。
对用户来说根本不需要关心这些,体验就是:点一下就能进入后台,不需要再登录第二次。
这个设计能让常用后台的人非常省心,也能避免"换设备了所有后台入口都找不到"的情况。
2. 用户管理(内部员工统一账号体系)
公司内部的后台系统多了之后,最容易乱的就是账号体系:
每个系统自己做一套账号密码,权限也各管各的,部门越多越难维护。
所以在门户里,我专门做了一个统一的用户管理模块,把所有员工的账号全部集中到一处管理。
如下图所示,用户列表上能看到每个人的基础信息(用户名、邮箱)、角色、状态、最近登录时间、是否在线等等。新同事入职,只需要在这里建一个账号,他就能直接进入自己有权限的所有后台,不用再到每个系统里重复创建。

另外,因为这个门户本质上是所有内部后台的"总入口",安全性要求比普通业务系统高得多,所以我在用户体系里加了两个非常关键的功能:
① 双因子验证(2FA)
后台入口一旦泄露,相当于所有子系统的门都被打开了。
内部环境里又经常出现密码被共享、弱密码、密码泄露没人知道这些情况,所以给账号加一道 2FA 可以大幅提高安全性。
只要绑定了 2FA,就算密码被别人拿到了,也进不来。
② 踢下线(强制下线)
这个在内部环境特别重要,比如:
- 发现账号异常登录
- 某个用户正在操作不该操作的东西
- 临时需要马上收回某人的权限
- 用户离职但还保持登录状态
管理员在后台点一下"踢下线",当前用户所有 token 会立刻失效,需要重新登录才能继续。
对于内部后台来说,这功能简直是救命的。
总体来说,用户管理模块就是两个字:统一 。
把账号、权限、安全都统一起来管理,后面所有子系统都能直接复用这一套体系,再也不会出现"密码在哪""这个系统是谁管理的"这种混乱情况。
3. 系统管理(子系统注册)
这个模块其实就是整个平台的"系统字典"。
我们公司的后台系统本来就多,而且大部分都是私有化部署,像测试后台、正式后台、日志平台、监控平台、构建发布平台......越做越多,不统一管理的话,谁都记不住哪里还有个后台。
所以我在门户里做了一个非常关键的功能:系统注册 。
作用就是告诉门户------"我们公司目前到底有哪些后台系统,它们长什么样、入口在哪、怎么访问"。
如下图所示
每一个后台系统在这里都可以独立配置:
- 系统名称 ------ 展示在首页卡片上的名字
- 模块 ID ------ 后续子系统接入时会用到
- 系统分类 ------ 比如后台系统、监控系统、日志系统、构建发布等,首页会按分类分组
- 入口地址 ------ 点击卡片跳转过去的 URL
- 状态 ------ 是否启用
- 创建/更新时间 ------ 方便运维排查
- 操作按钮(编辑 / 禁用 / 删除)
至于我们为什么要做成一个独立的系统管理模块?
因为内部后台数量一旦多起来,我们不可能靠写死配置或靠脑子记住。
所以有了这个列表,任何系统变更都可以统一管理,比如:
- 某个后台换域名了 → 在这里改一次,全公司生效
- 新做了一个子系统 → 在这里点"新增系统",首页就会自动展示
- 某个测试环境要临时下线 → 在这里点"禁用"
门户首页上的所有"卡片",都是直接从这个列表动态渲染出来的,
而不是写死在代码里。这样后期系统再多,也不会乱。
总的来说,"系统管理"就是整个平台的"系统资产中心"。
后台多也不怕,只要这里登记清楚,整个门户就能随时扩展、随时更新。
4. 系统健康检查(探活监控)
既然门户已经把所有子系统都统一展示出来了,那每个系统现在是不是"在线",我们肯定也要能看到。
不然同事一点击某个后台,结果发现打不开,还以为是自己网络问题,其实系统早就挂了。
所以我在门户里做了一个 健康检查模块(如下图所示):

- 每个子系统都会配置自己的健康检查地址(一般就是
/health或/actuator/health) - 门户会定时去请求这些地址
- 根据响应情况来判断系统是否正常
- 还会记录响应时间、最后检测时间,方便排查
比如图里这样:哪个系统在线、延迟多少、哪个访问失败,一眼就能看出来。
这个模块对多后台、多环境特别有用。因为很多时候某些后台并不是我们负责的,有了这个页面之后,就能快速判断问题到底是在入口、在系统本身、还是在网络。
健康检查探活接口大概是怎么做的?
做法其实很简单:
-
系统管理里登记健康检查地址 (比如
https://xxx.com/health) -
后台用定时任务(Scheduled)每隔 N 秒发一次 GET 请求
-
根据返回结果更新数据库状态
- 200 → 正常
- 超时 / 异常 → 异常
-
前端展示状态、响应时间、最后检测时间
-
用户可以点击"刷新"按钮立刻重新探活一次
整个逻辑不复杂,但实际效果非常有用。
尤其是在多后台、多环境的场景下,像系统偶发超时、某个环境挂掉、或者某个服务正在发布,都能在这里第一时间看出来,省得点进去才发现打不开。
如果想做得更完善一点,其实还可以加上系统通知 能力,比如某个后台连续探活失败就自动发企业微信/邮件提醒。
这次的 Demo 我暂时没写这一块,但如果作为正式内部平台,这个功能非常建议做上,会更接近大厂的运维体系。
5. 用户授权(控制员工能进入哪些后台)
有了统一的用户体系之后,下一步就是做权限分配,也就是控制"谁能访问哪些后台系统"。
这个模块是整个门户的核心之一,基本所有权限相关的逻辑都从这里开始。
界面大概是下面这样(如下图所示):
左侧是所有员工列表,点选某个员工后,右侧会展示他当前已经拥有的后台系统权限,同时也可以继续新增、取消某些系统的访问权限。

整个授权过程非常直观:
- 选中左侧的员工
- 右侧自动列出所有可授权的系统
- 已授权的会高亮显示
- 想给谁开权限、关权限,直接点一下即可
- 最后点击"保存授权"
这种设计对我们的内部场景非常适合,因为每个人的职责差异都很大,比如:
- 新来的运营可能只需要数据后台,不需要 Prometheus 或 GitLab
- 技术团队需要多个系统权限,比如构建平台、监控中心、日志分析
- 财务、审核、发布等部门各自有完全不一样的后台入口
- 一些敏感系统(例如运维系统、生产管理后台)只能开放给特定人员
所有这些都可以在这个页面里统一管理,而不需要让每个子系统再做一套自己的权限体系。
这一点非常关键:子系统不需要再维护自己的用户表、角色表、权限关系表等复杂逻辑,所有权限都由门户来管理。
子系统只需要做一件事:收到 code → 向认证中心换用户信息 → 判断是否允许进入即可。
授权逻辑简单、统一、可控,也非常符合公司内部多后台场景的需求。
6. IP 白名单(安全控制)
因为这个门户等于所有内部系统的总入口,所以安全必须加强。我做了一个 IP 白名单模块,类似公司的访问控制:
- 只有公司网络、内网服务器、特定地址才能访问
- 不在白名单的直接拦截
这样可以避免外部恶意访问,比如撞库攻击、接口扫爆、弱密码尝试等等。
7. 操作日志(审计记录)
后台系统必须有操作日志。
不管是修改了权限、添加了用户、更新了子系统配置,还是登录、退出,都要记录下来:
- 谁操作的
- 操作内容是什么
- 操作时间
- 操作来源 IP
一旦出现问题,可以快速定位到具体的人和操作。
数据表设计
说完后台的几个管理模块之后,接下来就要看它们底层的数据结构了。
其实整个统一门户的平台数据结构并不复杂,真正的核心也就四张表,分别负责:用户、系统分类、系统信息、用户授权。
我这里把关键表结构都贴出来,并结合业务说明一下设计思路。
1. 用户信息表:sys_user
这是整个门户的账号根表,所有员工的登录信息都在这里统一管理。
为什么要这样设计呢?
因为门户是所有后台的入口,一个员工有且只应该在门户这里维护一次账号,不再让每个子系统重复建表、重复维护账号。
这里会存储:
- 用户名(唯一)
- 加密后的密码
- 邮箱、手机号
- 2FA 密钥(google_secret)
- 状态(启用/禁用)
- 最近登录时间
- 创建时间、更新时间
sql
CREATE TABLE `sys_user` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(64) NOT NULL COMMENT '用户名(唯一)',
`password` varchar(255) NOT NULL COMMENT '加密后的密码',
`email` varchar(128) DEFAULT NULL COMMENT '邮箱',
`phone` varchar(32) DEFAULT NULL COMMENT '手机号',
`google_secret` varchar(64) DEFAULT NULL COMMENT '2FA 密钥',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像地址',
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1启用,0禁用',
`last_login_at` datetime DEFAULT NULL COMMENT '最近登录时间',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';
2. 系统分类表:sys_category
首页展示需要分组,否则几百个系统放一起会乱成一锅粥。
所以我们用分类表用来定义系统的业务分组,比如:
- 后台系统
- 构建发布
- 数据分析
- 日志系统
- 监控系统
主要用于显示,不涉及权限。
sql
CREATE TABLE `sys_category` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`category_code` varchar(64) NOT NULL COMMENT '分类标识(英文缩写,如 backend、deploy)',
`category_name` varchar(128) NOT NULL COMMENT '分类名称(中文,如 后台系统)',
`sort_no` int(11) DEFAULT '0' COMMENT '排序号(越大越靠前)',
`remark` varchar(255) DEFAULT NULL COMMENT '备注说明',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `category_code` (`category_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统分类表';
3. 接入系统信息表:sys_application
这张表记录 公司所有的后台系统,是统一门户最关键的一张。
每个子系统都会在这里登记:
- 系统 ID(英文)
- 系统名称(中文)
- 系统介绍
- 入口地址(点击卡片跳过去用)
- 图标地址
- 状态(启用/禁用)
- 分类 ID(关联
sys_category)
门户首页那些卡片、图标、分组展示,全部来源于这张表。
sql
CREATE TABLE `sys_application` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`category_id` bigint(20) unsigned DEFAULT NULL COMMENT '分类ID(关联sys_category)',
`app_id` varchar(64) NOT NULL COMMENT '系统唯一标识(英文缩写)',
`app_name` varchar(128) NOT NULL COMMENT '系统名称(中文名)',
`app_desc` varchar(255) DEFAULT NULL COMMENT '系统副标题/描述',
`entry_url` varchar(255) NOT NULL COMMENT '系统入口地址',
`icon` varchar(255) DEFAULT NULL COMMENT '系统图标地址',
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1启用,0禁用',
`sort_no` int(11) DEFAULT '0' COMMENT '分类内排序号',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `app_id` (`app_id`),
KEY `fk_app_category` (`category_id`),
CONSTRAINT `fk_app_category` FOREIGN KEY (`category_id`) REFERENCES `sys_category` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接入系统信息表';
4. 用户授权关系表:sys_user_application
所有权限控制都依赖这张表。
这张表的作用是:记录某个员工可以访问哪些后台系统。
关系是多对多:
- 一个员工可以有多个系统权限
- 一个系统也可以授权给多个员工
所以用了 (user_id, app_id) 唯一索引来避免重复授权。
子系统登录时,通过门户验证 code,就能知道该用户是否有权限进入。
sql
CREATE TABLE `sys_user_application` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`app_id` bigint(20) unsigned NOT NULL COMMENT '系统ID',
`granted_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间',
`granted_by` varchar(64) DEFAULT NULL COMMENT '授权人',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_app` (`user_id`,`app_id`),
KEY `fk_app` (`app_id`),
CONSTRAINT `fk_app` FOREIGN KEY (`app_id`) REFERENCES `sys_application` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户与系统授权关系表';
后端接口设计
在实现门户免登子系统之前,我们后端接口设计其实只需要准备两个核心接口:
- 创建授权码接口 (认证中心 → 子系统)
用来生成一次性 code,并发给子系统。 - 校验授权码接口 (子系统 → 认证中心)
子系统拿 code 来换取用户信息,完成免登录。
整个流程就是围绕 "发 code → 用 code 换用户信息" 两件事来展开的,这也是我们这套轻量化 SSO 的核心机制。
只要把这两个接口打通,所有语言的子系统(Java / Go / PHP / Python)都能用统一方式接入,换句话说,我们这套机制并不是把所有子系统都彻底绑死在认证中心上,也不要求它们完全放弃自己的登录体系。每个子系统依然可以保留各自原有的账号密码登录逻辑,只是额外多封装了一步"通过门户授权码免登"的逻辑。
这样做有两个好处:
- 门户可以实现统一入口、一键免登
- 子系统也能独立运行、独立登录,不依赖门户也能使用
我们最终要的不是把所有系统的登录逻辑全部迁到认证中心,而是实现一种 松耦合的 SSO :
------能统一就统一,不能统一也不影响各自运行。
1. 创建授权码接口(/sso/code/create)
要实现门户免登子系统,核心就是生成一个一次性的授权码(code),然后子系统再拿着这个 code 去认证中心换取用户信息。这个接口就是整个 SSO 流程的第一步。
接口地址:
json
POST /sso/code/create
流程简单说就是:
用户点击门户某个系统卡片 → 门户向认证中心要一个 code → 前端把 code 拼到系统 URL → 子系统拿 code 来换用户信息 → 完成免登。
前端调用逻辑
当用户在门户首页点击某个系统卡片时,会触发前端封装的 goSystem 方法:
javascript
/** 点击进入系统 */
const goSystem = async (item) => {
if (!item.link) {
ElMessage.warning("该系统暂未配置访问地址");
return;
}
if (!user.id) {
ElMessage.error("用户未登录或信息缺失");
return;
}
try {
const res = await createSSOCode(user.id, item.code); // 调用 /sso/code/create
if (res.success && res.data?.code) {
const url = `${item.link}?code=${res.data.code}`;
window.open(url, "_blank"); // 带 code 跳转到子系统
} else {
ElMessage.error(res.message || "生成登录 code 失败");
}
} catch (err) {
ElMessage.error("认证中心连接失败");
}
};
我们可以看到,前端其实就做了三件事情:
- 验证用户是否已登录门户
- 调用
/sso/code/create生成一个短期有效的授权码 - 把 code 拼接到子系统 URL,例如:
ini
https://admin.xxx.com?code=asf8sdf87dsf0sd8f0
子系统收到这个 code 后,就可以开始免登录流程。
后端调用逻辑
后端的 createCode 方法主要分为 5 步:
java
@Override
public Map<String, Object> createCode(HttpServletRequest request, Map<String, Object> body) {
// 1. 验证 Token 登录态
Long userId = (Long) request.getAttribute("userId");
if (userId == null) {
throw new RuntimeException("未登录或 Token 无效");
}
String redisKey = SsoRedisKeys.userLogin(userId);
User cachedUser = redisUtils.get(redisKey, User.class);
if (cachedUser == null) {
throw new RuntimeException("登录状态已过期,请重新登录");
}
// 2. 校验系统信息
String appId = body.get("appId").toString();
SysApplication app = applicationMapper.selectByAppId(appId);
if (app == null) throw new RuntimeException("应用不存在:" + appId);
if (app.getStatus() == 0) throw new RuntimeException("应用已禁用:" + app.getAppName());
// 3. 校验用户权限
if (!userHasPermission(userId, appId)) {
throw new RuntimeException("用户无权访问系统:" + appId);
}
// 4. 生成一次性 code
String code = UUID.randomUUID().toString().replace("-", "");
String authRedisKey = SsoRedisKeys.authCode(code);
Map<String, Object> authInfo = new HashMap<>();
authInfo.put("userId", userId);
authInfo.put("appId", appId);
// 将 code → 用户信息 写入 Redis,1 分钟自动失效
redisUtils.set(authRedisKey, authInfo, 60);
// 5. 返回给前端
Map<String, Object> result = new HashMap<>();
result.put("code", code);
result.put("expire", 60);
result.put("appId", appId);
return result;
}
其中这个 code 的作用其实很单纯,就是给门户和子系统之间当一个临时的跳转凭证,所以一定要做到短效、一次性、用完即删。它不应该长时间存在,更不应该落库,否则就失去了临时授权的意义。通常用户点击卡片后几百毫秒就能完成跳转,60 秒的有效期已经足够,同时还能避免被拦截后延迟使用、被重放攻击或者被人恶意构造 URL 利用。越短的有效期,就越能减少被利用的风险,所以 60 秒其实是一个安全性和可用性都比较平衡的选择。
最后当认证中心生成了 code 之后,其实并不是简单地返回一个字符串,而是同时在 Redis 里保存了一条和这个 code 绑定的临时授权信息。像图里这样:

Redis 里存的是 appId 和 userId,也就是"哪个用户要进入哪个系统"。这样做的原因很简单:后续子系统带着 code 来换用户信息时,认证中心只需要通过 code 找到这一条 Redis 记录,就能知道这次授权对应的用户是谁、目标系统是哪一个。整个过程不依赖数据库,也不需要在 URL 中暴露敏感信息,而是完全走 Redis 的短期缓存,既轻量又安全。换句话说,code 只是一个随机字符串,本身没有任何意义,真实的授权数据全部在 Redis 里,一旦子系统来验证,认证中心就能立即把 userId 拿出来做下一步处理。
2. 回调验证接口(/sso/code/verify)
当用户从门户跳到子系统时,URL 里会带一个 code,子系统拿到这个 code 后需要到认证中心来"换用户信息",这个过程就是通过 /sso/code/verify 完成的。
这个接口的目的很简单:
确认这个 code 是否真实、有效、没过期、没被用过,并最终告诉子系统:当前用户是谁。
接口地址:
bash
POST /sso/code/verify
伪代码如下:
java
public Map<String, Object> verifyCode(HttpServletRequest request, Map<String, Object> body) {
// 解析参数
String code = String.valueOf(body.get("code"));
String appId = String.valueOf(body.get("appId"));
if (code == null || code.isBlank() || appId == null || appId.isBlank()) {
throw new RuntimeException("参数缺失:code 或 appId 不能为空");
}
// 校验 Redis 是否存在授权码
String redisKey = SsoRedisKeys.authCode(code);
Map<String, Object> authInfo = redisUtils.getObject(redisKey, Map.class);
if (authInfo == null) {
throw new RuntimeException("授权码无效或已过期");
}
// 校验 appId 一致性
String storedAppId = (String) authInfo.get("appId");
if (!appId.equals(storedAppId)) {
throw new RuntimeException("授权码与应用不匹配");
}
// 查询用户信息
Long userId = Long.parseLong(authInfo.get("userId").toString());
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 校验登录态是否有效
String loginKey = SsoRedisKeys.userLogin(userId);
User cachedUser = redisUtils.get(loginKey, User.class);
if (cachedUser == null) {
throw new RuntimeException("登录状态已过期,请重新登录");
}
// 一次性使用后删除
redisUtils.delete(redisKey);
// 返回身份信息
Map<String, Object> result = new HashMap<>();
result.put("userId", user.getId());
result.put("username", user.getUsername());
result.put("appId", appId);
return result;
}
当子系统把 code 发过来后,认证中心会先检查这个 code 是否存在。因为 code 是一次性的、短期有效的,所以就在 Redis 里搜索是否有对应的授权记录。如果查不到,说明 code 要么过期了,要么已经被用过了,这种情况就直接拒绝登录。
如果 Redis 里确实有值,里面包含 userId 和 appId 两项关键信息。这时认证中心会再校验一下传过来的 appId 是否和 code 所属的 appId 一致,防止有人把 code 拿去访问别的系统。
接下来就是根据 userId 查用户信息,并且再校验一次用户的登录态是否有效,避免用户已经在门户退出,却还能继续使用旧的 code。
所有校验都通过后,这个 code 就算使用完了,会立即从 Redis 里删除,确保只能用一次。最后返回最小必要的身份信息,包括 userId、用户名和 appId。子系统拿到这些信息后,就可以根据自身的逻辑生成本地 Token,完成免登流程。
子系统如何接入门户免登
前面两个接口把门户和认证中心之间的流程已经串起来了,接下来我们就要简单讲下子系统这边怎么接入。其实子系统的逻辑非常简单,就是判断 URL 上有没有 code,如果有,就把这个 code 拿到认证中心换成用户信息,然后由子系统自己生成一个本地 token。这样既能支持从门户免登,也不会影响子系统保持自己的独立登录体系,两种方式互不冲突。这个设计的好处是,子系统不用共享 Session、不需要共享 JWT Secret,也不依赖门户,各自维护好自己的登录体系就行了。
1)前端处理逻辑:在路由守卫里自动检测 URL 中的 code
子系统前端只需要在路由守卫里加一个简单的判断:如果当前没有 token,但 URL 上带着 code=xxx,那就说明用户是从门户点进来的,这时候就自动走 SSO 登录,把 code 发给子系统后端验证即可。
javascript
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token')
const code = getQueryParam('code')
// 自动 SSO 登录
if (!token && code) {
try {
const res = await verifyCode(code)
if (res.success && res.data) {
localStorage.setItem('token', res.data.token)
localStorage.setItem('user', JSON.stringify(res.data.user))
ElMessage.success('SSO 登录成功')
// 清除 URL 中的 ?code=xx 避免重复触发
const cleanUrl = window.location.origin + to.path
window.history.replaceState({}, '', cleanUrl)
window.location.reload()
next('/gray/config')
return
} else {
ElMessage.error(res.message || 'SSO 登录失败')
}
} catch (err) {
ElMessage.error('认证中心连接失败')
}
}
// 已登录再访问登录页 → 直接跳首页
if (to.path === '/login' && token) {
next('/gray/config')
return
}
// 未登录访问需要权限的页面 → 跳转登录页
if (!to.meta.public && !token) {
next('/login')
return
}
next()
})
这段逻辑很直观:门户跳进来自动登录,不影响我们系统原有的用户名密码登录。
2)后端处理逻辑:收到 code → 去认证中心验证 → 生成本地 token
子系统后端也不复杂,收到前端传来的 code 之后,带着自己的 appId 去认证中心验证,认证中心确认 code 正常后,会返回用户最基本的身份信息,比如 userId、username。我们再根据这些信息生成自己的 JWT token,前端拿到 token 之后就算登录成功了。
伪代码整理如下:
java
public Result<?> verifyCode(@RequestBody Map<String, Object> body) {
String code = (String) body.get("code");
// 当前子系统的 appId
String appId = grayAppProperties.getAppId();
// 认证中心地址
String ssoUrl = "http://localhost:8085/sso/code/verify";
// 构建请求参数
Map<String, Object> payload = new HashMap<>();
payload.put("code", code);
payload.put("appId", appId);
// 调用认证中心接口
Map<String, Object> ssoResp = RestClient.postJson(ssoUrl, payload);
if (ssoResp == null || !Boolean.TRUE.equals(ssoResp.get("success"))) {
return Result.fail("认证中心验证失败");
}
// 认证中心返回的用户信息
Map<String, Object> data = (Map<String, Object>) ssoResp.get("data");
Long userId = ((Number) data.get("userId")).longValue();
String username = (String) data.get("username");
// 组合 JWT claims
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("fromSSO", true);
// 子系统本地生成 token
String token = jwtUtil.generateToken(claims);
// 返回给前端
Map<String, Object> resultData = new HashMap<>();
resultData.put("token", token);
resultData.put("user", data);
return Result.ok(resultData);
}
整个过程就是标准的 "code 换 token" 流程,不过我们并没有把 token 放到认证中心里,而是由各个子系统自己生成自己的 token,这样每个系统都保持独立、互不影响。前端一存,就算从门户免登成功了。
3)为什么这样设计?
我们在设计这部分的时候,核心点就是让每个子系统保持自己的独立性。我们只用 code 做一次性的身份凭证,然后各个子系统还是走自己的登录体系、自己的 token 校验,不需要共享密钥,也不需要耦合认证中心的结构。子系统既能支持门户单点进入,也能支持本地登录退出,而且逻辑非常清晰简单,任何语言都能接入,属于接入成本最低的一种模式。
单点退出(Logout)设计与实现
统一门户既然承担了统一登录的职责,那退出逻辑也必须做到统一。如果用户从门户点了退出,我们不希望出现一种情况:门户退出了,但他之前用 SSO 打开的子系统仍然保持登录状态,这样既不安全,也会造成体验不一致。所以我们需要补齐"单点退出"的能力,让用户在认证中心退出后,所有通过 SSO 进入的系统都自动失效。
单点退出的核心目标只有两个:一是让认证中心退出后能通知所有子系统;二是让子系统能够识别来自 SSO 的登录与本地登录是不同的,以免影响子系统的独立登录场景。我们采用的方案比较简单,不需要做传统 CAS 那样的"登出广播",而是直接利用 Redis 作一次性标记。认证中心退出时写一条 logout 标记,子系统在自己的 JWT 拦截器里检查这个标记,如果标记存在,就说明门户已经退出了,这时子系统应该让用户回到登录页。
1)认证中心退出:写入退出标记
认证中心退出逻辑很简单:删除登录状态,同时写一条带有 TTL 的 logout 记录,子系统看到这条记录就会认为用户已在门户退出。
伪代码如下:
java
@Override
public Map<String, Object> logout(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
if (userId == null) {
throw new RuntimeException("未登录或 Token 无效");
}
// 删除登录状态
String loginKey = SsoRedisKeys.userLogin(userId);
redisUtils.delete(loginKey);
// 写入退出标记
long logoutTime = System.currentTimeMillis() / 1000;
String key = SsoRedisKeys.userLogout(userId);
redisUtils.set(key, logoutTime, 7 * 24 * 3600); // 7 天 TTL
Map<String, Object> data = new HashMap<>();
data.put("userId", userId);
data.put("logoutTime", logoutTime);
data.put("ttl", "7d");
return data;
}
退出记录在 Redis 中大概是这样的:
bash
sso:user:logout:1 → 1763620214
这条记录说明用户 1 在某个时间点触发过统一退出。子系统只要检查这条记录是否存在,就可以判断 SSO 登录是否还有效。
2)子系统拦截器:判断是否属于 SSO 登录,并检查退出状态
子系统自身有登录体系,所以不能看到 logout 标记就直接踢用户,否则会导致本地登录的用户也被强制退出。这里我们用的解决办法是,在生成 token 的时候我们加上一个 fromSSO 的标记,用来区分这次登录到底是门户过来的,还是本地登录的。
拦截器伪代码逻辑如下:
java
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler)
throws Exception {
String token = req.getHeader("Authorization");
if (token == null || token.isEmpty()) {
ResponseUtil.writeJson(resp, Result.fail(401, "缺少 Token"));
return false;
}
try {
Claims claims = jwtUtil.parseToken(token);
Long userId = Long.valueOf(String.valueOf(claims.get("userId")));
Boolean fromSSO = claims.get("fromSSO", Boolean.class);
// 仅拦截通过 SSO 登录的用户
if (Boolean.TRUE.equals(fromSSO)) {
String logoutKey = GrayRedisKeys.userLogout(userId);
if (redisUtils.hasKey(logoutKey)) {
ResponseUtil.writeJson(resp, Result.fail(401, "登录已过期,请重新登录"));
return false;
}
}
req.setAttribute("userId", userId);
} catch (Exception e) {
ResponseUtil.writeJson(resp, Result.fail(401, "Token 无效或已过期"));
return false;
}
return true;
}
这样子系统的拦截器就非常清晰:如果是通过 SSO 进入的用户,就判断认证中心是否退出;如果是本地登录的用户,就不受认证中心的影响。我们用 fromSSO 字段把行为分隔得很清楚,既保护了 SSO 的一致性,也不会破坏子系统单独登录的使用场景。
3)前端响应拦截:自动处理 401,清理本地 token
子系统前端只需要在响应拦截器里判断 code 是否为 401,如果是,就清除本地存储并回到登录页面:
javascript
service.interceptors.response.use(
(res) => {
const data = res.data;
if (data && data.code === 401) {
ElMessage.warning(data.message || "登录已过期,请重新登录");
localStorage.clear();
router.push("/login");
return Promise.reject(new Error("Token 过期"));
}
return data;
},
(err) => {
if (err.response && err.response.status === 401) {
localStorage.clear();
ElMessage.warning("登录已过期,请重新登录");
router.push("/login");
}
return Promise.reject(err);
}
);
有了服务端的退出标记和前端的自动清理,整体的单点退出流程就闭环了。
4)为什么必须加 fromSSO?
fromSSO 是整个单点退出中最关键的一个小细节,因为我们的整体架构是不强制子系统必须按照传统 SSO 方式,只能通过统一门户登录。每个系统都可以单独维护自己的本地登录逻辑,而门户 SSO 只是额外的能力。如果不区分 fromSSO,那么一旦门户退出,所有子系统的本地用户也会被强制退出,这明显不符合预期。
所以我们在生成 token 时加入了这样一个标记:
- 子系统本地登录 → fromSSO = false
- 从门户 code 登录 → fromSSO = true
也正是因为这个标记,子系统拦截器才能精准判断哪些登录需要被 SSO 退出影响,哪些不需要。
最后我这边贴一个简单的测试图我们来看下效果:

上面的这段动图可以很直观地看到整个单点退出机制的实际效果。我们从统一门户进入灰度中心时,灰度中心并没有做任何本地登录操作,而是完全依靠门户生成的授权码完成免登。因此,当我们在统一门户点击退出后,再回到灰度中心刷新页面,它会立即识别到"门户已经退出",从而自动清理自身登录态并回到登录页。
另外也可以看到,如果我们不是通过门户跳转,而是在灰度系统内部执行一次本地登录,那么它生成的 token 是本系统自己维护的,不携带 fromSSO 标记。这种情况下,即使门户已经退出,灰度系统依然保持独立登录状态,不会受到 SSO 退出的影响。
这样的设计目的就是减少耦合,不强制所有系统必须依赖统一门户统一登录。每个子系统依然可以根据业务场景选择单独登录或者 SSO 免登录,两种模式互不干扰。只有通过门户进入的用户才会受统一退出控制,本地登录的用户始终拥有独立的登录生命周期。 这样既能满足统一管理的需求,也保留了系统的独立性,整体结构既清晰又不互相牵制,适合多后台、多系统同时存在的企业环境。
其他扩展
我们做到这里,一个完整的 SSO(统一门户免登 + 单点退出)其实已经能稳定跑起来了,但如果系统一多、语言一杂、团队一大,就会出现一个问题:
每个子系统都要自己写一遍 SSO 登录、code 校验、token 生成、拦截器逻辑?
这样太浪费,维护成本也会飙升,所以接下来我们就需要把 SSO 的能力做成"可复用的模块",让所有系统都能无感接入。
1. Java 体系:直接封成 SDK,所有项目一行依赖即可用
对于 Java 系的项目来说(Spring Boot 为主),完全可以把所有 SSO 相关逻辑整理成一个 SDK,例如:
js
sso-client-sdk
├── JwtUtil
├── SSOProperties(比如 appId、centerUrl)
├── SSOLoginFilter(自动处理 code 验证)
├── TokenInterceptor(自动处理 fromSSO 的退出检查)
└── SSOClient(封装调用认证中心的 verify 接口)
发布到私服或 GitHub Packages 后,子系统只需要:
java
<dependency>
<groupId>com.xxx.sso</groupId>
<artifactId>sso-client-sdk</artifactId>
<version>1.0.0</version>
</dependency>
然后在配置文件中写:
yml
sso:
app-id: gray-center
server-url: https://sso.xxx.com
整个 SSO 登录流程就自动接入了,不需要重复写逻辑,也不会在几十个项目里散落多个版本,后期升级、修复都能统一管理。
2. PHP / Go / Node.js 也同样可以做成 SDK
非 Java 的系统一般也会有同样需求,所以也可以做对应语言的 SSO 客户端库,让接入方式尽可能统一:
- PHP:封装一个
SsoClient.php,代码验证、JWT 生成、token 拦截都在里面 - Go:做成一个 module,比如
github.com/xxx/sso-client - Node.js / Express:封装为中间件
sso-client-middleware
只要系统接入 SDK,就能:
- 自动解析 URL code
- 自动调用认证中心
/sso/code/verify - 自动创建本地 token
- 自动接入退出机制
- 自动拦截无效 token
整个流程不需要人工重复实现,提高一致性,也减少出错概率。
3. 非私有化系统接入:像 Jenkins、GitLab,这类系统不方便改代码怎么办?
很多工具系统(Jenkins、GitLab、Prometheus、Grafana)是第三方部署的,不是我们写的代码,所以它们不可能接我们的 SSO verifyCode 逻辑。
但对于这类系统,其实登录控制也不复杂,我们可以用一种"轻量级通行证模式"处理。
作为例子:
门户跳转到 Jenkins 时,不用 code,不用 verify,只要模拟用户已登录状态即可。
比如:
- 发一个带登录 Cookie 的 URL
- 带一个 access_token 过去
- 带 basic_auth 信息
- 或者直接反向代理注入 header(企业常用)
例如对于 Jenkins:
ini
https://jenkins.xxx.com/login?redirect=/?token=xxxx
或者 Nginx 代理:
sql
proxy_set_header X-Auth-User $user;
私有化系统走严格的 SSO 认证
非私有化系统走轻量登录模拟
这样就不会为了兼容某些系统搞得结构很乱。
4. 我们这样设计的核心目的是什么呢?
总的来说:
保留统一门户的能力,同时不给子系统增加负担,也不强制所有系统必须依赖 SSO。
也就是说:
- Java、PHP、Go 等项目:用 SDK 自动接入,低成本
- 第三方工具:用轻量方案模拟登录,不去硬塞 SSO
- 子系统既能通过门户免登,也能单独登录
- 单点退出只影响 SSO 入口,不影响本地登录
- 整体结构不耦合、可自由组合、适配能力强
这套模式对于我们真实的企业内部环境其实非常友好,尤其适合"多后台、多语言、多环境"的公司,不会因为引入 SSO 而把所有系统都搞得绑死在一起。
其他:分布式场景下的部署注意事项
我们做的这个系统在单机环境里跑 SSO 没啥问题,但只要上了真实公司环境,基本都是多实例、多服务的分布式部署,这时候有两个点必须提前规划好。
第一是登录态要统一存储 。不能让某台机器自己管自己的 Session,否则用户访问 A 机器登录成功,切到 B 机器就变成未登录,体验会直接崩掉。第二是授权码 code、用户登录态、单点退出记录这些信息都必须放到一个所有实例都能访问的地方。因此我们整个 SSO 体系都统一使用 Redis,不管认证中心有多少台机器、子系统有多少个实例,它们取出来的 userLogin、code、logout 信息都是一致的。
另外,如果子系统、门户有多环境(dev/qa/prod),一定要在 Redis key 前缀上隔离清楚,避免多环境混用导致奇怪的问题,比如 A 环境退出把 B 环境也踢掉了。
最后,如果公司规模大一些,Redis 也应该部署成集群或哨兵模式,避免它成为整个 SSO 的单点故障。总结来说就是一句话:SSO 本质上是无状态的,所有共享状态都统一收敛到 Redis,系统才能横向扩容、保证一致性。
为什么我没使用 Session?
其中有一点我觉得Session 最大的问题就是跟机器绑定。只要服务有多台实例,Session 就绝对会出现"不一致"的情况,除非我们上粘性会话(sticky session)或者把 Session 做成集中式存储,但那其实又变成自己造了一个简化版 Redis,维护成本还不如直接用 Redis。本质原因就是 Session 天然适合单机,不适合分布式的后台环境。
那为什么不把 code 存数据库呢?
code 是一次性的、秒级生命周期的东西,放数据库完全没有意义。数据库适合存长期、有价值的记录,而不是这种用完即删的临时凭证。而且数据库写入本身就慢,频繁插入、删除不仅占 IO,也会撑大 binlog,还会带来事务锁竞争。SSO 整个流程讲究"快",一旦数据库参与进来,延迟会立刻被放大。
更关键的是,code 要做到一次性删除,这种频繁、密集、瞬时的小操作特别适合内存型存储,而不是关系型数据库。
所以从一开始我们就把 Redis 当作整个 SSO 的"状态中心",门户、认证中心、子系统都可以横向扩容,而状态又是统一的,这样架构才能稳定跑起来。
最后
做到这里,其实这套统一门户 + SSO 的体系就算完整跑通了。这样设计之后,平时最烦的那几个问题基本都没了:不用再在浏览器贴一排标签页,也不用记一堆账号密码;老板再也不会隔三差五地来问"那个后台地址给我发一下";换个电脑也不用翻半天聊天记录去找某个系统的入口。所有后台都汇总在一个门户里,它就成了公司内部的"总入口"。
大家只要记一个地址、登录一次,就能进去自己有权限的所有系统。权限集中管理,系统集中展示,健康检查也集中显示,整套体验从零散变成了清清爽爽的一套。更重要的是,这套方案没有强制所有子系统完全依赖 SSO,谁愿意走免登录就走免登录,谁需要单独登录也完全不影响,互不耦合、互不干扰,用起来非常灵活。
从开发的角度看,把 SSO 封成 SDK 后,新项目接入变成一件非常轻松的事情,几分钟就能完成,不用每个项目重复写一遍 code 校验、token 生成、拦截器这些逻辑。后面系统越来越多、环境越来越多、团队越来越大,也不会出现登录体系混乱的情况。这种"统一但不死绑"的方式,特别适合中小团队在多后台、多语言、多环境的实际场景里长期使用。
按照这个架构继续扩展,其实还能做很多事,比如后台导航、内部搜索、快捷入口、消息通知、系统巡检等等,都可以往上叠。总之,这套统一门户可以慢慢成长成公司内部稳定好用的一站式入口,真正解决我们日常被多个后台折腾的那些麻烦事。终于不用谁都来找我要地址了...