# rat-sso-api **Repository Path**: ratcoder/rat-sso-api ## Basic Information - **Project Name**: rat-sso-api - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-07-29 - **Last Updated**: 2026-09-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # rat-sso-api 基于 Sa-Token 的**单点登录(SSO)服务端**与**统一权限中心**。为多应用提供统一的登录入口、票据签发、会话管理与细粒度 RBAC 授权。 ![Java](https://img.shields.io/badge/Java-17-orange) ![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.16-brightgreen) ![Sa-Token](https://img.shields.io/badge/Sa--Token-1.45.0-blue) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-17-336791) ![License](https://img.shields.io/badge/License-MIT-yellow) --- ## 目录 - [项目简介](#项目简介) - [功能特性](#功能特性) - [技术栈](#技术栈) - [架构设计](#架构设计) - [目录结构](#目录结构) - [快速开始](#快速开始) - [接口一览](#接口一览) - [开发约定](#开发约定) - [已知限制](#已知限制) - [许可证](#许可证) --- ## 项目简介 一个面向内部多系统的认证与授权中枢。它解决的问题是: - **一次登录,多系统通行** —— 用户在主站登录后,其他接入应用无需重复认证,通过 SSO 票据换取登录态; - **权限集中管理** —— 应用、角色、权限、用户组、用户五类实体统一建模,权限变更实时生效,无需各应用自行维护授权逻辑; - **权限模型可组合** —— 支持「直接授权 + 用户组授权 + 角色继承」三层叠加,并按应用维度隔离,适合组织架构复杂、应用数量多的场景。 服务端自身同时是一个标准的 SSO Server(对外提供授权、票据校验、单点注销、消息推送),也是一个管理后台所需的全套权限管理 API。 --- ## 功能特性 - **SSO 服务端**:授权跳转、票据签发与校验、单点注销、消息推送;内置应用注册表(`AppStore`),支持应用级密钥与回调白名单 - **统一权限中心**:应用 / 角色 / 权限 / 用户组 / 用户 五类实体的完整 CRUD 与关系绑定 - **四层权限模型**:直接角色 + 用户组角色 + 角色继承闭包,按 `app_id` 过滤 - **用户组树**:递归祖先 / 子孙查询,支持「禁用即级联禁用」「祖先启用才可启用」的不变量约束 - **角色继承**:闭包表物化 + 环检测;继承图变更(增删继承 / 删除角色)经事件驱动同步重建,与继承边处于同一事务(失败一并回滚,不会留下空表),启动时另做自愈全量重建 - **事件驱动的数据同步**:权限变更后精确失效缓存、踢下线、重建继承闭包,不依赖定时轮询 - **两级缓存**:角色缓存 + 权限缓存,按应用分桶,支持应用级 / 用户级精确失效 - **泛型 CRUD 骨架**:新增实体只需继承骨架 + 实现钩子,零重复代码 - **在线 API 文档**:基于 Scalar + springdoc,支持注解驱动的接口排序(`@ApiSort` / `@OpSort`) --- ## 技术栈 | 类别 | 技术 | 版本 | |------|------|------| | 语言 / 运行时 | Java | 17 | | 基础框架 | Spring Boot | 3.5.16 | | 持久层 | MyBatis-Plus | 3.5.16 | | 数据库 | PostgreSQL | 17 | | 认证授权 | Sa-Token(`sa-token-sso` / `sa-token-jwt` / `sa-token-forest` / `sa-token-redis-template`) | 1.45.0 | | 对象映射 | MapStruct-Plus | 1.5.1 | | 工具库 | Hutool | 5.8.46 | | 本地缓存 | Guava(`LoadingCache`) | 33.5.0 | | 密码散列 | jBCrypt | 0.4 | | API 文档 | springdoc-openapi + Scalar | 2.8.17 | | 代码增强 | Lombok / therapi-runtime-javadoc | 1.18.46 / 0.15.0 | | 构建工具 | Maven | — | --- ## 架构设计 ### 分层结构 ``` 接口层 SsoController · Auth{App,Role,Perm,Group,User}Controller · ProfileController │ 鉴权层 SaInterceptor(路径 × 操作 权限矩阵)· StpInterfaceHandler │ 业务层 CrudServiceImpl(泛型骨架 + 五类钩子)· 5 个业务 ServiceImpl │ 同步层 event/ ──▶ listener/ ──▶ sync/(AppStore · RoleCache · PermCache · RoleClosureRebuilder) │ 数据层 CrudDao / 各业务 Dao ──▶ BaseMapper ──▶ PostgreSQL(13 张表) │ 会话层 StpLogicJwtForSimple · SaTokenDao(内存 / Redis 可切换)· SSO ticket ``` ### 权限模型 权限解析采用**四层叠加 + 应用过滤**: ``` ① 用户直接绑定角色 auth_user_role ② 用户所属组授予的角色 auth_group_user → auth_group_role ├─ 直接所属组:角色全量生效 └─ 祖先组:仅 inheritable = true 的角色生效 ③ 角色继承闭包展开 auth_role_closure(由 auth_role_inherit 全量重建) ↓ 合并去重 ④ 按 app_id 过滤 → 该应用下的角色 code 列表 / 权限 code 列表 ``` 数据模型中,`auth_role` / `auth_perm` 归属某个应用,`code` 在应用内唯一;`auth_group` 为树形结构(`parent_id` 为 `null` 表示根组);`auth_user_cred` 支持 `PASSWORD` / `SMS` / `EMAIL` 多种凭据类型,同一用户同类型凭据唯一。其中 `secret` 列**允许为空**(`SMS` / `EMAIL` 凭据未下发验证码时即为空,这类凭据当前仅由 DB 直改产生),因此凭据比对是 **fail-closed** 语义:密文为空或不是合法 bcrypt 串时一律按「不匹配」处理(返回 `2003`),不会因此抛出服务端异常。 ### 鉴权矩阵 接口鉴权由两个枚举的**笛卡尔积**驱动,新增实体无需改动拦截器: - `AuthPath` —— 5 个业务前缀(`/auth/app`、`/auth/role`、`/auth/perm`、`/auth/group`、`/auth/user`)及各前缀的权限码前缀 - `CrudAuthWrapper` —— 7 种操作(新增 / 删除 / 批量删除 / 修改 / 详情 / 列表 / 分页),各自绑定请求路径、HTTP 方法与权限码 `SaInterceptor` 仅拦截 `/auth/**`,对每个请求按矩阵校验权限码(`StpUtil.checkPermission`)。两个设计细节: - **一个请求只命中一条规则**:含路径变量的规则(`/{id}`)显式排除字面量端点(`/list`、`/batch`、`/page`),避免一次请求同时校验两个权限码; - **启动期 fail-fast**:`CrudAuthWrapper` 的静态块校验「路径含 `{`」与「`pathType = VARIABLE`」必须一致,把易静默失效的配置错误转为启动异常。 `/sso/**` 不在拦截范围内,登录入口天然公开。 ### 事件驱动的数据同步 实体变更后的一致性通过事件而非轮询维护,按**实体的 5 个域**组织(App / User / Role / Group / Perm): ``` Service 钩子(afterInsert / beforeDelete …) └─▶ publishEvent(XxxSyncEvent) ├─▶ @TransactionalEventListener(fallbackExecution = true) // AFTER_COMMIT │ └─▶ Listener 薄转发 ──▶ AppStore(SSO 注册表) │ └─▶ RoleCache / PermCache(精确失效) └─▶ @TransactionalEventListener(phase = BEFORE_COMMIT, …) // 提交前 └─▶ Listener 薄转发 ──▶ RoleClosureRebuilder(重建继承闭包) ``` 关键设计: - **监听器只转发,不写同步逻辑**;同步逻辑聚合在目标类中。三个同步目标 —— `AppStore`(维护 `SaSsoServerConfig.clients`)、`RoleCache` / `PermCache`(精确失效)、`RoleClosureRebuilder`(重建闭包表)—— 同处 `sync/` 包,与 `event/`、`listener/` 构成本链路的完整三层 - **DAO 只允许被 Service 层持有**:`sync/`、`listener/` 等非 Service 组件一律经 Service 接口取数,只把「查库 / 落库」一步下沉,**算法与并发控制留在原地** —— `RoleClosureRebuilder` 的查环 + BFS 求最短深度 + `synchronized`(数据访问走 `AuthRoleService.listAllInherits` / `replaceClosure`)、`AppStore` 的注册表同步与乐观锁都属此类。该约束由 `arch/DaoAccessLayeringTest` 扫描源码固化,新增组件时间接阻止回潮 - **启动自愈由目标组件自持,不落在业务组件上**:`AppStore` 自己实现 `ApplicationRunner` 完成注册表初始化,`RoleClosureRebuilder` 同样自己承担启动时的闭包全量重建 —— 业务 Service 因此完全不持有同步组件引用,只负责发事件。注意启动回调**必须经容器引用**(`ObjectProvider`)调用带 `@Transactional` 的 `rebuild()`:写成 `this.rebuild()` 是同类内自调用,不经代理、注解静默失效,「清空 + 写入」会退回各自提交 - **两类同步目标的事务阶段刻意不同**:缓存与注册表是**可重建的易失数据**,放 `AFTER_COMMIT`(读已提交状态;提交前清缓存还会让并发线程用未提交数据重填),失败可静默、由 TTL 与定期对账兜底;而 `auth_role_closure` 是**持久化的物化派生数据**,必须与 `auth_role_inherit` 处于同一事务,故放 `BEFORE_COMMIT`,失败会让整次变更回滚 —— 若改成 `AFTER_COMMIT`,撤销继承后闭包表仍留着已撤销的祖先,会继续授予**已撤销的权限**(越权,且没有 TTL 可兜底) - **失效定位信息由事件携带**:App / Role / Perm 事件带 `appId`(经 `AppStore` 转 `clientId`),User 事件带 `userId`,Group 事件带受影响成员的 `userId` 列表。删除类事件在 **before 阶段**发布 —— 此时行仍在,定位信息才反查得到 - `appId → clientId` 的反查基于 `AppStore` 维护的**全部应用快照**(含内置 `sso` 与已停用应用),与只装可注册应用的 SSO 注册表**刻意分离**:内置 `sso` 应用承载管理端自身的角色与权限,若反查沿用注册表的过滤条件,它的缓存桶将永不精确失效 - **异常隔离**:监听器统一 `syncQuietly` 包裹,单个同步动作失败不影响事务已提交的事实,由缓存 TTL 与注册表定期对账兜底 —— **闭包重建是例外**:它必须向上传播异常以触发回滚,包住就成了「假装成功」 - **注册表对账**:`AppStore` 每小时全量对账一次,采用「锁 + 乐观版本号」,DB 查询在锁外执行,注册表就地增量同步 ### 缓存与失效 `RoleCache` / `PermCache` 均为 Guava `LoadingCache`,**按 `clientId` 分桶**,单桶上限 10000 条、写入后 1 小时过期。失效提供三档粒度: | 方法 | 语义 | 使用场景 | |------|------|----------| | `invalidate(clientId)` | 清空某应用桶 | 应用 / 角色 / 权限变更 | | `invalidateUser(userId)` | 跨桶清理某用户 | 用户 / 用户组变更 | | `invalidateAll()` | 清空全部 | 保留的兜底能力 | > 角色缓存与权限缓存必须**成对失效** —— 权限缓存存的是「按角色算出的权限结果」,只清角色缓存会留下用旧角色计算出的权限。 --- ## 目录结构 ``` rat-sso-api/ ├── src/main/java/rat/sso/api/ │ ├── SsoApp.java # 启动类 │ ├── annotation/ # @Add / @Edit / @Unique / @ApiSort / @OpSort │ ├── config/ # AuthConfig / WebMvcConfig / MybatisPlusConfig / DocConfig … │ ├── constant/ # 路径、权限码、表名、系统常量 │ ├── controller/ # CrudController(骨架)+ 7 个业务 Controller │ ├── converter/ # 请求参数转换器 │ ├── dao/ # CrudDao(骨架)+ 各实体 Dao 及实现 │ ├── deserializer/ serializer/ # Instant / 大数字的 JSON 处理 │ ├── doc/ # OpenAPI 文档定制(排序、Tag 重写) │ ├── domain/ # 实体 / BO / VO / DTO │ ├── enums/ # ErrorCode / AuthPath / CrudAuthWrapper … │ ├── event/ # 5 个域的同步事件 │ ├── exception/ handler/ # 业务异常定义与全局异常处理 │ ├── listener/ # 5 个事件监听器 │ ├── mapper/ # MyBatis-Plus Mapper(含递归 CTE 查询) │ ├── message/ # SSO 消息处理器(角色 / 权限推送) │ ├── service/ # CrudService(骨架)+ 业务 Service 及实现 │ ├── sync/ # 事件同步目标组件:AppStore / RoleCache · PermCache / RoleClosureRebuilder │ └── util/ # 加解密、反射、类型转换等工具 ├── src/main/resources/ │ ├── application.yml # 配置(数据库、认证参数) │ └── static/doc.html # 在线 API 文档页(Scalar) ├── src/test/java/ # 控制器 / 鉴权链路 / 序列化测试 ├── script/ │ ├── sql/sso.sql # 建库建表 + 初始化数据 │ └── docker/docker-compose.yml # PostgreSQL 开发环境 ├── openspec/ # 变更提案、规格说明与问题清单(backlog.md) ├── docs/ # 项目分析与优化方案 └── pom.xml ``` --- ## 快速开始 ### 环境要求 - JDK 17+ - Maven 3.8+ - PostgreSQL 15+(`auth_group` 的唯一索引使用了 `nulls not distinct`,需 PG 15 及以上) - Redis(可选,用于多实例部署时会话共享) ### 1. 启动数据库 ```bash cd script/docker docker compose up -d ``` 该 compose 会启动 `postgres:17` 并自动执行 `script/sql/sso.sql` 完成建表与初始化数据。若使用已有数据库,请手动执行该脚本。 ### 2. 调整配置 编辑 `src/main/resources/application.yml`: ```yaml server: port: 10001 spring: datasource: url: jdbc:postgresql://localhost:5432/sso_db username: <你的数据库用户> password: <你的数据库密码> auth: secret: use-redis: false # 多实例部署时改为 true,并补充 spring.data.redis 配置 ``` ### 3. 启动 ```bash mvn clean package java -jar target/rat-sso-api-1.0.jar ``` ### 4. 验证 - **API 文档**: - **OpenAPI 原始文档**: 初始化脚本内置了管理员账号 `super_admin`,初始密码见 `AuthUserConst.DEFAULT_PASSWORD`。**部署后请立即修改。** --- ## 接口一览 ### SSO 认证(`/sso/**`,公开) | 方法 | 路径 | 说明 | |------|------|------| | POST | `/sso/login` | 登录,返回 token | | GET | `/sso/isLogin` | 是否已登录 | | POST | `/sso/logout` | 登出 | | GET | `/sso/redirectUrl` | 查询客户端重定向地址(签发 ticket) | | GET | `/sso/pushS` | 接收客户端推送消息 | Sa-Token SSO 还内置了 `/sso/auth`(授权跳转)、`/sso/doLogin`(API 登录)、`/sso/checkTicket`(票据校验)、`/sso/signout`(单点注销)、`/sso/userinfo`(用户信息)等标准端点。 ### 权限管理(`/auth/{app,role,perm,group,user}/**`,受权限矩阵保护) 所有实体共用泛型骨架提供的 7 个端点: | 方法 | 路径 | 说明 | 对应权限码 | |------|------|------|-----------| | POST | `/auth/{entity}` | 新增 | `auth:{entity}:add` | | DELETE | `/auth/{entity}/{id}` | 删除 | `auth:{entity}:delete` | | DELETE | `/auth/{entity}/batch` | 批量删除 | `auth:{entity}:delete` | | PUT | `/auth/{entity}` | 修改 | `auth:{entity}:edit` | | GET | `/auth/{entity}/{id}` | 详情 | `auth:{entity}:view` | | GET | `/auth/{entity}/list` | 列表 | `auth:{entity}:query` | | POST | `/auth/{entity}/page` | 分页 | `auth:{entity}:query` | 分页请求体形如 `{ "pageNum": 1, "pageSize": 10, "cond": { }, "sorts": [ { "field": "属性名", "asc": true } ] }`,空值语义如下: - `pageNum` / `pageSize` 省略或**显式传 `null`** 均取默认值 `1` / `10`(`Nulls.SKIP` 跳过 null 赋值,防止拆箱 NPE);取值越界(如 `0`)由 `@Min` 拦为业务码 `400`(如 `[页码]必须大于等于1`) - `sorts` 中 `asc` 省略或传 `null` 均按升序;`field` 取**实体属性名**(非列名),无法识别的排序项被静默忽略、不影响其它排序项 此外还有子资源接口与实体专属操作: - **关系绑定** —— 用户↔角色(`/auth/user/{id}/role`)、组↔用户(`/auth/group/{id}/user`)、组↔角色(`/auth/group/{id}/role`)、角色↔权限(`/auth/role/{id}/perm`)、角色↔继承(`/auth/role/{id}/inherit`),均为 `PUT` 绑定 / `DELETE` 解绑 / 查询(多为 `GET .../list`,组成员为 `POST .../user/page`)三件套,权限码形如 `auth:user:role:config` - **绑定是幂等全量 set**:对已存在的关系行按传入值**覆盖**,不是增量修补。带有效期的三个绑定(用户↔角色、组↔用户、组↔角色)中,`expireTime` **不传即永久有效**,且会**清空该绑定原有的到期时间**;`inheritable`(仅组↔角色)**不传即 `false`**,会取消子组继承。只想续期请显式传新的 `expireTime` - **启禁** —— `PUT /{id}/enable`、`PUT /{id}/disable`(应用 / 用户组 / 用户),权限码 `auth:{entity}:enabled:toggle` - **应用密钥** —— `GET /auth/app/{id}/secret` 查看、`PUT /auth/app/{id}/secret` 重置 - **用户密码** —— `PUT /auth/user/{id}/password` 重置(`auth:user:password:reset`) - **应用状态查询(刻意公开)** —— `GET /auth/app/{clientId}/status` 供前端登录页在回跳失败时诊断「应用不存在 / 应用已停用 / 回跳失败」。该接口**有意不做鉴权**(调用时用户可能尚无有效登录态),请勿为其补充权限校验 ### 个人资料(`/auth/profile/**`) | 方法 | 路径 | 说明 | |------|------|------| | GET | `/auth/profile` | 查询当前用户资料 | | PUT | `/auth/profile` | 编辑资料 | | PUT | `/auth/profile/password` | 修改密码 | | POST | `/auth/profile/phone` | 绑定手机号(未实现) | | POST | `/auth/profile/email` | 绑定邮箱(未实现) | 以上端点**仅要求登录态**(`@SaCheckLogin`,未登录返回业务码 `401`),**不参与权限矩阵** —— 该矩阵只覆盖 `/auth/{app,role,perm,group,user}` 五个前缀。因此在 `/auth/profile/**` 下新增端点时需自行标注 `@SaCheckLogin`,拦截器不会代为校验。 ### 统一响应格式 ```json { "code": 200, "message": "ok", "data": {} } ``` 成功固定为 `200`;业务错误码按实体分段,定义见 `enums/ErrorCode`: | 区段 | 含义 | |------|------| | `400` / `401` / `403` / `404` / `405` / `406` / `415` | 请求语义错误(与 HTTP 状态码同值) | | `1001` ~ `1004` | 通用(数据不存在 / 已存在 / 缓存异常 / 完整性约束冲突) | | `2001` ~ `2004` | 凭据与认证 | | `3001` ~ | 用户 | | `4001` ~ | 应用 | | `5001` ~ | 角色 | | `6001` ~ | 权限 | | `7001` ~ | 用户组 | | `8001` ~ | 关联关系(内置绑定、跨应用、循环继承) | | `9999` | 未知异常 | `400` 段按成因细分,`message` 指出具体原因: | 场景 | 触发例 | |------|--------| | 缺少请求参数 | 未传 `?password=` | | 请求体格式错误 | 请求体 JSON 语法错误 / 反序列化失败 | | 参数校验失败 | Bean Validation 不通过,`message` 为各字段注解文案(多个以「;」连接) | | 参数类型不匹配 | 具名参数(路径变量 / 查询参数等)转换失败,如 `GET /auth/user/abc`、`?expireTime=abc`,`message` 形如 `参数[expireTime]类型不匹配(期望 Instant)!` | | 无效的重定向地址 | `/sso/redirectUrl` 的 `redirect` 缺失或非法 | | 重定向地址不在允许范围内 | `/sso/redirectUrl` 的 `redirect` 不在该客户端 `allowUrl` 白名单内 | | 无效的应用标识 | `/sso/redirectUrl`、`/sso/pushS` 的 `client` 缺失或未注册(框架 `30013` / 无细分码两条路径) | | 签名校验失败 | `/sso/pushS` 的 `timestamp` / `nonce` / `sign` 缺失、签名不匹配或 timestamp 超范围(框架 `SaSignException` 各分支) | `403` 表示**已认证但权限/角色不足**(`FORBIDDEN`),`message` 形如 `无权限:auth:app:query!`。 ### 框架异常与响应结构的边界 **框架错误码不透出**:`GlobalExceptionHandler` 对 `SaTokenException`(sa-token core / sign / jwt / sso 各插件的共同基类) **按白名单映射**为项目错误码,未命中的一律回落 `9999` 并把框架码写进 `error` 日志。 因此客户端只会看到本项目 `ErrorCode`,不会看到 `11051`、`12201`、`30001` 之类的框架码。 同理 `NotLoginException` 的文案由项目按框架 `type` 自产(`401`),**不回显 token 原文**。 映射有两条通道:**码表**(`SA_TOKEN_CODE_MAPPING`)覆盖带细分码的框架异常; **异常类型**覆盖**不带细分码**的那几个(`SaSignException` 的「缺少 timestamp/nonce/sign」、 `SaSsoException` 的「client 标识不可为空」)—— 它们没有码可查,只能按类型判定,成因同样在调用方参数上。 **责任在服务端/部署的码故意不映射**(如 `30015` 无效的 allow-url 配置、sign 的 `12201` 未配置 secret-key), 避免把「服务端没配对」谎报成「调用方传错了」。 **`/error` 已归一化**:自定义 `ApiErrorController` 覆盖了 Spring Boot 默认的 `/error` 行为, 进入错误分发的请求同样返回 `R` + HTTP 200(默认实现返回 `{timestamp,status,error}` 且状态码非 200)。 **以下三处刻意不套 `R`(框架协议面,改动会破坏客户端交互)**: | 端点 | 形态 | 原因 | |------|------|------| | `POST /sso/logout`(无 `back`) | `{"code":200,"msg":"单点注销成功","data":null}` | sa-token 协议载荷,键名是 `msg` 而非 `message` | | `POST /sso/logout?back=SELF` | HTML `