pohoc / sa-token
一个轻量级 PHP 权限认证框架,灵感源自 sa-token
Requires
- php: ^8.2
- ext-openssl: *
- firebase/php-jwt: ^7.0
- pohoc/crypto-sm: ^0.3
- psr/http-message: ^2.0
- psr/simple-cache: ^3.0
Requires (Dev)
- php-cs-fixer/shim: ^3.94
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.0
Suggests
- ext-gmp: 可选:国密算法(SM2/SM3/SM4)所需
- ext-redis: 可选:使用 Redis 存储适配器时所需
Provides
None
Conflicts
None
Replaces
None
README
适用于 PHP 生态的轻量级权限认证框架,灵感源自 sa-token。
致谢与声明:本项目参考了 Java 生态 dromara/sa-token(Apache-2.0)的 API 设计理念, 代码为独立 PHP 实现。感谢原项目作者与社区。
未发布版本升级提示:当前分支包含安全加固和兼容性收紧,最低 PHP 版本为 8.2(详见 CHANGELOG.md): OAuth2 换 token 必须携带 redirect_uri、SSO 回调强制校验 state、机密 OAuth2 客户端必须配置高熵 secret(公开客户端仅能使用授权码 + PKCE)、
SaSign强制 timestamp/nonce 且移除 MD5、加密插件密文格式升级(AES 旧格式兼容读取;安全敏感路径拒绝旧版无密钥 SM3 摘要)。升级前请阅读变更日志。
存储完整性说明:安全敏感读取会拒绝 MAC 校验失败的密文。旧版 SM4 存储使用无密钥 SM3 摘要,无法验证篡改;启用 SM 模式升级后,应重新登录并清理旧版持久化会话和 OAuth2 令牌。
特性一览
- 登录认证 — 单端/多端登录、同端互斥登录、记住我、踢人下线、账号封禁、临时 Token
- 权限认证 — 角色/权限校验、路由拦截鉴权、二级认证、身份切换
- Token 安全 — Token 内容加密(AES-256-CBC / SM4-CBC)、防内容泄露
- Refresh Token — AccessToken + RefreshToken 双 Token 机制、Token 轮换、无感刷新
- 国密全链路 —
cryptoType=sm时自动切换 SM4 加密 + HMAC-SM3 签名 + SM3 JWT - SSO 单点登录 — 同域 / 跨域 / 前后端分离 / 无 SDK 四种模式,域名校验,参数防丢
- OAuth2.0 — 授权码 / 隐藏式 / 密码 / 客户端凭证 + OpenID Connect + Scope 校验
- JWT 集成 — 扩展参数、无状态模式、混合模式、HS256 / HMAC-SM3 双算法
- Http 认证 — Basic / Digest 一行代码接入
- 参数签名 — 跨系统 API 调用签名校验,防篡改、防重放
- API Key — 第三方接入秘钥授权
- 全局过滤器 — CORS、安全响应头、前后置过滤器
- 持久层 — 内存 / 文件 / Redis / PSR-16 任意适配,配置驱动自动装配,独立 Redis 分离
- 密码加密 — MD5 / SHA1 / SHA256 / HMAC / bcrypt
- 多账号体系 — 不同 type 的 StpLogic 实例独立鉴权
- 协程安全 — SaRouter 支持协程上下文隔离(Swoole / Hyperf)
- 框架无关 — 纯 PHP 实现,PSR-7 / PSR-16 适配,适用于任意框架
- 防暴力破解 — 登录失败计数、账号自动锁定、手动解锁
- IP 异常检测 — 登录 IP 历史记录、异地登录告警
- 设备管理 — 登录设备注册/踢出、UA 自动解析
- 敏感操作验证 — OTP 验证码、安全令牌、场景化验证
- 审计日志 — 登录/登出/踢人/封禁/身份切换全链路记录
- RPC 上下文 — 微服务间 Token 透传与验证、拦截器模式
- 健康检查工具 — DAO 连接、配置项、Token 状态一键检查
- 性能指标收集 — 登录/登出/鉴权/查询性能指标
- Token 指纹绑定 — IP + User-Agent 哈希绑定,防止 Token 盗用
- Token 黑名单 — 手动拉黑 Token,无需等待过期
- 配置构建器 — 链式调用 API 配置 SaToken
环境要求
- PHP >= 8.2
- ext-openssl
- ext-redis(可选:使用 Redis 存储适配器时需要)
安装
composer require pohoc/sa-token
快速开始
1. 配置
在项目根目录创建 config/sa_token.php:
return [ 'tokenName' => 'satoken', 'timeout' => 86400, 'activityTimeout' => -1, 'isReadHeader' => true, 'isReadCookie' => true, 'isReadBody' => false, 'isWriteCookie' => true, 'isWriteHeader' => false, 'concurrent' => true, 'isShare' => true, 'maxLoginCount' => 12, 'cryptoType' => 'intl', ];
完整配置项参见 配置参考。
2. 初始化
use SaToken\SaToken; SaToken::init(); SaToken::init([ 'tokenName' => 'my-token', 'timeout' => 7200, ]);
使用配置构建器(推荐)
链式调用 API 配置 SaToken,更具可读性和类型安全:
use SaToken\Config\SaTokenConfigBuilder; $config = (new SaTokenConfigBuilder()) ->tokenName('my-token') ->timeout(7200) ->activityTimeout(1800) ->concurrent(true) ->maxLoginCount(12) ->tokenEncrypt(true) ->cryptoType('intl') ->signAlg('sha256') ->tokenFingerprint(true) ->build(); SaToken::init($config);
3. 健康检查与性能指标
一键检查 SaToken 状态,收集性能指标:
use SaToken\Util\SaHealthCheck; use SaToken\Util\SaMetrics; // 健康检查 $health = SaHealthCheck::checkAll(); // $health = [ // 'dao_connected' => true, // 'config_valid' => true, // 'token_test' => true, // 'overall' => 'healthy' // ] // 性能指标收集 // 在任意操作后 SaMetrics::recordLogin(10001, 1.2); // 登录耗时 ms SaMetrics::recordCheckLogin(0.05); // 鉴权耗时 ms SaMetrics::recordQuery(0.1); // 查询耗时 ms SaMetrics::recordDelete(0.08); // 删除操作耗时 ms // 获取统计数据 $stats = SaMetrics::getStats(); // $stats = [ // 'login_count' => 100, // 'login_avg_ms' => 1.1, // 'check_login_count' => 2000, // 'check_login_avg_ms' => 0.04, // ... // ] // 重置统计 SaMetrics::reset();
4. Token 安全增强
Token 指纹绑定(IP + User-Agent)
防止 Token 被盗用,绑定客户端特征:
// 配置开启 $config = (new SaTokenConfigBuilder()) ->tokenFingerprint(true) ->build(); // 登录时自动记录当前 IP + User-Agent $result = StpUtil::login(10001); // 访问时自动验证指纹,不匹配则强制重新登录 StpUtil::checkLogin(); // 指纹不匹配时抛出异常
该选项同时校验普通 Token 和无状态 JWT。开启后,缺少指纹记录的旧 Token 会按失效处理,需要重新登录;启用指纹绑定时会为无状态 JWT 额外保存每个 Token 的指纹状态。
Token 黑名单
手动拉黑 Token,无需等待过期:
// 拉黑指定 Token,有效期内无法使用 StpUtil::addToBlacklist($tokenValue, 3600); // 检查是否在黑名单 $isBlacklisted = StpUtil::isInBlacklist($tokenValue); // 从黑名单移除 StpUtil::removeFromBlacklist($tokenValue); // 查询所有黑名单 Token $tokens = StpUtil::getBlacklistTokens();
5. 登录认证
StpUtil::login() 只负责为已认证用户建立 Sa-Token 会话,不会校验账号密码。请先通过应用的用户认证逻辑确认身份,再把服务端查到的用户 ID 传入;不要直接使用请求提交的 userId。
use SaToken\StpUtil; // 此处假设上游登录流程已经验证了账号凭据。 $result = StpUtil::login(10001); $token = $result->getAccessToken(); $param = new \SaToken\SaLoginParameter(); $param->setDeviceType('PC')->setIsLastingCookie(true)->setTimeout(7200); $result = StpUtil::login(10001, $param); $token = $result->getAccessToken(); StpUtil::checkLogin(); $isLogin = StpUtil::isLogin(); $loginId = StpUtil::getLoginId(); $loginId = StpUtil::getLoginIdAsNotNull(); $tokenValue = StpUtil::getTokenValue(); $tokenInfo = StpUtil::getTokenInfo(); StpUtil::logout(); StpUtil::logoutByLoginId(10001);
6. 踢人下线
StpUtil::kickoutByTokenValue($tokenValue); StpUtil::kickout(10001);
logoutByLoginId()、kickout() 和 kickoutByTokenValue() 是管理操作,不会判断当前调用者是否有权注销目标账号或 Token。对外提供这些操作时,必须在应用层校验专用管理权限,并验证目标账号/Token 属于调用者可管理的范围;不要直接把请求参数传入这些方法。
5. 权限校验
实现 SaTokenActionInterface 提供权限/角色数据:
use SaToken\Action\SaTokenActionInterface; use SaToken\SaToken; class MyAction implements SaTokenActionInterface { public function getPermissionList(mixed $loginId, string $loginType): array { return ['user:add', 'user:delete', 'user:update']; } public function getRoleList(mixed $loginId, string $loginType): array { return ['admin', 'super-admin']; } public function generateTokenValue(mixed $loginId, string $loginType): ?string { return null; } } SaToken::setAction(new MyAction());
校验权限和角色:
StpUtil::checkPermission('user:add'); StpUtil::checkPermissionAnd(['user:add', 'user:delete']); StpUtil::checkPermissionOr(['user:add', 'user:delete']); $has = StpUtil::hasPermission('user:add'); StpUtil::checkRole('admin'); StpUtil::checkRoleAnd(['admin', 'super-admin']); StpUtil::checkRoleOr(['admin', 'super-admin']); $has = StpUtil::hasRole('admin');
6. 路由鉴权
use SaToken\SaRouter; use SaToken\StpUtil; SaRouter::match('/user/**')->check(fn() => StpUtil::checkLogin()); SaRouter::match('/admin/**', '/system/**')->check(fn() => StpUtil::checkRole('admin')); SaRouter::notMatch('/public/**')->check(fn() => StpUtil::checkLogin());
match() 和 notMatch() 每次调用都会设置新的匹配条件;公开路径排除规则应直接交给 notMatch(),不要再链式调用 match() 覆盖它。
通配符:** 匹配任意多级路径,* 匹配单级路径。
7. 账号封禁
StpUtil::disable(10001, 'comment'); StpUtil::disable(10001, 'comment', 3, 86400); $isDisable = StpUtil::isDisable(10001, 'comment'); StpUtil::checkDisable(10001, 'comment'); $level = StpUtil::getDisableLevel(10001, 'comment'); StpUtil::untieDisable(10001, 'comment');
封禁、解封及按 loginId 查询/操作会话或设备的 API 都是管理原语,本身不做调用者授权。应用必须先检查管理权限和目标范围,不能将请求提交的 loginId 直接传给这些方法。
8. 二级认证
StpUtil::openSafe(120); StpUtil::openSafe(300, 'payment'); StpUtil::checkSafe(); $isSafe = StpUtil::isSafe(); StpUtil::checkSafe('payment'); StpUtil::closeSafe(); StpUtil::closeSafe('payment');
9. 身份切换
switchTo() 会让当前 Token 后续读取的登录 ID、角色和权限切换为目标账号。它不会判断调用者是否有权模拟该账号;必须先在应用层检查专用管理权限,并确认目标账号处于允许范围内。不要将未经授权的用户输入直接传给 switchTo()。
StpUtil::checkPermission('user:impersonate'); StpUtil::switchTo(20002); $isSwitch = StpUtil::isSwitch(); StpUtil::endSwitch();
10. 会话管理
$session = StpUtil::getSession(); $session = StpUtil::getSessionByLoginId(10001); $tokenSession = StpUtil::getTokenSession(); $session->set('name', '张三'); $name = $session->get('name'); $name = $session->get('age', 18); $has = $session->has('name'); $session->delete('name'); $session->update(['key1' => 'v1', 'key2' => 'v2']); $data = $session->getDataMap(); $session->clear(); $session->destroy();
11. Token 管理
$timeout = StpUtil::getTokenTimeout(); StpUtil::renewTimeout(3600); $tempToken = StpUtil::createTempToken(10001, 600); $device = StpUtil::getLoginDeviceType(); $terminals = StpUtil::getTerminalListByLoginId(10001);
getTerminalListByLoginId() 返回终端元数据,不包含可用于认证的 Token。该方法和按 loginId 查询的设备管理接口不会验证调用者对目标账号的管理权限;面向用户提供时,应从已认证身份或服务端授权策略确定目标账号,并限制其可操作范围。
12. Refresh Token
AccessToken + RefreshToken 双 Token 机制,AccessToken 短期有效,RefreshToken 长期有效,用于无感刷新:
SaToken::init([ 'timeout' => 7200, // AccessToken 有效期 2 小时 'refreshToken' => true, // 启用 RefreshToken 'refreshTokenTimeout' => 2592000, // RefreshToken 有效期 30 天 'refreshTokenRotation' => true, // 刷新时轮换 RefreshToken ]);
登录时自动创建 RefreshToken(通过响应头 satoken-refresh 返回):
$result = StpUtil::login(10001); $accessToken = $result->getAccessToken(); $refreshToken = $result->getRefreshToken(); // refreshToken=true 时有值 $accessExpire = $result->getAccessExpire(); $refreshExpire = $result->getRefreshExpire();
手动创建 RefreshToken:
$refreshToken = StpUtil::createRefreshToken($accessToken);
AccessToken 过期后,用 RefreshToken 换取新 Token 对:
$result = StpUtil::refreshToken($refreshToken); $newAccessToken = $result->getAccessToken(); $newRefreshToken = $result->getRefreshToken(); // rotation=true 时有值
撤销 RefreshToken:
StpUtil::revokeRefreshToken($refreshToken); StpUtil::revokeRefreshTokenByAccessToken($accessToken);
查询 RefreshToken:
$isValid = StpUtil::isRefreshTokenValid($refreshToken); $refreshToken = StpUtil::getRefreshTokenByAccessToken($accessToken);
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
refreshToken |
bool | false |
是否启用 RefreshToken |
refreshTokenTimeout |
int | 2592000 |
RefreshToken 有效期(秒) |
refreshTokenRotation |
bool | true |
刷新时是否同时轮换 RefreshToken |
安全特性:
- RefreshToken 一次性使用,用完即销毁
- Rotation 模式下每次刷新生成新 RefreshToken,旧 Token 立即失效
- 刷新时检查账号封禁状态
- 登出自动清除关联的 RefreshToken
12.1 实际应用中的常见问题
以下配置在示例里可以直接运行,但在生产环境通常需要按部署形态重新评估:
| 场景 | 默认/常见写法 | 实际风险 | 建议 |
|---|---|---|---|
| 前后端分离、跨子域登录 | cookieSameSite='Strict' |
浏览器不会在跨站跳转、第三方回调、部分 iframe 场景携带 Cookie,表现为“明明登录了却丢会话” | 同站应用优先保留 Strict;跨站场景改为 None,同时开启 cookieSecure=true,并明确 cookieDomain |
| 多实例部署 | 内存 DAO / 单机配置直接上线 | 踢人下线、黑名单、RefreshToken、SSO、登录态同步会在不同节点之间失效 | 至少使用 Redis 或共享缓存;多节点场景不要依赖默认内存存储 |
| 移动网络、代理网络、企业出口 | 开启 tokenFingerprint=true |
IP 频繁变化、代理层统一出口、UA 被网关改写时会误判盗用,导致用户被频繁踢下线 | 只在高风险后台启用,或改为基于稳定设备标识/代理透传 IP 的策略 |
| RefreshToken 落地 | 刷新令牌直接暴露给前端脚本 | 一旦前端存储或日志泄漏,攻击者可长期续签 AccessToken | 浏览器端优先用 HttpOnly Cookie 承载 RefreshToken;若前端自行持有,必须配合轮换、失效回收和最短必要有效期 |
| RefreshToken 轮换 | 服务端返回新 RefreshToken,客户端异步更新 | 并发请求或旧令牌覆盖新令牌时会造成“刷新一次后全部失效” | 客户端原子更新 token 对;刷新接口避免并发调用;服务端日志中区分 AccessToken 失效和 Rotation 冲突 |
| 登录策略 | 沿用 concurrent=true、isShare=true |
无法满足“新登录挤掉旧登录”或“同设备唯一会话”等业务要求 | 明确产品语义后再组合 concurrent、isShare、maxLoginCount,不要直接沿用示例值 |
| Token 加密 | 仅开启 tokenEncrypt=true |
只能降低 DAO 中内容裸露风险,不能替代 HTTPS、密钥托管、日志脱敏 | 生产环境同时配置 aesKey/sm4Key 或 tokenEncryptKey,并做好密钥轮换与日志审计 |
| 多机时间差 | 默认 signTimestampGap=600 |
节点时钟漂移过大时会出现签名偶发失败 | 所有节点做 NTP 校时;按链路延迟调整 signTimestampGap,不要单纯无限放大 |
如果你的接入场景是 SSO、跨域前后端分离或多节点部署,建议先从 Cookie 策略、共享存储、RefreshToken 保存方式三项开始排查,而不是直接按快速开始配置上线。
12.2 框架接入约束
当前仓库提供的是框架无关核心库,不是 Laravel ServiceProvider / Symfony Bundle 这类原生框架插件。因此在实际接入时,需要明确遵守以下生命周期约束:
- 请求进入时完成
SaToken::init(...),并立刻调用SaTokenContext::setRequest(...) - 控制器或业务逻辑执行后,将响应对象回写到
SaTokenContext::setResponse(...) - 最终返回响应时,优先返回
SaTokenContext::getResponse(),因为登录、登出、RefreshToken 续签可能已经改写 Header / Cookie - 请求结束时执行
SaToken::clearContext(),避免常驻内存、协程或复用 worker 场景下的上下文串用
如果只注入 request,没有把 response 回写到 SaTokenContext,那么 isWriteCookie、isWriteHeader 和 satoken-refresh 响应头都可能失效。
12.3 当前验证状态
当前仓库已完成以下验证:
- PHPUnit 全量通过:
739tests /1578assertions /3skipped - PHPStan 通过:
No errors - PHP-CS-Fixer dry-run 通过:无待修复格式问题
- 已补真实 Redis 集成测试;在配置
REDIS_HOST/REDIS_PORT且安装ext-redis时会执行真实连通与 TTL 校验
建议在 CI 中至少保留三类检查:基础 PHP 版本矩阵、带 Redis 扩展和 Redis 服务容器的测试任务、phpstan + cs-check 质量门禁。
13. 多账号体系
use SaToken\StpLogic; use SaToken\SaToken; $adminLogic = new StpLogic('admin'); SaToken::registerStpLogic($adminLogic); $adminLogic = SaToken::getStpLogic('admin'); $adminLogic->login(10001); $adminLogic->checkLogin(); $adminLogic->logout();
14. 会话查询
use SaToken\TokenManager; $tokens = TokenManager::searchTokenValue('keyword', 0, 10); $sessionIds = TokenManager::searchSessionId('keyword', 0, 10); $tokenSessionIds = TokenManager::searchTokenSessionId('keyword', 0, 10);
Token 风格
支持 6 种内置风格 + 自定义生成策略:
| 风格 | 说明 | 示例 |
|---|---|---|
uuid |
标准 UUID(默认) | 623368f0-ae24-4f... |
simple-random |
32 位随机字符串 | a1b2c3d4e5f6... |
random-64 |
64 位随机字符串 | a1b2...64chars |
random-128 |
128 位随机字符串 | a1b2...128chars |
random-256 |
256 位随机字符串 | a1b2...256chars |
tiket |
20 位纯数字 | 837492017364... |
自定义生成策略:
class MyAction implements SaTokenActionInterface { public function generateTokenValue(mixed $loginId, string $loginType): ?string { return 'custom-' . $loginId . '-' . bin2hex(random_bytes(16)); } }
Token 内容加密
防止 Token 存储内容泄露,DAO 层透明加解密:
SaToken::init([ 'tokenEncrypt' => true, 'tokenEncryptKey' => 'your-secret-key-at-least-32-bytes', ]);
- 国际算法:AES-256-CBC + HMAC-SHA256
- 国密算法:SM4-CBC + HMAC-SM3(需
cryptoType => 'sm') - 安全敏感数据通过带密钥的 MAC 校验;旧版无密钥 SM3 摘要只保留在
decrypt()的迁移兼容路径,不会被内置认证、Session 和 OAuth2 读取接受。
国密全链路
配置 cryptoType => 'sm' 后,以下环节自动切换国密算法:
| 环节 | 国际算法 | 国密算法 |
|---|---|---|
| Token 内容加密 | AES-256-CBC | SM4-CBC |
| 加密签名 | HMAC-SHA256 | HMAC-SM3 |
| SSO 参数签名 | HMAC-SHA256 | HMAC-SM3 |
| JWT 签名 | HS256 | SM3 |
JWT 集成
基本用法
use SaToken\Plugin\SaTokenJwt; $jwt = new SaTokenJwt('your-secret-key'); $token = $jwt->createToken(10001, 'login'); $payload = $jwt->parseToken($token);
扩展参数
$token = $jwt->createToken(10001, 'login', null, [ 'role' => 'admin', 'dept' => 'IT', ]); $extra = $jwt->getExtraClaims($token);
无状态模式
Token 自包含所有信息,无需 DAO 存储:
SaToken::init([ // 通过密钥管理服务注入预生成的随机密钥,至少 32 字节 'jwtSecretKey' => getenv('SA_TOKEN_JWT_SECRET_KEY') ?: '', 'jwtStateless' => true, ]); $token = StpUtil::loginStateless(10001);
无状态 JWT 若启用正值 activityTimeout,会额外在 DAO 保存短期活动标记;标记缺失或超过活动窗口会使 Token 失效。
jwtStateless 控制无状态签发与校验;jwtMode 用于有状态 JWT,支持 simple(默认)和 mixed(同时校验签名 JWT 与 DAO 登录记录)。jwtMixed 不是有效配置项。
JWT Payload 仅签名、不加密,持有者可以解码读取其中的声明。不要把密码、密钥或其他机密写入 JWT;无状态 Token 不会自动携带服务端登录 Session 数据,需传入 createStatelessToken() 的显式 claims 也应仅包含可公开的数据。
SM3 签名
$jwt = new SaTokenJwt('your-secret-key', 'sm'); $token = $jwt->createToken(10001, 'login');
SSO 单点登录
配置
SaToken::init([ 'sso' => [ 'loginUrl' => 'https://sso.example.com/login', 'authUrl' => 'https://sso.example.com/auth', 'backUrl' => 'https://app.example.com/callback', 'checkTicketUrl' => 'https://sso.example.com/checkTicket', 'sloUrl' => 'https://sso.example.com/logout', 'mode' => 'cross-domain', 'clientId' => 'your-client-id', 'clientSecret' => 'replace-with-a-random-secret-of-at-least-32-bytes', 'allowDomains' => ['*.example.com', 'app.mycompany.cn'], 'checkState' => true, // 强制校验回调 state 防 CSRF(默认 true,不推荐关闭) 'crossRedis' => false, // 认证中心与客户端是否跨 Redis 部署 'crossRedisCheckUrl' => '', // 跨 Redis 模式的 check-ticket 端点,必须 HTTPS(localhost 豁免) ], ]);
四种模式
| 模式 | 适用场景 | 类 |
|---|---|---|
same-domain |
前端同域 + 后端同 Redis | SsoModeSameDomain |
cross-domain |
前端不同域 + 后端同 Redis | SsoModeCrossDomain |
front-separate |
前后端分离 | SsoModeFrontSeparate |
no-sdk |
认证中心对接未引入 SDK 的客户端 | SsoModeNoSdk |
使用
check-ticket 端点必须把请求中的 nonce 原样放入签名响应的 request_nonce 字段,且回显 ticket。认证中心可用 $sso->buildCheckTicketResponse($ticket, $loginId, $request['nonce']) 构建响应;客户端会将两者与本次请求比较,拒绝旧响应重放。
use SaToken\Sso\SaSsoManager; $sso = SaToken::getSsoManager(); $loginUrl = $sso->buildLoginUrl(); // $ticket、$redirect、$state 均来自回调请求参数; // $state 会与 buildLoginUrl 写入的 state Cookie 比对校验(checkState 默认开启,缺失/不匹配直接抛异常; // 确需关闭可在 SSO 配置中设置 'checkState' => false,不推荐) $loginId = $sso->doLoginCallback($ticket, $redirect, $state);
域名校验
配置 allowDomains 后,SSO 回调会校验 redirect 域名是否在白名单内,防止开放重定向攻击。支持通配符(如 *.example.com);该规则匹配其子域,不包含 example.com 根域,若需允许根域请单独添加。白名单不带端口时只允许 HTTPS 默认端口 443(本机开发地址仍可使用自定义端口);需要其他端口时显式配置,如 app.example.com:8443 或 *.example.com:8443。
SSO state 使用 __Host- 前缀的 Secure、HttpOnly、host-only Cookie,固定 Path=/ 且不设置 Domain,避免父域 Cookie 配置下被兄弟子域注入覆盖。SSO 回调应返回发起登录的同一主机;若跨子域回调,请在目标主机发起登录。该状态 Cookie 始终要求 HTTPS(浏览器对 localhost 开发地址有特殊处理)。
same-domain 模式必须为共享 Token 配置 Cookie Domain,因此该主域下的所有子域都属于同一信任边界;不要把 Cookie 共享给不受信任或可被用户托管的子域。无法信任全部子域时,请使用 cross-domain 或 front-separate 模式。此模式每次取身份都要求 X-CSRF-Token 请求头,并要求配置 signKey(或 tokenEncryptKey / aesKey)用于 HMAC 绑定;不会在缺少密钥时退化成可预测的纯哈希。前端从 __Host-sso_csrf_token Cookie 读取该值并放入请求头;该 Cookie 固定 Secure、host-only、Path=/,防止兄弟子域覆盖。
参数防丢
登录前 URL 参数自动保存,登录成功后精准回传:
// buildLoginUrl 只有一个参数,携带当前 URL 请通过模式处理器调用 $loginUrl = $sso->getModeHandler()->buildLoginUrl(null, $currentUrl); $result = $sso->getModeHandler()->doLoginCallbackWithRedirect($ticket, $state); $loginId = $result['loginId']; $redirect = $result['redirect'];
注意:restorePreLoginUrl 恢复登录前 URL 时会强制通过 allowDomains 白名单(未配置时返回空串),并要求预登录 URL Cookie 的 HMAC 签名有效。签名密钥依次使用至少 32 字节的 SSO clientSecret、全局 signKey、hmacKey、tokenEncryptKey 或 aesKey;未配置可用密钥时不保存或恢复预登录 URL,避免信任可被兄弟子域覆盖的 Cookie。
单点注销回调
// 认证中心侧:生成带签名的注销回调参数 $params = $sso->buildSloCallbackParams($loginId); // 客户端侧:处理注销回调 $sso->doSloCallback($loginId, $params);
doSloCallback 必须配置 clientSecret 并验签;参数须包含 sign、签名的 loginId 和一次性 nonce。同一注销回调在时间窗内只能处理一次,防止重放旧请求注销后续会话。
OAuth2.0
配置
SaToken::init([ // 签发 id_token(OpenID Connect)必需;仅服务端保存 RSA 私钥 // 将对应 RSA 公钥安全分发给客户端用于验签,最低 2048 位 'rsaPrivateKey' => getenv('SA_TOKEN_OIDC_RSA_PRIVATE_KEY') ?: '', 'oauth2' => [ 'grantTypes' => ['authorization_code'], 'codeTimeout' => 60, 'accessTokenTimeout' => 7200, 'refreshTokenTimeout' => 2592000, 'isNewRefreshToken' => false, 'openIdMode' => true, 'issuer' => 'https://auth.example.com', ], ]);
四种授权模式
| 模式 | 说明 |
|---|---|
authorization_code |
授权码模式(推荐;公开客户端必须使用 PKCE) |
implicit |
隐藏式(仅用于旧系统兼容,不建议新部署启用) |
password |
密码模式(旧式兼容模式;新部署应使用授权码 + PKCE) |
client_credentials |
客户端凭证模式 |
新部署只启用所需的 grant。尤其不要为了方便默认开启 password:它会把用户密码验证暴露在 Token 端点;旧系统确需使用时,必须显式配置用户凭据验证回调、来源限速和账户锁定策略。密码模式不会签发 OpenID Connect id_token。
访问令牌和 ID Token 必须经 grant 专用入口签发。SaOAuth2Handle::generateAccessToken() 与 generateIdToken() 是已弃用的低层 API,现在会拒绝直接签发;请使用授权码兑换、密码或客户端凭证流程,避免跳过客户端认证、授权码和 PKCE 校验。
使用
使用前必须先注册客户端(机密客户端 clientSecret 至少 32 字节;公开客户端可留空,但仅能使用授权码 + PKCE;redirectUris 必须 HTTPS 且在白名单内,grantTypes/scopes 为白名单):
use SaToken\OAuth2\Data\SaOAuth2Client; use SaToken\OAuth2\SaOAuth2Manager; $oauth2 = SaToken::getOAuth2Manager(); $oauth2->registerClient(new SaOAuth2Client([ 'clientId' => 'your-client-id', 'clientSecret' => 'replace-with-a-random-secret-of-at-least-32-bytes', 'redirectUris' => ['https://app.example.com/callback'], 'grantTypes' => ['authorization_code', 'refresh_token'], 'scopes' => ['user:read', 'user:write'], ])); // 注意:换 token 必须传与授权请求一致的 redirect_uri; // 请求 scope 不得超出注册白名单;grant_type 必须在客户端注册列表中 $code = $oauth2->generateAuthorizationCode($clientId, $loginId, $redirectUri, $scope); $accessToken = $oauth2->exchangeTokenByCode($code, $clientId, $clientSecret, $redirectUri);
PKCE
公开客户端(注册时不配置 clientSecret 且 grantTypes 仅 authorization_code)强制使用 PKCE:
// 第 5/6 参数为 codeChallenge/codeChallengeMethod,仅支持 S256,challenge 长度 43-128 字符 $code = $oauth2->generateAuthorizationCode($clientId, $loginId, $redirectUri, $scope, $codeChallenge, 'S256'); // 第 5 参数为 codeVerifier $accessToken = $oauth2->exchangeTokenByCode($code, $clientId, $clientSecret, $redirectUri, $codeVerifier);
OpenID Connect
配置 openIdMode => true 且请求 scope 必须包含 openid 时,响应中自动包含使用 RS256 签名的 id_token(依赖至少 2048 位的 rsaPrivateKey 配置)。私钥只留在签发端,并将对应公钥分发给客户端验签;不要把签名私钥或全局对称密钥交给客户端。issuer 必须配置为无凭据、无查询参数和片段的 HTTPS URL:
$accessToken = $oauth2->exchangeTokenByCode($code, $clientId, $clientSecret, $redirectUri); $idToken = $accessToken->getIdToken();
Scope 校验
$oauth2->checkScopeAndThrow($accessTokenValue, 'user:read'); $hasScope = $oauth2->hasScope($accessTokenValue, 'user:write');
Http Basic / Digest 认证
认证回调必须查询应用的用户存储并验证密码,不能使用示例账号或硬编码密码。生产环境应通过 HTTPS 使用 Basic;Digest 仅适用于兼容既有客户端的场景。Digest HA1 可用于离线猜测密码,必须按密码等价凭据保护。
use SaToken\SaToken; $auth = SaToken::getHttpAuth(); $userRepository = app(UserRepository::class); // 替换为应用自己的用户仓库 $auth->setBasicValidator(function (string $username, string $password) use ($userRepository): mixed { $user = $userRepository->findByUsername($username); if ($user !== null && password_verify($password, $user->passwordHash)) { return $user->id; } return null; }); $auth->checkBasic('My Realm');
Digest 认证:
$auth->setDigestValidator(function (string $username) use ($userRepository): ?string { // 返回账户存储中为该 realm 预先保存的 HA1 值,不要在代码中硬编码账号或密码。 return $userRepository->findDigestHa1($username, 'My Realm'); }); $auth->checkDigest('My Realm');
Digest 流程:服务端先返回 401 + WWW-Authenticate 响应头(含服务端签发的一次性 nonce,TTL 300 秒),客户端携带该 nonce 完成认证;nonce 在认证成功后立即作废,重放直接拒绝。手搓 Digest 头调试时,必须先触发 challenge 获取服务端签发的 nonce。
Basic 和 Digest 凭据失败会接入 antiBruteMaxFailures / antiBruteLockDuration,按账号和可信来源 IP 进行限流;认证成功会清除该账号当前来源的失败计数。
参数签名校验(SaSign)
跨系统 API 调用签名,防参数篡改、防请求重放。使用前需配置至少 32 字节的高熵 signKey:
use SaToken\SaToken; $sign = SaToken::getSign(); $params = ['userId' => '10001', 'action' => 'query']; $signed = $sign->signParams($params); $isValid = $sign->verifySign($signed);
防重放默认内置:nonce 一次性存储基于 DAO 原子 setIfNotExists 实现,verifySign 强制要求 timestamp 与 nonce,缺失即拒绝;signParams / verifySign 支持可选 method / path 参数将 HTTP 方法与路径纳入签名,Web 场景建议传入:
$signed = $sign->signParams($params, 'POST', '/api/order'); $isValid = $sign->verifySign($signed, 'POST', '/api/order');
也可通过 setNonceValidator 接入自定义 nonce 存储。关闭内置存储(nonceStore=false)时必须配置该 validator,否则签名校验会拒绝请求。注意不要使用 Cache::has 这类"先查后写"的校验器——它存在并发重放窗口,推荐使用内置存储。
签名算法仅支持 sha256(MD5 因碰撞风险已移除):
$sign->setSignAlg('sha256');
API Key 秘钥授权
use SaToken\SaToken; $apiKey = SaToken::getApiKey(); $apiKey->registerKey('ak-123456', 'replace-with-random-api-secret-at-least-32-bytes-1', 10001); $apiKey->registerKey('ak-789012', 'replace-with-random-api-secret-at-least-32-bytes-2', 10002); $apiKey->checkApiKey();
API Key/Secret 验证失败也会使用 antiBruteMaxFailures / antiBruteLockDuration,按 API Key 和可信来源 IP 限制连续错误的 Secret 尝试;验证成功会清除该 API Key 当前来源的失败计数。
所有 API Secret(包括自定义验证器收到的凭据)都必须至少 32 字节;生产环境应为每个 Key 独立生成随机 Secret。自定义验证器还应将 Secret 安全存储并使用恒时比较。
自定义验证器:
$apiKey->setValidator(function (string $apiKey, string $apiSecret): mixed { $user = Db::table('api_keys')->where('api_key', $apiKey)->first(); if ($user && hash_equals($user->api_secret, $apiSecret)) { return $user->id; } return null; });
全局过滤器
use SaToken\SaToken; $filter = SaToken::getGlobalFilter(); $filter->setCors([ 'allowOrigin' => 'https://example.com', 'allowMethods' => 'GET, POST, PUT, DELETE', 'allowHeaders' => 'Content-Type, Authorization', 'allowCredentials' => 'true', 'maxAge' => '3600', ]); $filter->addBeforeFilter(function () { SaRouter::match('/api/**')->check(fn() => StpUtil::checkLogin()); }); $filter->addAfterFilter(function () { // 日志记录等 }); $filter->execute();
allowCredentials 可使用布尔值、"true" / "false" 或 1 / 0;其他值会被拒绝,避免字符串 "false" 被当成启用。
CORS 预检请求自动处理:
if ($filter->isCorsRequest()) { $filter->handlePreflight(); return; }
自定义存储
内存存储(默认)
use SaToken\SaToken; SaToken::setDao(new \SaToken\Dao\SaTokenDaoMemory());
文件存储
单机部署、无 Redis 环境时使用本地文件存储(flock 保证单机多进程原子性,不支持 NFS 等网络文件系统):
use SaToken\Dao\SaTokenDaoFile; use SaToken\SaToken; SaToken::setDao(new SaTokenDaoFile(['path' => __DIR__ . '/runtime/sa-token']));
新建的存储目录仅当前用户可访问,数据文件和锁文件使用 0600 权限。锁使用固定的 256 个条带,因此随机缺失 key 查询不会无限创建锁文件;不同 key 偶尔会共享一把锁。升级包含锁文件布局变化的版本时,需先停止旧 worker,再启动新 worker,避免新旧进程使用不同锁路径。
Redis 存储
use SaToken\Dao\SaTokenDaoRedis; use SaToken\SaToken; SaToken::setDao(new SaTokenDaoRedis());
注入已有的 \Redis 客户端时,必须使用默认的原始值编码(关闭 PhpRedis serializer 和 compression)。DAO 的原子操作通过 Lua 直接比较 Redis 原始字节;编码选项会让普通 GET/SET 与 Lua 读写不一致,构造或设置不兼容客户端时会抛出异常。
独立 Redis
权限缓存与业务缓存分离:
$dao = SaTokenDaoRedis::createWithSeparateRedis( ['host' => '127.0.0.1', 'port' => 6379, 'db' => 0], ['host' => '127.0.0.1', 'port' => 6379, 'db' => 1], ); SaToken::setDao($dao);
PSR-16 适配
use SaToken\Dao\SaTokenDaoPsr16; use SaToken\SaToken; $psr16Cache = new SomePsr16Cache(); SaToken::setDao(new SaTokenDaoPsr16($psr16Cache));
PSR-16 不提供原子 getAndDelete、setIfNotExists 或 increment。该适配器用主机本地文件锁串行化这些 DAO 操作;默认锁目录按当前有效 Unix UID 隔离,且目录和锁文件权限分别收紧为 0700、0600,因此只覆盖使用相同 UID 和同一锁目录的 PHP 进程。不同系统用户无法通过共享锁目录获得协调;跨 UID 或跨主机共享 PSR-16 缓存时,请使用 Redis DAO 或实现分布式原子操作的自定义 DAO。文件锁目录默认位于系统临时目录,也可通过构造函数第二个参数指定。
配置驱动自动装配
在 config/sa_token.php 中声明 storage 段,SaToken::init() 会自动构造存储层(无需再调用 setDao;显式 setDao 的优先级更高):
return [ // 文件存储(单机) 'storage' => [ 'type' => 'file', 'path' => runtime_path() . '/sa-token', // 数据目录,默认 sys_get_temp_dir()/sa-token 'scanLimit'=> 10000, // search 扫描文件数上限 ], // 或 Redis 存储(分布式部署) // 'storage' => [ // 'type' => 'redis', // 'host' => '127.0.0.1', // 'port' => 6379, // 'password' => '', // 'database' => 0, // 'timeout' => 0, // ], ];
选型建议:memory 仅适合单进程/测试;file 适合单机小规模生产(无外部依赖,但 search 为目录扫描,规模大时建议 Redis);redis 适合多实例部署与分布式锁场景。
自定义 DAO
实现 SaTokenDaoInterface。另外,登录、会话、OAuth2 刷新和敏感验证码状态更新使用带租约互斥锁,
自定义 DAO 还必须实现 SaTokenDaoCompareAndDeleteInterface,并保证比较后删除是原子操作;
否则这些加锁操作会在获取锁前抛出 SaTokenException。OAuth2 刷新家族状态变更还要求实现
SaTokenDaoCompareAndExpireInterface,以原子方式比较并延长记录 TTL;调用 TokenManager::renewLock() 时也需要此接口。
会话变更还要求实现
SaTokenDaoCompareAndSetInterface 和 SaTokenDaoCompareAndUpdateInterface:条件更新必须原子地比较并替换,且保留记录原 TTL;条件删除用于阻止租约过期后的旧会话对象覆盖或删除新代际。不要用先读取再删除/更新来模拟原子操作。
use SaToken\Dao\SaTokenDaoInterface; use SaToken\Dao\SaTokenDaoCompareAndDeleteInterface; use SaToken\Dao\SaTokenDaoCompareAndExpireInterface; use SaToken\Dao\SaTokenDaoCompareAndSetInterface; use SaToken\Dao\SaTokenDaoCompareAndUpdateInterface; class MyDao implements SaTokenDaoInterface, SaTokenDaoCompareAndDeleteInterface, SaTokenDaoCompareAndExpireInterface, SaTokenDaoCompareAndSetInterface, SaTokenDaoCompareAndUpdateInterface { public function get(string $key): ?string { /* ... */ } public function set(string $key, string $value, ?int $timeout = null): void { /* ... */ } public function update(string $key, string $value): void { /* ... */ } public function delete(string $key): void { /* ... */ } public function exists(string $key): bool { /* ... */ } public function getTimeout(string $key): int { /* ... */ } public function expire(string $key, int $timeout): void { /* ... */ } public function getAndExpire(string $key, int $timeout): ?string { /* ... */ } public function getAndDelete(string $key): ?string { /* ... */ } public function size(): int { /* ... */ } public function search(string $prefix, string $keyword, int $start, int $size): array { /* ... */ } public function deleteMultiple(array $keys): void { /* ... */ } // 批量删除 public function searchKeys(string $prefix, string $keyword, int $start, int $size): array { /* ... */ } // 仅返回键名列表 public function setIfNotExists(string $key, string $value, ?int $timeout = null): bool { /* ... */ } // 仅当 key 不存在时设置(登录锁/签名防重放依赖) public function increment(string $key, int $amount = 1, ?int $timeout = null): int { /* ... */ } // 原子计数(防爆破/OTP 依赖) public function compareAndDelete(string $key, string $expectedValue): bool { /* 原子比较并删除 */ } public function compareAndExpire(string $key, string $expectedValue, int $timeout): bool { /* 原子比较并续期,不缩短现有 TTL */ } public function compareAndSet(string $key, ?string $expectedValue, ?string $newValue, ?int $timeout = null): bool { /* 原子条件设置或删除 */ } public function compareAndUpdate(string $key, string $expectedValue, string $newValue): bool { /* 原子条件更新并保留原 TTL */ } }
摘要与密码哈希工具
use SaToken\Plugin\SaTokenCrypto; $digest = SaTokenCrypto::md5('payload'); $digest = SaTokenCrypto::sha1('payload'); $digest = SaTokenCrypto::sha256('payload'); $hmacKey = bin2hex(random_bytes(32)); // 保存到安全配置中,后续签名与验签使用同一密钥 $hmac = SaTokenCrypto::hmacSha256('data', $hmacKey); $hmac = SaTokenCrypto::hmacSha1('data', $hmacKey); $bcrypt = SaTokenCrypto::bcryptHash('password', 12); $valid = SaTokenCrypto::bcryptVerify('password', $bcrypt);
MD5、SHA1 和 SHA256 是通用快速摘要,不能用于存储密码;密码应使用 Argon2id 等专用密码哈希。遗留系统必须使用 bcrypt 时,成本至少为 10,并应根据服务器性能选择更高成本;本方法拒绝低于 10 的成本。bcrypt 输入不能超过 72 字节;bcryptHash() 对超长密码抛出异常,bcryptVerify() 对超长输入返回 false,避免 bcrypt 静默忽略后缀导致不同密码被视为相同凭据。需要支持更长密码时,请使用 Argon2id 等不受该限制的算法。
AES / RSA / SM2 / SM3 / SM4 等加解密功能参见 SaTokenCrypto 类。
AES/SM4 解密默认只接受带 HMAC 的认证格式。旧版未认证 CBC 密文如需迁移,可在受控离线流程中临时使用 aesDecryptLegacy() / sm4DecryptLegacy() 读取,再用对应的加密方法重加密;迁移方法不能用于请求输入或在线解密路径。
Token 存储加密器 SaTokenEncryptor::decrypt() 同样默认拒绝旧版无密钥 SM3 摘要的 SM4 密文;仅受控离线迁移可显式调用 decryptLegacy(),然后立即用 encrypt() 重加密。
防暴力破解
登录失败次数按账号和来源 IP 分别记录;单一账号达到阈值后只锁定该账号在当前来源的尝试,避免匿名请求锁住其他用户。另有来源级累计上限,防止攻击者不断更换账号名制造无界计数键;达到上限后,该来源在锁定时长内不能继续尝试该登录类型,并且同一来源同时进行的认证校验数不会超过该阈值。显式调用 lock() / unlockAccount() 仍按账号全局生效。部署在代理后时,应在应用启动时只信任实际反向代理的精确 IP,并确保代理会覆盖/清理客户端传入的转发头:
use SaToken\Util\SaTokenContext; SaTokenContext::setTrustedProxies(['10.0.0.10', '10.0.0.11']);
配置后,只有来自可信代理的请求才会解析 X-Forwarded-For(从右向左跳过可信代理)或回退到 X-Real-IP;未配置可信代理时不会采信这些头。此列表只支持精确 IP,不支持 CIDR。启用任一防暴力阈值时,如果无法解析客户端 IP,认证检查和失败计数会拒绝继续;需要在 HTTP 请求上下文中提供可靠的客户端 IP。分布式来源的全局限流仍应由网关或应用层补充。
StpUtil::checkAntiBrute('user@example.com'); // 凭据验证成功后释放并发槽位,并清理该账号在当前来源的失败状态。 StpUtil::clearAntiBruteFailures('user@example.com'); // 凭据验证失败后记录失败(不要同时调用成功清理)。 StpUtil::recordAntiBruteFailure('user@example.com'); $isLocked = StpUtil::isAccountLocked('user@example.com'); $remaining = StpUtil::getRemainingLockTime('user@example.com'); StpUtil::unlockAccount('user@example.com'); $info = StpUtil::getAntiBruteInfo('user@example.com'); // ['failCount' => 3, 'isLocked' => false, 'remainingLockTime' => 0, ...]
配置:
SaToken::init([ 'antiBruteMaxFailures' => 5, 'antiBruteLockDuration' => 600, 'antiBruteIpMaxFailures' => 100, ]);
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
antiBruteMaxFailures |
int | 5 |
单一账号、单一来源的最大失败次数,0 不限制 |
antiBruteLockDuration |
int | 600 |
锁定时长(秒) |
antiBruteIpMaxFailures |
int | 100 |
同一来源、同一登录类型在锁定窗口内的累计失败上限,并限制并发认证校验数;成功登录只清除该账号在当前来源的失败状态,不会重置来源累计失败数,避免用其他有效账号绕过来源限流。需 antiBruteLockDuration 大于 0,0 关闭此上限 |
IP 异常检测
记录登录 IP 历史,检测异地登录异常:
$info = StpUtil::getLoginInfo(10001); // ['currentIp' => '192.168.1.1', 'lastLoginIp' => '10.0.0.1', 'anomalyCount' => 2, ...] $count = StpUtil::getAnomalyCount(10001); $history = StpUtil::getIpHistory(10001); StpUtil::clearLoginHistory(10001);
配置:
SaToken::init([ 'ipAnomalyDetection' => true, 'ipAnomalySensitivity' => 3, ]);
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
ipAnomalyDetection |
bool | false |
是否启用 IP 异常检测 |
ipAnomalySensitivity |
int | 3 |
灵敏度(历史同网段 IP 数 ≥ 此值视为正常) |
设备管理
登录设备注册与踢出:
$devices = StpUtil::getDeviceList(10001); $count = StpUtil::getDeviceCount(10001); StpUtil::kickoutDevice(10001, 'device-id-xxx'); $kickedCount = StpUtil::kickoutAllDevices(10001, $currentToken); $device = StpUtil::findDevice(10001, 'device-id-xxx');
设备列表、设备查询和踢出方法不会验证调用者对目标账号的管理权限。面向用户提供时,应从已认证身份或服务端授权策略确定目标账号,并限制其可操作范围。
配置:
SaToken::init([ 'deviceManagement' => true, ]);
无状态 JWT 调用 kickoutAllDevices($loginId, $exceptToken) 时,必须启用设备管理来记录并枚举该账号的令牌;未启用时方法会抛出异常,避免静默跳过撤销。
设备信息自动检测(User-Agent 解析):设备类型(PC/Mobile/Tablet)、设备名称(微信/钉钉/支付宝/Web)、操作系统、浏览器。
启用 tokenEncrypt 后,设备管理记录中的 Token 字段也会加密保存;未加密的历史记录会在更新时逐步迁移。
敏感操作验证
OTP 验证码与安全令牌。使用这些功能前必须配置至少 32 字节的高熵 signKey;敏感验证状态会使用派生 HMAC 绑定当前验证场景和账户,防止 DAO 数据被篡改或跨账户复制。升级后已有的验证码和未消费安全令牌会失效,需要重新获取。
StpUtil::sendOtpCode('payment'); StpUtil::verifyOtpCode('payment', '123456'); $isVerified = StpUtil::isSensitiveVerified('payment'); $remaining = StpUtil::getSensitiveVerifyRemainingAttempts('payment'); StpUtil::clearSensitiveVerify('payment'); $token = StpUtil::openSensitiveVerify('payment', 600); StpUtil::checkSensitiveVerify('payment', $token);
sendOtpCode() 要求当前请求已登录,并按当前登录账号投递验证码,不会把验证码返回给调用方。底层静态方法 SaSensitiveVerify::sendCode() 可由服务端指定任意 loginId,本身不验证调用者身份或目标归属;只应从可信服务端流程调用,不要暴露为允许匿名用户任意指定手机号/邮箱的接口。对外发送端点还应校验目标归属并设置来源级限流。generateOtpCode() 同样仅供可信服务端自定义投递流程使用;验证码属于秘密凭据,不要将其放入 HTTP/RPC 响应返回给用户。
一次性安全令牌必须在 OTP 成功验证后通过 createSafeTokenAfterVerified()(或 StpUtil::openSensitiveVerify())签发。旧的 SaSensitiveVerify::createSafeToken() 入口现 fail-closed,因为直接调用会绕过 OTP 验证;请迁移到受验证状态保护的签发流程。
审计日志
记录登录、登出、踢人、封禁、身份切换等操作:
$logs = StpUtil::getAuditLogs(50); $log = StpUtil::getAuditLog('log-id-xxx'); SaAuditLog::logLogin(10001); SaAuditLog::logLogout(10001); SaAuditLog::logKickout(10001); SaAuditLog::logDisable(10001, 'login', '违规操作'); SaAuditLog::logSwitchTo(10001, 20002); $recentLogs = SaAuditLog::getRecentLogs('login', 100); $logsByIp = SaAuditLog::getLogsByIp('192.168.1.1'); $logsByEvent = SaAuditLog::getLogsByEvent('login'); SaAuditLog::clearLogs();
配置:
SaToken::init([ 'auditLog' => true, 'auditLogMaxEntries' => 1000, 'auditLogTtlDays' => 30, ]);
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
auditLog |
bool | false |
是否启用审计日志 |
auditLogMaxEntries |
int | 1000 |
每种登录类型最大日志条数 |
auditLogTtlDays |
int | 30 |
日志保留天数 |
RPC 上下文传播
微服务间 Token 透传与验证:
use SaToken\Rpc\SaRpcContext; use SaToken\Rpc\SaRpcInterceptor; // 发送端:将认证信息注入请求头 $headers = SaRpcContext::attachToHeaders(['X-Custom' => 'value']); $psr7Request = SaRpcContext::attachToRequest($request); // 接收端:提取并验证 SaRpcContext::extractAndValidate(); $loginId = SaRpcContext::getForwardedLoginId(); $token = SaRpcContext::getForwardedToken(); $loginType = SaRpcContext::getForwardedLoginType(); $isRpc = SaRpcContext::isRpcRequest();
拦截器模式:
$interceptor = new SaRpcInterceptor(); $interceptor->setValidateToken(true) ->setAutoLogin(true) ->setLoginType('login'); $interceptor->handleIncoming(); $outHeaders = $interceptor->handleOutgoing();
接收端拦截器会按 setLoginType() 配置验证 Token;若请求头声明了不同的 X-Sa-Login-Type,请求将被拒绝。
事件监听
use SaToken\Listener\SaTokenListenerInterface; use SaToken\SaToken; class MyListener implements SaTokenListenerInterface { public function onLogin(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void { } public function onLogout(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void { } public function onKickout(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void { } public function onReplaced(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void { } } SaToken::addListener(new MyListener());
异常处理
所有异常均继承 SaToken\Exception\SaTokenException:
| 异常类 | 触发场景 |
|---|---|
NotLoginException |
未登录 / Token 无效 / Token 已过期 / Token 已被踢出 |
NotPermissionException |
权限校验不通过 |
NotRoleException |
角色校验不通过 |
DisableServiceException |
账号被封禁 |
NotSafeException |
二级认证校验不通过 |
use SaToken\Exception\NotLoginException; use SaToken\Exception\NotPermissionException; try { StpUtil::checkPermission('user:add'); } catch (NotPermissionException $e) { echo $e->getPermission(); } catch (NotLoginException $e) { echo $e->getType(); }
配置参考
核心配置
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tokenName |
string | satoken |
Token 名称(Cookie/Header/参数名) |
tokenPrefix |
string | '' |
Token 前缀,如 Bearer |
tokenStyle |
string | uuid |
Token 风格:uuid / simple-random / random-64 / random-128 / random-256 / tiket |
timeout |
int | 86400 |
Token 有效期(秒),-1 永不过期 |
activityTimeout |
int | -1 |
最低活动频率(秒),-1 不限制 |
concurrent |
bool | true |
是否允许多端同时登录 |
isShare |
bool | true |
同端是否复用 Token |
maxLoginCount |
int | 12 |
同账号最大登录数,-1 不限制 |
maxTryTimes |
int | 12 |
创建 Token 最高循环次数 |
isReadHeader |
bool | true |
从 Header 读取 Token |
isReadCookie |
bool | true |
从 Cookie 读取 Token |
isReadBody |
bool | false |
从请求体读取 Token |
isWriteCookie |
bool | true |
登录后写入 Cookie |
isWriteHeader |
bool | false |
登录后写入响应头 |
cookieDomain |
string | '' |
Cookie 作用域 |
cookiePath |
string | '/' |
Cookie 路径 |
cookieSecure |
bool | true |
Cookie 仅 HTTPS 传输 |
cookieHttpOnly |
bool | true |
Cookie HttpOnly |
cookieSameSite |
string | 'Strict' |
Cookie SameSite:Strict / Lax / None |
tokenFingerprint |
bool | false |
是否启用 Token 指纹绑定(IP + User-Agent) |
storage |
array | ['type' => 'memory'] |
存储层配置,type: memory / file / redis;file 可配 path、scanLimit,redis 可配 url 或 host / port / username / password / database / timeout;PSR-16 请用 SaToken::setDao() |
启用正值 activityTimeout 时,Token 签发即创建活动标记,每次有效请求刷新标记;标记缺失或已过期会让 Token 失效,不会自动恢复。升级前已存在但没有活动标记的会话需要重新登录。
加密配置
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
cryptoType |
string | 'intl' |
加密类型:intl / sm |
tokenEncrypt |
bool | false |
是否启用 Token 内容加密 |
tokenEncryptKey |
string | '' |
Token 加密密钥(AES 至少 32 字节;SM4 使用 32 字节材料或 32 位十六进制密钥) |
aesKey |
string | '' |
AES 密钥(至少 32 字节;旧版未认证密文迁移入口仍可读取历史短密钥密文) |
rsaPrivateKey |
string | '' |
RSA 私钥 |
rsaPublicKey |
string | '' |
RSA 公钥 |
hmacKey |
string | '' |
HMAC 密钥 |
sm2PrivateKey |
string | '' |
SM2 私钥 |
sm2PublicKey |
string | '' |
SM2 公钥 |
sm4Key |
string | '' |
SM4 密钥(32 字节材料或 32 位十六进制密钥) |
jwtSecretKey |
string | '' |
JWT 密钥 |
jwtStateless |
bool | false |
JWT 无状态模式 |
jwtMode |
string | 'simple' |
有状态 JWT 模式:simple / mixed;无状态模式由 jwtStateless 控制 |
签名配置
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
signKey |
string | '' |
参数签名密钥,至少 32 字节;空值表示未启用 |
signTimestampGap |
int | 600 |
签名时间戳容差(秒) |
signAlg |
string | 'sha256' |
签名算法:仅支持 sha256(MD5 因碰撞风险已移除) |
API Key 配置
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
apiKeyHeader |
string | 'api-key' |
API Key 请求头名 |
apiSecretHeader |
string | 'api-secret' |
API Secret 请求头名 |
Redis 配置
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
separateRedis |
bool | false |
是否使用独立 Redis |
separateRedisConfig |
array | [] |
独立 Redis 连接配置 |
其他配置
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tokenSessionCheckLogin |
bool | true |
TokenSession 是否校验登录 |
refreshToken |
bool | false |
是否启用 RefreshToken |
refreshTokenTimeout |
int | 2592000 |
RefreshToken 有效期(秒) |
refreshTokenRotation |
bool | true |
刷新时是否轮换 RefreshToken |
SSO 配置
| 键名 | 默认值 | 说明 |
|---|---|---|
loginUrl |
'' |
SSO 登录地址 |
authUrl |
'' |
认证中心 URL |
backUrl |
'' |
回调地址 |
checkTicketUrl |
'' |
Ticket 校验地址 |
sloUrl |
'' |
单点注销地址 |
mode |
'same-domain' |
SSO 模式:same-domain / cross-domain / front-separate / no-sdk |
clientId |
'' |
Client ID |
clientSecret |
'' |
Client Secret |
allowDomains |
[] |
允许的回调域名白名单(支持通配符 *) |
paramName |
'sso_params' |
参数防丢 Cookie 名 |
checkState |
true |
强制校验回调 state 防 CSRF |
crossRedis |
false |
认证中心与客户端是否跨 Redis 部署 |
crossRedisCheckUrl |
'' |
跨 Redis 模式的 check-ticket 端点,必须 HTTPS(localhost 豁免) |
OAuth2 配置
| 键名 | 默认值 | 说明 |
|---|---|---|
grantTypes |
['authorization_code'] |
支持的授权模式 |
codeTimeout |
60 |
授权码有效期(秒) |
accessTokenTimeout |
7200 |
Access Token 有效期(秒) |
refreshTokenTimeout |
-1 |
Refresh Token 有效期(秒),-1 不刷新 |
isNewRefreshToken |
false |
是否每次生成新 Refresh Token |
openIdMode |
false |
是否启用 OpenID Connect |
issuer |
'' |
OpenID 签发者 URL |
clientSecretMaxFailures |
10 |
同一客户端和来源 IP 的密钥连续失败临时锁定阈值;请求上下文无法提供来源 IP 时跳过本地锁定,需由上游限流 |
clientFailureWindow |
300 |
失败计数窗口(秒) |
项目结构
src/
├── Action/ # 业务行为接口
│ └── SaTokenActionInterface.php
├── Auth/ # 认证扩展
│ ├── SaHttpAuth.php # Http Basic / Digest
│ └── SaApiKey.php # API Key 秘钥授权
├── Config/
│ └── SaTokenConfig.php # 核心配置类
├── Dao/ # 持久层
│ ├── SaTokenDaoInterface.php # DAO 接口
│ ├── SaTokenDaoMemory.php # 内存存储
│ ├── SaTokenDaoRedis.php # Redis 存储(支持独立 Redis)
│ └── SaTokenDaoPsr16.php # PSR-16 适配
├── Exception/ # 异常类
│ ├── SaTokenException.php
│ ├── NotLoginException.php
│ ├── NotPermissionException.php
│ ├── NotRoleException.php
│ ├── DisableServiceException.php
│ └── NotSafeException.php
├── Listener/ # 事件监听
│ ├── SaTokenEvent.php
│ └── SaTokenListenerInterface.php
├── Middleware/ # 中间件
│ └── SaGlobalFilter.php # 全局过滤器(CORS + 安全头)
├── OAuth2/ # OAuth2.0 模块
│ ├── Data/ # 数据对象
│ │ ├── SaOAuth2AccessToken.php
│ │ ├── SaOAuth2AuthorizationCode.php
│ │ ├── SaOAuth2Client.php
│ │ ├── SaOAuth2RefreshToken.php
│ │ └── SaOAuth2IdToken.php
│ ├── Strategy/ # 策略模式
│ │ ├── AuthorizationCodeStrategy.php
│ │ ├── ImplicitStrategy.php
│ │ ├── PasswordStrategy.php
│ │ └── ClientCredentialsStrategy.php
│ ├── SaOAuth2Config.php
│ ├── SaOAuth2Handle.php
│ └── SaOAuth2Manager.php
├── Plugin/ # 插件
│ ├── SaTokenCrypto.php # 加密工具(AES/RSA/HMAC/MD5/SHA/bcrypt/SM)
│ ├── SaTokenJwt.php # JWT(HS256/HMAC-SM3 + 扩展参数 + 无状态)
│ └── SaTokenSmCrypto.php # 国密插件(SM2 签名验签 + SM3 哈希 + SM4 加解密)
├── Rpc/ # 微服务 RPC
│ ├── SaRpcContext.php # RPC 上下文(Token 透传 + 验证)
│ └── SaRpcInterceptor.php # RPC 拦截器(自动验证 + 自动登录)
├── Security/ # 安全模块
│ ├── SaAntiBruteUtil.php # 防暴力破解(失败计数 + 账号锁定)
│ ├── SaAuditLog.php # 审计日志(登录/登出/踢人/封禁/切换)
│ ├── SaIpAnomalyDetector.php # IP 异常检测(异地登录告警)
│ ├── SaLoginDeviceManager.php # 设备管理(注册/踢出/UA 解析)
│ └── SaSensitiveVerify.php # 敏感操作验证(OTP + 安全令牌)
├── Session/ # 会话维护
│ └── SaSessionCleaner.php # 过期会话清理
├── Sign/ # 参数签名
│ └── SaSign.php # 签名校验(防篡改 + 防重放)
├── Sso/ # SSO 单点登录
│ ├── Mode/ # 三种模式
│ │ ├── SsoModeSameDomain.php
│ │ ├── SsoModeCrossDomain.php
│ │ └── SsoModeFrontSeparate.php
│ ├── SaSsoConfig.php
│ ├── SaSsoHandle.php
│ └── SaSsoManager.php
├── Util/ # 工具类
│ ├── SaFoxUtil.php # 通用工具
│ ├── SaTokenContext.php # 请求上下文
│ └── SaTokenEncryptor.php # Token 加解密器
├── SaLoginParameter.php # 登录参数类
├── SaRouter.php # 路由鉴权匹配器(协程安全)
├── SaSession.php # 会话管理
├── SaTerminalInfo.php # 终端信息
├── SaToken.php # 核心入口类
├── SaTokenInfo.php # Token 信息
├── StpLogic.php # 底层鉴权逻辑
├── StpUtil.php # 静态鉴权入口
└── TokenManager.php # Token 管理器
依赖
| 依赖 | 用途 |
|---|---|
ext-openssl |
AES/RSA/HMAC 等加密算法 |
ext-redis |
可选,Redis 分布式会话存储 |
firebase/php-jwt |
JWT Token 模式 |
pohoc/crypto-sm |
国密 SM2/SM3/SM4 算法 |
psr/http-message |
PSR-7 HTTP 消息接口 |
psr/simple-cache |
PSR-16 缓存适配 |
License
MIT