# 盛迪嘉单点登录PHP SDK **Repository Path**: lushaoming/sdjgroup-sso ## Basic Information - **Project Name**: 盛迪嘉单点登录PHP SDK - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-08 - **Last Updated**: 2026-10-12 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # SSO PHP 客户端 [![Latest Version on Packagist](https://img.shields.io/packagist/v/sdjgroup/sso-php.svg?style=flat-square)](https://packagist.org/packages/sdjgroup/sso-php) [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) 面向 **SSO 统一登录服务** 的 PHP SDK。把 OAuth2「授权码模式」封装成三行接入代码, 内置 CSRF 防护与结构化错误处理,不依赖任何第三方库。 > English documentation: [README.md](README.md) ## 特性 - **三行接入** —— 发起授权、处理回调、拿到用户,就这三步。 - **默认安全** —— 自动生成并校验 `state`(校验不通过直接失败),`client_secret` 只留在服务端。 - **错误可读** —— 所有异常都继承 `SsoException`;业务错误带原始错误码、HTTP 状态码与 `SsoError` 枚举。 - **最小权限** —— 按需申请 `base_info` / `account` / `email`;超出应用已登记范围的申请会被服务端直接拒绝。 - **零依赖** —— 不需要 Guzzle 或任何 PSR 包,只要 `ext-curl` 与 `ext-json`。 - **不绑定框架** —— 原生 PHP、Laravel、ThinkPHP、Symfony、Hyperf 都能用;HTTP 传输、state 存储、令牌缓存都是可替换接口。 ## 环境要求 | 项目 | 版本 | |---|---| | PHP | `>= 8.2` | | ext-curl | 任意 | | ext-json | 任意 | ## 安装 ```bash composer require sdjgroup/sso-php ``` 接入前需要先向 SSO 管理员申请应用,拿到 `client_id`、`client_secret`,并登记准确的 回调地址 `redirect_uri`。 ## 快速开始 ```php use Sdjgroup\Sso\SsoClient; $sso = SsoClient::create( host: 'https://sso.example.com', clientId: '5f8a1c...', clientSecret: 'b7e4d9...', // 只放服务端,绝不下发到浏览器 redirectUri: 'https://app.example.com/sso/callback', ); // 1. 用户点「SSO 登录」——把浏览器送到授权页 session_start(); header('Location: ' . $sso->buildAuthorizeUrl()); // state 自动生成并保存 exit; // 需要默认资料之外的权限时,显式传入: // $sso->buildAuthorizeUrl(scopes: [SsoClient::SCOPE_BASE_INFO, SsoClient::SCOPE_EMAIL]) // 2. 回调路由——校验 state、换取用户信息 $user = $sso->handleCallback($_GET); // 3. 用稳定的 openid 关联你本地的账号 $account = Account::firstOrCreate( ['sso_openid' => $user->openid], ['nickname' => $user->nickname], ); ``` `handleCallback()` 一步完成整条服务端链路: ``` code ──POST /sso/openid──▶ openid ──GET /sso/userinfo──▶ 用户资料 ▲ └── Authorization: Bearer (POST /sso/token,自动缓存) ``` ## 授权流程 ``` 用户 你的应用前端 你的应用后端 SSO 服务 │ 点击登录 │ │ │ │──────────────▶│ ① 302 /authorize │ │ │───────────────────────────────────────▶│ │ ② 在 SSO 授权页登录并确认授权 │ │◀──────────────────────────────────────────────────────┤ │ ③ 302 跳回 redirect_uri?code=...&state=... │ │──────────────▶│ ④ │ │ │ │─────────────────▶│ │ │ │ ⑤ POST /sso/token │ │ │ (client_id + secret) │ │ │───────────────────────────────────────▶│ │ │ ⑥ POST /sso/openid(Bearer) │ │ │───────────────────────────────────────▶│ │ │ ⑦ GET /sso/userinfo │ │ │───────────────────────────────────────▶│ │ │◀───────────────────────────────────────│ │◀──────────────┤ ⑧ 建立你自己的登录态 │ ``` ⑤⑥⑦ 必须在你自己的服务端完成。 ## API 参考 ### `SsoClient` 工厂方法(最简用法): ```php $sso = SsoClient::create( host: 'https://sso.example.com', clientId: '...', clientSecret: '...', redirectUri: 'https://app.example.com/sso/callback', timeout: 10, // 可选,单位秒 ); ``` 需要完全控制依赖时: ```php $sso = new SsoClient( new Config('https://sso.example.com', $clientId, $clientSecret, $redirectUri), new CurlHttpClient(timeout: 10), // ?HttpClientInterface new ArrayStateStore(), // ?StateStoreInterface new MemoryTokenCache(), // ?TokenCacheInterface ); ``` | 方法 | 说明 | |---|---| | `buildAuthorizeUrl(?string $state = null, ?array $scopes = null): string` | 生成授权页 URL。传 `null`(默认)自动生成随机 state 并保存、附带 —— 推荐;传 `''` 表示不使用 state。`$scopes` 为所需权限列表(见 [权限](#权限scopes));不传则只申请默认的 `base_info`。 | | `handleCallback(array $query, bool $verifyState = true): SsoUser` | 校验 state、换取授权码、返回用户资料。回调路由用这一个方法就够。 | | `getUserFromCode(string $code): SsoUser` | 不带 state 处理的完整换取(`code` → `openid` → 资料)。 | | `getOpenid(string $code): string` | 一次性授权码换 openid。 | | `getUserInfo(string $openid): SsoUser` | 用 openid 取用户资料。 | | `getAccessToken(bool $forceRefresh = false): AccessToken` | 取可用的访问令牌(优先用缓存)。 | | `requestAccessToken(): AccessToken` | 强制重新获取令牌并写入缓存。 | | `generateState(): string` | 生成不可猜测的 state(32 位十六进制)。 | | `verifyState(?string $state): bool` | 校验**并消费** state(一次性)。 | | `config(): Config` | 取配置对象。 | ### 值对象 **`AccessToken`** —— `token`、`tokenType`、`expiresIn`、`issuedAt`,以及 `expiresAt()`、`isExpired(?int $now, int $leeway = 60)`、`remainingSeconds()`、 `isBearer()`、`authorizationHeader()`。 **`SsoUser`** —— `openid`、`nickname`、`avatar`、`type`,以及在获得对应权限时才有值的 `username`、`email`;方法有 `isAdmin()`、`isSuperAdmin()`、`hasUsername()`、`hasEmail()`、 `displayName()`(保证非空)、`toArray()`。 `type`:`0` 普通用户 / `1` 普通管理员 / `2` 超级管理员。 > `openid` 始终返回;`nickname` / `avatar` / `type` 需 `base_info` 权限,`username` 需 `account` 权限, > `email` 需 `email` 权限。未获得授权的字段为 `null`,且不会出现在 `toArray()` 中。 ### 异常 所有异常都继承 `Sdjgroup\Sso\Exception\SsoException`: | 异常 | 触发场景 | |---|---| | `SsoApiException` | 服务端返回业务错误(`code != 0`)。 | | `SsoHttpException` | HTTP 传输失败(DNS、超时、TLS、响应异常)。 | | `SsoStateMismatchException` | 回调缺少 `state` 或校验不通过。 | | `SsoException` | 通用失败(响应结构异常、缺少 `code` 等)。 | | `InvalidArgumentException` | 编码错误:参数为空、配置非法、state 超长。 | ```php try { $user = $sso->handleCallback($_GET); } catch (SsoApiException $e) { $e->getErrorCode(); // 43006 $e->getHttpStatus(); // 400 $e->error(); // SsoError::CodeInvalid(未知码为 null) $e->getMessage(); // 服务端返回的提示 $e->getResponse(); // 原始响应体 $e->isRetryable(); // 50000 时为 true $e->requiresReauthorization(); // 43006 / 43008 时为 true } ``` ### 错误码 | code | HTTP | 含义 | 处理建议 | |---|---|---|---| | `43001` | 400 | 应用不存在或已停用 | 核对 `client_id`,联系 SSO 管理员 | | `43002` | 400 | 回调地址不匹配 | 与登记值比对(协议/主机/端口/路径) | | `43003` | 400 | 响应类型不支持 | 固定使用 `code` | | `43004` | 401 | 应用认证失败 | 核对 `client_id` / `client_secret`,记录日志并告警 | | `43005` | 401 | 访问令牌无效或已过期 | SDK 会自动刷新并重试一次 | | `43006` | 400 | 授权码无效、过期或已使用 | 引导用户重新发起授权 | | `43007` | 404 | openid 不存在或用户被禁用 | 拒绝本次登录 | | `43008` | 401 | SSO 登录状态已失效 | 引导用户重新发起授权 | | `43009` | 400 | 申请的权限超出应用允许范围 | 把 `$scopes` 收敛到已登记范围,或请 SSO 管理员开通 | | `42200` | 422 | 参数校验失败 | 检查传入参数 | | `50000` | 500 | 服务端异常 | 稍后重试 | ## 权限(scopes) 每个应用在 SSO 侧登记了「允许申请的权限集合」。`buildAuthorizeUrl()` 会把本次申请的权限 作为 `scopes` 查询参数带上;用户在授权页确认后,`userinfo` 只会返回这些权限覆盖的字段。 | 常量 | 值 | 授予的字段 | |---|---|---| | `SsoClient::SCOPE_BASE_INFO` | `base_info` | 基础信息:`nickname`、`avatar`、`type` | | `SsoClient::SCOPE_ACCOUNT` | `account` | 账号:`username` | | `SsoClient::SCOPE_EMAIL` | `email` | 邮箱:`email` | ```php use Sdjgroup\Sso\SsoClient; $url = $sso->buildAuthorizeUrl( scopes: [SsoClient::SCOPE_BASE_INFO, SsoClient::SCOPE_EMAIL], ); // …/authorize?client_id=…&redirect_uri=…&response_type=code&scopes=base_info%2Cemail ``` 需要留意的几条规则: - **按需最小化。** 只在真正用到时才申请。 - **默认是 `base_info`。** 不传 `$scopes` 时只申请 `base_info`。 - **只能是已登记权限的子集。** 申请未登记的能力会在用户被询问之前就返回 `43009` / HTTP 400。 - **以最近一次授权为准。** 用更小的权限集重新授权会**替换**该用户已有的权限集,而不是累加 —— 不再申请的字段也随之停止返回。 - **未授权的字段为 `null`。** 若「已授权但值为空」与「未授权」需要区分,请用 `hasUsername()` / `hasEmail()` 判断,而不是判空。 ## 进阶用法 ### 只用其中某几步 ```php $openid = $sso->getOpenid($_GET['code']); $user = $sso->getUserInfo($openid); ``` ### 替换 HTTP 传输(Guzzle / PSR-18 / 测试替身) ```php use Sdjgroup\Sso\Http\HttpClientInterface; use Sdjgroup\Sso\Http\HttpResponse; final class GuzzleHttpClient implements HttpClientInterface { public function __construct(private \GuzzleHttp\ClientInterface $client) {} public function request(string $method, string $url, array $headers = [], ?array $json = null): HttpResponse { $response = $this->client->request($method, $url, [ 'headers' => $headers, 'json' => $json, 'http_errors' => false, // 4xx/5xx 交给 SDK 解释 ]); return new HttpResponse($response->getStatusCode(), (string) $response->getBody()); } } ``` ### 替换 state 存储 默认的 `NativeSessionStateStore` 依赖 `session_start()`。在 Swoole/Hyperf 环境下, 或想用 Redis 时,自行实现接口: ```php final class RedisStateStore implements StateStoreInterface { public function __construct(private \Redis $redis) {} public function put(string $state, int $ttlSeconds): void { $this->redis->setex('sso:state:' . $state, $ttlSeconds, '1'); } public function pull(string $state): bool { return (bool) $this->redis->del('sso:state:' . $state); } } ``` ### 跨请求复用访问令牌 ```php final class CacheTokenCache implements TokenCacheInterface { public function __construct(private \Psr\SimpleCache\CacheInterface $cache) {} public function get(string $key): ?AccessToken { $data = $this->cache->get($key); return is_array($data) ? AccessToken::fromArray($data) : null; } public function set(string $key, AccessToken $token): void { $this->cache->set($key, [ 'access_token' => $token->token, 'token_type' => $token->tokenType, 'expires_in' => $token->expiresIn, 'issued_at' => $token->issuedAt, ], $token->remainingSeconds()); } public function delete(string $key): void { $this->cache->delete($key); } } ``` ### Laravel 集成 ```php // config/services.php 'sso' => [ 'host' => env('SSO_HOST'), 'client_id' => env('SSO_CLIENT_ID'), 'client_secret' => env('SSO_CLIENT_SECRET'), 'redirect_uri' => env('SSO_REDIRECT_URI'), ], // AppServiceProvider::register() $this->app->singleton(SsoClient::class, fn () => new SsoClient( Config::fromArray(config('services.sso')), null, new LaravelSessionStateStore(), new LaravelTokenCache(Cache::store()), )); ``` ```php // routes/web.php Route::get('/sso/login', fn (SsoClient $sso) => redirect()->away($sso->buildAuthorizeUrl())); Route::get('/sso/callback', function (SsoClient $sso) { $user = $sso->handleCallback(request()->query()); auth()->login(Account::firstOrCreate( ['sso_openid' => $user->openid], ['nickname' => $user->nickname], )); return redirect('/'); }); ``` 基于 session 门面的 state 存储: ```php final class LaravelSessionStateStore implements StateStoreInterface { public function put(string $state, int $ttlSeconds): void { session()->put('sso.states.' . $state, time() + $ttlSeconds); } public function pull(string $state): bool { $expiresAt = session()->pull('sso.states.' . $state); return is_int($expiresAt) && $expiresAt > time(); } } ``` ## 安全建议 1. **`client_secret` 绝不进浏览器** —— 用环境变量注入。 2. **保持 state 校验开启**(默认行为)。只有在明确不使用 state 的流程里才传 `verifyState: false`。 3. **授权码一次性、5 分钟有效** —— 拿到后立刻换取。 4. **不要把 401 / 5xx 原文直接展示给用户** —— 记录日志,给友好提示。 5. **生产环境全链路 HTTPS**。 6. **回调地址尽量精确** —— 具体到路径,不要用通配。 ## 测试 ```bash composer test # PHPUnit composer analyse # PHPStan,level 8 composer cs-check # 代码风格(PSR-12) composer check # 以上全部 ``` ## 许可 MIT,详见 [LICENSE](LICENSE)。