# 外部系统单点登录到 pms-agent 接入说明

## 1. 文档目的

本文档面向目标系统 `pms-agent` 的实现人员，以及后续需要接入 `pms-agent` 的各外部来源系统。

本文档不绑定某一个来源系统。只要某个外部系统已经完成自己的登录，并且能确认当前登录用户，就可以按本文档的方式把用户单点带到 `pms-agent`。

当前部署形态通常是同域不同路径，例如：

- 来源系统路径：`/union-js`、`/oa`、`/portal` 或其他业务系统路径
- 目标系统路径：`/pms-agent`
- 来源系统与目标系统在同一域名下，只是域名后的二级路径不同

本文档只说明“外部系统单点到目标系统”的接入方案，不说明普通账号密码登录，也不展开各来源系统内部自己的登录逻辑。

## 2. 核心结论

- `pms-agent` 是统一接收方，所有来源系统都按同一套 SSO Cookie 协议接入。
- 来源系统负责确认当前用户是谁，并生成短期 AES-GCM 加密 Token。
- 来源系统把加密 Token 写入 Cookie，Cookie 名建议统一为 `pms_agent_sso_token`。
- Cookie 必须设置 `Path=/`，这样浏览器访问 `/pms-agent` 时才能携带。
- 来源系统写完 Cookie 后，必须重定向到 `/pms-agent/auth/external-sso`。
- `pms-agent` 后端读取 Cookie、识别来源系统 `issuer` 和密钥版本 `kid`、选择对应 AES 密钥解密、校验 `aud` 和过期时间，再按 `username` 匹配自己的用户。
- 匹配成功后，`pms-agent` 建立自己的登录态，并清理一次性 SSO Cookie。
- Cookie 中不能放明文 JSON，也不能只做 Base64 编码；用户名必须进入 AES-GCM 密文。
- `HttpOnly` 只能防止前端脚本读取 Cookie，不等于加密，不能单独作为隐私保护手段。
- 不建议让 `pms-agent` 复用任意来源系统的登录 Cookie、Session 或 Redis key。

## 3. 总体方案

### 3.1 角色划分

来源系统：

- 已经完成自己的登录认证。
- 能拿到当前登录用户的 `username`。
- 能和 `pms-agent` 约定来源系统编码 `issuer`。
- 能和 `pms-agent` 共享本来源系统专用 AES 密钥，并约定当前密钥版本 `kid`。
- 负责生成短期 AES-GCM 加密 Token 并写入 Cookie。

目标系统 `pms-agent`：

- 不关心来源系统内部如何登录。
- 只信任配置过的来源系统。
- 只接收可成功解密、未过期、`aud` 正确的 SSO Token。
- 根据解密后的 `username` 匹配本系统用户。
- 建立自己的登录态，不长期依赖 SSO Token。

### 3.2 标准流程

1. 用户已经登录某个来源系统。
2. 用户点击来源系统中的“进入智能体系统”按钮或菜单。
3. 浏览器先访问来源系统自己的出站 SSO 入口，例如 `/{source-context}/auth/out-sso/pms-agent`。
4. 来源系统后端读取当前登录用户的 `username`。
5. 来源系统生成短期 AES-GCM 加密 Token，写入 Cookie `pms_agent_sso_token`。
6. Cookie 设置 `Path=/`、`HttpOnly=true`、短有效期。
7. 来源系统重定向浏览器到 `/pms-agent/auth/external-sso`。
8. 浏览器访问 `/pms-agent/auth/external-sso` 时自动携带 `pms_agent_sso_token`。
9. `pms-agent` 按 `issuer + kid` 选择 AES 密钥并解密 Token。
10. `pms-agent` 校验 `issuer`、`kid`、`aud=pms-agent`、`exp`、`iat`、`jti`。
11. `pms-agent` 通过 `username` 匹配自己的用户。
12. 匹配成功后，`pms-agent` 建立自己的 Cookie、Redis 或 Session 登录态。
13. `pms-agent` 清理 `pms_agent_sso_token`，然后跳转目标系统首页或原始访问地址。

### 3.3 为什么不能直接跳 `/pms-agent`

前端不要直接写：

```javascript
window.location.href = '/pms-agent';
```

直接跳目标系统时，来源系统后端没有机会生成 `pms_agent_sso_token`，`pms-agent` 也无法知道用户来自哪个已登录系统。

正确方式是先访问来源系统自己的出站 SSO 入口：

```javascript
window.location.href = '/{source-context}/auth/out-sso/pms-agent';
```

HTML 示例：

```html
<a href="/{source-context}/auth/out-sso/pms-agent">进入智能体系统</a>
```

如果来源系统是 `/union-js`，则入口可以是：

```text
/union-js/auth/out-sso/pms-agent
```

如果来源系统是 `/portal`，则入口可以是：

```text
/portal/auth/out-sso/pms-agent
```

## 4. SSO Cookie 协议

### 4.1 Cookie 名称

建议所有来源系统统一写：

```text
pms_agent_sso_token
```

该 Cookie 只是一次跳转用的短期 SSO 凭证，不是来源系统登录态，也不是 `pms-agent` 登录态。

### 4.2 Cookie 属性

来源系统写 Cookie 时建议设置：

- `Path=/`
- `HttpOnly=true`
- `SameSite=Lax`
- `Max-Age=60` 到 `120`
- 生产 HTTPS 环境设置 `Secure=true`
- 同域不同路径部署时一般不设置 `Domain`

关键点：

- `Path=/` 必须设置，否则 `/pms-agent` 可能收不到。
- 如果误写成 `Path=/{source-context}`，目标系统 `/pms-agent` 收不到。
- `HttpOnly=true` 只能防止前端脚本读取 Cookie，不代表 Cookie 内容被加密。
- `SameSite=Lax` 适合当前 GET 跳转式单点；如果未来需要跨站 iframe 或 POST 单点，再单独评估 `SameSite=None; Secure`。
- SSO 成功后，`pms-agent` 应用同名 Cookie、同样 `Path=/`、`Max-Age=0` 清理它。

### 4.3 Cookie 值格式

Cookie 值建议采用以下格式：

```text
v1.{issuer}.{kid}.{iv}.{ciphertext}
```

字段说明：

- `v1`：协议版本。
- `issuer`：来源系统编码，例如 `union-js`、`portal`、`oa`；该字段用于选择密钥，不包含个人用户信息。
- `kid`：密钥版本，例如 `2026-04`、`k1`；该字段用于支持密钥轮换，不包含个人用户信息。
- `iv`：AES-GCM 随机初始化向量，12 字节，Base64URL 编码。
- `ciphertext`：AES-GCM 加密结果，包含密文和认证标签，Base64URL 编码。

AES-GCM 加密和解密时必须设置 AAD，建议 AAD 固定为：

```text
v1.{issuer}.{kid}.pms-agent
```

AAD 不会被加密，但会参与认证标签计算。任何人篡改外层 `issuer`、`kid` 或目标系统标识后，`pms-agent` 解密必须失败。

约束：

- Cookie 值里不能出现明文 `username`。
- Cookie 值里不能出现工号、姓名、组织等非必要个人信息。
- Cookie 值里不能放明文 JSON。
- 只做 Base64 编码不算加密。

### 4.4 加密载荷

AES-GCM 加密前的 JSON 只保留登录所需的最小字段：

```json
{
  "iss": "source-system-code",
  "kid": "2026-04",
  "aud": "pms-agent",
  "username": "登录账号",
  "iat": 1776900000000,
  "exp": 1776900120000,
  "jti": "随机串"
}
```

字段说明：

- `iss`：来源系统编码，必须和 Cookie 外层 `issuer` 一致。
- `kid`：密钥版本，必须和 Cookie 外层 `kid` 一致。
- `aud`：接收方，固定 `pms-agent`。
- `username`：登录账号，目标系统用它匹配用户。
- `iat`：签发时间。
- `exp`：过期时间。
- `jti`：随机串，可用于防重放。

不建议放入以下字段：

- 工号。
- 用户姓名。
- 组织 ID。
- 手机号、邮箱等联系方式。
- 来源系统用户完整对象。

### 4.5 用户匹配字段

`pms-agent` 不应该假设所有来源系统都和自己共用同一个数据库。目标系统只需要基于解密后的 `username` 查找自己的用户。

具体用户实体、仓储、SQL、表结构不在本文档展开，由目标系统自行实现或从现有系统复制调整。

## 5. 多来源系统信任配置

`pms-agent` 应维护来源系统白名单，不要接受任意 `issuer`。

示例配置：

```yaml
pms-agent:
  sso:
    cookie-name: pms_agent_sso_token
    audience: pms-agent
    token-expire-seconds: 120
    trusted-issuers:
      union-js:
        active-kid: 2026-04
        keys:
          2026-04: ${PMS_AGENT_SSO_UNION_JS_AES_KEY_2026_04}
          2026-01: ${PMS_AGENT_SSO_UNION_JS_AES_KEY_2026_01}
      portal:
        active-kid: 2026-04
        keys:
          2026-04: ${PMS_AGENT_SSO_PORTAL_AES_KEY_2026_04}
      oa:
        active-kid: 2026-04
        keys:
          2026-04: ${PMS_AGENT_SSO_OA_AES_KEY_2026_04}
```

要求：

- 每个来源系统使用不同 AES 密钥。
- 每个来源系统至少配置一个当前密钥 `active-kid`。
- `keys.{kid}` 建议配置为 Base64 编码后的 32 字节随机密钥。
- 密钥只放后端配置或环境变量，不下发前端。
- 日志中不要打印完整 Token 和密钥。
- `pms-agent` 先从 Cookie 外层解析 `issuer` 和 `kid`，再按 `issuer + kid` 选择 AES 密钥解密。
- 密钥轮换时，来源系统先切换到新 `kid` 签发；`pms-agent` 在一个短过渡期内同时保留新旧 `kid` 用于解密，过渡期至少覆盖 Token 最大有效期和发布时间差。
- 生产密钥不要写入仓库。

32 字节 AES 密钥可用以下命令生成：

```powershell
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($bytes)
[Convert]::ToBase64String($bytes)
```

## 6. 来源系统实现建议

### 6.1 出站入口

每个来源系统都需要提供自己的出站入口。路径不强制，但建议统一风格：

```text
/{source-context}/auth/out-sso/pms-agent
```

入口职责：

- 校验当前用户是否已登录。
- 读取当前登录用户的 `username`。
- 生成 `aud=pms-agent` 的短期 AES-GCM 加密 Token。
- 写入 Cookie `pms_agent_sso_token`，且 `Path=/`。
- 重定向到 `/pms-agent/auth/external-sso`。

### 6.2 来源系统 Controller 示例

```java
@RestController
@RequestMapping("/auth")
public class OutSsoController {

    private static final String SSO_COOKIE_NAME = "pms_agent_sso_token";

    private final CurrentUserService currentUserService;
    private final OutSsoTokenService outSsoTokenService;

    public OutSsoController(CurrentUserService currentUserService,
                            OutSsoTokenService outSsoTokenService) {
        this.currentUserService = currentUserService;
        this.outSsoTokenService = outSsoTokenService;
    }

    @GetMapping("/out-sso/pms-agent")
    public void outSsoToPmsAgent(@RequestParam(required = false) String redirect,
                                 HttpServletResponse response) throws IOException {
        CurrentUser currentUser = currentUserService.getRequiredCurrentUser();
        String token = outSsoTokenService.createToken(currentUser.getUsername(), "pms-agent");

        addSsoCookie(response, token, 120);

        String target = "/pms-agent/auth/external-sso";
        if (isSafeRedirect(redirect)) {
            target = target + "?redirect=" + URLEncoder.encode(redirect, "UTF-8");
        }
        response.sendRedirect(target);
    }

    private boolean isSafeRedirect(String redirect) {
        if (redirect == null) {
            return false;
        }
        try {
            if (redirect.contains("\\") || redirect.startsWith("//")) {
                return false;
            }
            String decoded = URLDecoder.decode(redirect, "UTF-8");
            if (decoded.contains("\\") || decoded.startsWith("//")) {
                return false;
            }
            URI uri = URI.create(decoded).normalize();
            if (uri.isAbsolute() || uri.getRawAuthority() != null) {
                return false;
            }
            String path = uri.getPath();
            return "/pms-agent".equals(path) || path.startsWith("/pms-agent/");
        } catch (Exception ex) {
            return false;
        }
    }

    private void addSsoCookie(HttpServletResponse response, String token, int maxAgeSeconds) {
        response.addHeader("Set-Cookie", SSO_COOKIE_NAME + "=" + token
                + "; Path=/"
                + "; Max-Age=" + maxAgeSeconds
                + "; HttpOnly"
                + "; SameSite=Lax");
    }
}
```

说明：

- `CurrentUserService` 由各来源系统自己实现，用于读取本系统当前登录用户。
- 来源系统不需要把自己的登录 Cookie 或 Redis key 暴露给 `pms-agent`。
- `redirect` 必须先 URL decode、拒绝反斜杠和 `//`、执行 URI normalize，再限制为 `/pms-agent` 路径树内的相对路径，避免开放重定向和路径穿越风险。

### 6.3 来源系统 Token 生成示例

```java
@Service
public class OutSsoTokenService {

    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_LENGTH = 128;

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final SecureRandom secureRandom = new SecureRandom();

    @Value("${out-sso.issuer}")
    private String issuer;

    @Value("${out-sso.kid}")
    private String kid;

    @Value("${out-sso.aes-key}")
    private String aesKey;

    @Value("${out-sso.token-expire-seconds:120}")
    private long expireSeconds;

    public String createToken(String username, String audience) {
        try {
            long now = System.currentTimeMillis();
            Map<String, Object> payload = new LinkedHashMap<>();
            payload.put("iss", issuer);
            payload.put("kid", kid);
            payload.put("aud", audience);
            payload.put("username", username);
            payload.put("iat", now);
            payload.put("exp", now + expireSeconds * 1000);
            payload.put("jti", UUID.randomUUID().toString());

            byte[] iv = new byte[GCM_IV_LENGTH];
            secureRandom.nextBytes(iv);

            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            SecretKeySpec keySpec = new SecretKeySpec(decodeKey(aesKey), "AES");
            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.ENCRYPT_MODE, keySpec, parameterSpec);
            cipher.updateAAD(buildAad(issuer, kid, audience));
            byte[] encrypted = cipher.doFinal(objectMapper.writeValueAsBytes(payload));

            Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
            return "v1." + issuer + "." + kid + "."
                    + encoder.encodeToString(iv) + "."
                    + encoder.encodeToString(encrypted);
        } catch (Exception ex) {
            throw new RuntimeException("生成单点登录 Token 失败", ex);
        }
    }

    private byte[] decodeKey(String value) {
        byte[] key = Base64.getDecoder().decode(value);
        if (key.length != 32) {
            throw new IllegalArgumentException("AES 密钥必须是 Base64 编码的 32 字节随机值");
        }
        return key;
    }

    private byte[] buildAad(String issuer, String kid, String audience) {
        return ("v1." + issuer + "." + kid + "." + audience).getBytes(StandardCharsets.UTF_8);
    }
}
```

来源系统配置示例：

```yaml
out-sso:
  issuer: union-js
  kid: 2026-04
  aes-key: ${PMS_AGENT_SSO_UNION_JS_AES_KEY_2026_04}
  token-expire-seconds: 120
```

其他来源系统只需要把 `issuer`、`kid` 和 `aes-key` 换成自己的配置。

## 7. pms-agent 侧实现建议

### 7.1 新增文件落点

以下路径按默认目标项目 `D:\Code\pms-agent` 举例，包名由目标系统实际包名替换。用户实体、用户仓储、用户表结构由目标系统按自己的实现维护，本文档不列实体和仓储文件：

- `D:\Code\pms-agent\src\main\java\<目标包名>\controller\ExternalSsoController.java`
- `D:\Code\pms-agent\src\main\java\<目标包名>\service\ExternalSsoTokenVerifier.java`
- `D:\Code\pms-agent\src\main\java\<目标包名>\service\TrustedIssuerProperties.java`
- `D:\Code\pms-agent\src\main\java\<目标包名>\service\LoginSessionService.java`
- `D:\Code\pms-agent\src\main\java\<目标包名>\service\TargetUserService.java`
- `D:\Code\pms-agent\src\main\resources\application.yml`

### 7.2 ExternalSsoController 示例

```java
@RestController
@RequestMapping("/auth")
public class ExternalSsoController {

    private static final String SSO_COOKIE_NAME = "pms_agent_sso_token";

    private final ExternalSsoTokenVerifier tokenVerifier;
    private final TargetUserService targetUserService;
    private final LoginSessionService loginSessionService;

    public ExternalSsoController(ExternalSsoTokenVerifier tokenVerifier,
                                 TargetUserService targetUserService,
                                 LoginSessionService loginSessionService) {
        this.tokenVerifier = tokenVerifier;
        this.targetUserService = targetUserService;
        this.loginSessionService = loginSessionService;
    }

    @GetMapping("/external-sso")
    public void externalSso(@RequestParam(required = false) String redirect,
                            HttpServletRequest request,
                            HttpServletResponse response) throws IOException {
        String token = getCookieValue(request, SSO_COOKIE_NAME);
        if (token == null || token.trim().isEmpty()) {
            throw new RuntimeException("单点登录失败，缺少 pms_agent_sso_token");
        }

        ExternalSsoUser ssoUser = tokenVerifier.verify(token);
        TargetUser targetUser = targetUserService.findByUsername(ssoUser.getUsername());
        if (targetUser == null) {
            throw new RuntimeException("单点登录失败，目标系统不存在该用户");
        }

        loginSessionService.createLoginSession(targetUser, response);
        clearSsoCookie(response);

        String target = normalizeRedirect(redirect);
        response.sendRedirect(target);
    }

    private String normalizeRedirect(String redirect) {
        if (redirect == null) {
            return "/pms-agent/";
        }
        try {
            if (redirect.contains("\\") || redirect.startsWith("//")) {
                return "/pms-agent/";
            }
            String decoded = URLDecoder.decode(redirect, "UTF-8");
            if (decoded.contains("\\") || decoded.startsWith("//")) {
                return "/pms-agent/";
            }
            URI uri = URI.create(decoded).normalize();
            if (uri.isAbsolute() || uri.getRawAuthority() != null) {
                return "/pms-agent/";
            }
            String path = uri.getPath();
            if ("/pms-agent".equals(path) || path.startsWith("/pms-agent/")) {
                String query = uri.getRawQuery();
                return query == null ? path : path + "?" + query;
            }
            return "/pms-agent/";
        } catch (Exception ex) {
            return "/pms-agent/";
        }
    }

    private String getCookieValue(HttpServletRequest request, String name) {
        if (request.getCookies() == null) {
            return null;
        }
        for (Cookie cookie : request.getCookies()) {
            if (name.equals(cookie.getName())) {
                return cookie.getValue();
            }
        }
        return null;
    }

    private void clearSsoCookie(HttpServletResponse response) {
        response.addHeader("Set-Cookie", SSO_COOKIE_NAME + "="
                + "; Path=/"
                + "; Max-Age=0"
                + "; HttpOnly"
                + "; SameSite=Lax");
    }
}
```

### 7.3 ExternalSsoTokenVerifier 示例

```java
@Service
public class ExternalSsoTokenVerifier {

    private static final int GCM_TAG_LENGTH = 128;
    private static final long MAX_CLOCK_SKEW_MILLIS = 60_000L;
    private static final long MAX_TOKEN_LIFETIME_MILLIS = 120_000L;

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final TrustedIssuerProperties trustedIssuerProperties;
    private final UsedSsoTokenService usedSsoTokenService;

    public ExternalSsoTokenVerifier(TrustedIssuerProperties trustedIssuerProperties,
                                    UsedSsoTokenService usedSsoTokenService) {
        this.trustedIssuerProperties = trustedIssuerProperties;
        this.usedSsoTokenService = usedSsoTokenService;
    }

    public ExternalSsoUser verify(String token) {
        try {
            String[] parts = token.split("\\.");
            if (parts.length != 5 || !"v1".equals(parts[0])) {
                throw new RuntimeException("单点登录失败，Token 格式错误");
            }

            String issuer = parts[1];
            String kid = parts[2];
            String aesKey = trustedIssuerProperties.getAesKey(issuer, kid);
            if (aesKey == null || aesKey.trim().isEmpty()) {
                throw new RuntimeException("单点登录失败，未信任的来源系统或密钥版本：" + issuer + "/" + kid);
            }

            Base64.Decoder decoder = Base64.getUrlDecoder();
            byte[] iv = decoder.decode(parts[3]);
            byte[] encrypted = decoder.decode(parts[4]);

            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            SecretKeySpec keySpec = new SecretKeySpec(decodeKey(aesKey), "AES");
            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec);
            cipher.updateAAD(buildAad(issuer, kid, "pms-agent"));
            byte[] plaintext = cipher.doFinal(encrypted);

            Map<String, Object> payload = objectMapper.readValue(
                    plaintext,
                    new TypeReference<Map<String, Object>>() {}
            );
            verifyPayload(payload, issuer, kid);

            ExternalSsoUser user = new ExternalSsoUser();
            user.setIssuer(issuer);
            user.setUsername((String) payload.get("username"));
            user.setTokenId((String) payload.get("jti"));
            return user;
        } catch (Exception ex) {
            throw new RuntimeException("单点登录失败，Token 解密或校验失败", ex);
        }
    }

    private void verifyPayload(Map<String, Object> payload, String issuer, String kid) {
        if (!issuer.equals(payload.get("iss"))) {
            throw new RuntimeException("单点登录失败，Token 来源系统不匹配");
        }
        if (!kid.equals(payload.get("kid"))) {
            throw new RuntimeException("单点登录失败，Token 密钥版本不匹配");
        }
        if (!"pms-agent".equals(payload.get("aud"))) {
            throw new RuntimeException("单点登录失败，Token 接收方错误");
        }
        String username = (String) payload.get("username");
        if (username == null || username.trim().isEmpty()) {
            throw new RuntimeException("单点登录失败，Token 缺少用户名");
        }
        String jti = (String) payload.get("jti");
        if (jti == null || jti.trim().isEmpty()) {
            throw new RuntimeException("单点登录失败，Token 缺少一次性标识");
        }
        long now = System.currentTimeMillis();
        long iat = ((Number) payload.get("iat")).longValue();
        long exp = ((Number) payload.get("exp")).longValue();
        if (iat > now + MAX_CLOCK_SKEW_MILLIS) {
            throw new RuntimeException("单点登录失败，Token 签发时间异常");
        }
        if (exp <= iat || exp - iat > MAX_TOKEN_LIFETIME_MILLIS + MAX_CLOCK_SKEW_MILLIS) {
            throw new RuntimeException("单点登录失败，Token 有效期异常");
        }
        if (exp < now) {
            throw new RuntimeException("单点登录失败，Token 已过期");
        }
        if (!usedSsoTokenService.markUsed(issuer, jti, exp)) {
            throw new RuntimeException("单点登录失败，Token 已被使用");
        }
    }

    private byte[] decodeKey(String value) {
        byte[] key = Base64.getDecoder().decode(value);
        if (key.length != 32) {
            throw new IllegalArgumentException("AES 密钥必须是 Base64 编码的 32 字节随机值");
        }
        return key;
    }

    private byte[] buildAad(String issuer, String kid, String audience) {
        return ("v1." + issuer + "." + kid + "." + audience).getBytes(StandardCharsets.UTF_8);
    }
}
```

说明：

- `ExternalSsoUser` 可以是目标项目中的普通 DTO，不需要绑定数据库实体。
- `UsedSsoTokenService.markUsed(issuer, jti, exp)` 必须是原子操作，建议用 Redis `SETNX` 并设置过期时间到 `exp` 之后；同一个 `issuer + jti` 第二次出现必须拒绝登录。

### 7.4 目标系统用户匹配

`TargetUserService` 只表达一件事：把已解密、已校验的 SSO 用户信息匹配成 `pms-agent` 自己的用户。

匹配逻辑建议：

- 只按 `username` 匹配目标系统用户。
- 如果未来不同来源系统的同名账号可能冲突，再扩展为 `issuer + username` 映射。

具体用户实体、仓储、SQL、表结构不在本文档展开，由目标系统自行实现或从现有系统复制调整。

## 8. 如何触发单点登录

### 8.1 来源系统菜单触发

来源系统页面中的按钮或菜单不要直接跳 `/pms-agent`，而是跳本系统自己的出站 SSO 入口。

通用 HTML 示例：

```html
<a href="/{source-context}/auth/out-sso/pms-agent">进入智能体系统</a>
```

通用 JS 示例：

```javascript
window.location.href = '/{source-context}/auth/out-sso/pms-agent';
```

示例：

```html
<a href="/union-js/auth/out-sso/pms-agent">从 union-js 进入智能体系统</a>
<a href="/portal/auth/out-sso/pms-agent">从 portal 进入智能体系统</a>
```

### 8.2 目标系统未登录回跳

如果用户直接访问 `/pms-agent`，目标系统发现自己未登录，可以引导用户选择来源系统，或按默认来源系统回跳：

```text
/{source-context}/auth/out-sso/pms-agent?redirect=/pms-agent/原始访问路径
```

来源系统写入 Cookie 后，再跳回：

```text
/pms-agent/auth/external-sso?redirect=/pms-agent/原始访问路径
```

`pms-agent` 完成登录后跳回用户原本要访问的地址。

如果存在多个来源系统，不建议 `pms-agent` 盲目猜测用户来自哪里。可以提供一个来源系统选择页，或根据入口链接、租户、Referer、网关规则确定默认来源。

## 9. 安全约束

- `pms_agent_sso_token` 不能是明文 JSON。
- `pms_agent_sso_token` 不能只做 Base64 编码。
- 来源系统必须使用 `AES/GCM/NoPadding` 加密 Token 载荷。
- AES 密钥必须是 32 字节随机密钥，不要使用短口令直接当密钥。
- AES-GCM 必须使用 12 字节随机 IV，每次签发 Token 都必须重新生成 IV。
- AES-GCM 必须使用 AAD 绑定 `v1.{issuer}.{kid}.pms-agent`。
- `pms-agent` 必须成功解密后才能信任 Token 内容。
- `pms-agent` 必须校验载荷中的 `iss`、`kid` 与 Cookie 外层一致。
- `aud` 必须固定校验为 `pms-agent`。
- `issuer` 必须在 `pms-agent` 白名单内。
- `kid` 必须在对应 `issuer` 的密钥配置内。
- Token 有效期建议 60 到 120 秒。
- `iat` 不能晚于当前服务器时间超过允许时钟偏移，建议偏移不超过 60 秒。
- `pms-agent` 必须校验 `exp - iat` 不超过目标系统配置的最大有效期加允许时钟偏移，不能只相信来源系统传入的 `exp`。
- Cookie 必须 `Path=/`。
- Cookie 建议 `SameSite=Lax`。
- Spring Boot 2.2 原生 `Cookie` API 不支持 SameSite 时，必须使用 `Set-Cookie` 响应头显式写入 `SameSite=Lax`，清理 Cookie 时也要保持相同属性。
- 生产 HTTPS 环境 Cookie 应设置 `Secure=true`。
- `HttpOnly` 不是加密，只是减少浏览器脚本读取风险。
- SSO 成功后，`pms-agent` 必须清理 `pms_agent_sso_token`。
- `pms-agent` 必须建立自己的登录态，不得把 SSO Token 当长期 session。
- `redirect` 必须先 URL decode、拒绝反斜杠和 `//`、执行 URI normalize，再限制为 `/pms-agent` 路径树内的相对路径；不能允许完整外部 URL、路径穿越或规范化后逃逸出 `/pms-agent`。
- 密钥不得写死在前端，不得进入日志，不得提交真实生产密钥。
- `pms-agent` 必须在 Redis 中短期记录已使用的 `issuer + jti`，同一个 SSO Token 只能消费一次。

## 10. 最小接入清单

来源系统需要具备：

- 能读取当前已登录用户的 `username`。
- 能生成 AES-GCM 加密 Token。
- 能写 Cookie `pms_agent_sso_token`。
- 能把 Cookie 设置为 `Path=/`。
- 能把 Cookie 设置为 `SameSite=Lax`。
- 能重定向到 `/pms-agent/auth/external-sso`。
- 能和 `pms-agent` 约定自己的 `issuer`、`kid` 和 AES 密钥。

`pms-agent` 需要具备：

- 能读取 Cookie `pms_agent_sso_token`。
- 能维护可信来源系统白名单。
- 能按 `issuer + kid` 选择对应 AES 密钥。
- 能用 AAD 解密 Token 并校验 `iss`、`kid`、`aud=pms-agent`、`iat`、`exp`。
- 能校验 `exp - iat` 不超过目标系统允许的最大 Token 生命周期。
- 能用 Redis 原子记录 `issuer + jti`，保证 Token 只能消费一次。
- 能通过 `username` 匹配用户。
- 能建立自己的登录态。
- 能处理 Cookie 缺失、Token 格式错误、解密失败、Token 过期、签发时间异常、Token 重放、来源未信任、密钥版本不存在、用户不存在九类异常。

## 11. 验证清单

- 用户从来源系统点击入口时，必须先访问来源系统的出站 SSO 入口。
- 来源系统响应必须写入 `pms_agent_sso_token`。
- `pms_agent_sso_token` 必须是 `Path=/`。
- `pms_agent_sso_token` 建议是 `SameSite=Lax`。
- 浏览器请求 `/pms-agent/auth/external-sso` 时必须携带 `pms_agent_sso_token`。
- Cookie 值中不能看到明文 `username`。
- Cookie 值中不能看到工号、姓名、组织等个人信息。
- Cookie 外层 `issuer`、`kid` 任意一个被修改后，`pms-agent` 必须拒绝登录。
- 修改 Token 任意字符后，`pms-agent` 必须拒绝登录。
- Token 过期后，`pms-agent` 必须拒绝登录。
- Token `iat` 晚于服务器当前时间超过允许偏移时，`pms-agent` 必须拒绝登录。
- Token `exp - iat` 超过目标系统最大有效期时，`pms-agent` 必须拒绝登录。
- 同一个 Token 第二次提交时，`pms-agent` 必须拒绝登录。
- `aud` 不是 `pms-agent` 时，`pms-agent` 必须拒绝登录。
- `issuer` 未配置在白名单时，`pms-agent` 必须拒绝登录。
- `kid` 未配置在对应 `issuer` 下时，`pms-agent` 必须拒绝登录。
- 用户在 `pms-agent` 匹配成功后，必须建立 `pms-agent` 自己的登录态。
- `pms-agent` 登录成功后应清理 `pms_agent_sso_token`。
- `redirect=/pms-agent/../other`、`redirect=/pms-agent/%2e%2e/other`、`redirect=//evil.example` 必须被拒绝或回退到 `/pms-agent/`。

## 12. 最后建议

最终推荐方案是把 `pms-agent` 做成统一接收方：

- 所有来源系统按统一协议写短期 AES-GCM 加密 Cookie。
- Cookie 名统一为 `pms_agent_sso_token`。
- Cookie `Path=/`。
- Cookie `SameSite=Lax`。
- Cookie 中只加密保存 `username` 和必要校验元数据。
- 浏览器统一跳转 `/pms-agent/auth/external-sso`。
- `pms-agent` 根据 `issuer + kid` 识别来源系统和密钥版本，使用 AAD 解密。
- `pms-agent` 必须用 Redis 原子消费 `jti`，防止同一个 SSO Token 重放。
- `pms-agent` 只用 Token 完成一次登录交换，然后立即建立自己的登录态。

这样后续不管来源系统是 `union-js`、门户、OA，还是其他业务系统，都可以用同一套方式接入 `pms-agent`，不会把目标系统绑定到某一个现有系统的 Cookie、Redis 或代码结构上，也不会在 Cookie 中暴露用户名以外的用户资料。
