Sa-Token PHP 完全开发手册(ThinkPHP 8 版)

框架版本: ThinkPHP 8.0+
项目版本: 基于 pohoc/sa-token 最新源码
PHP 版本: PHP >= 8.1

目录

一、框架概述与核心概念

1.1 什么是 Sa-Token?

Sa-Token 是一个轻量级、功能完备的权限认证框架,以简单、优雅的方式完成系统的权限认证部分。与 SpringSecurity、Shiro 等传统框架相比,Sa-Token 的 API 设计更加简洁——绝大多数功能都能用一行代码解决。

核心设计理念

  • 简单:登录认证只需 StpUtil::login(10001),权限校验只需 StpUtil::checkPermission('user:add')
  • 灵活:支持多账号体系、多存储引擎、多部署模式
  • 安全:内置防暴力破解、IP 异常检测、设备管理、敏感操作验证
  • 全面:从登录认证到权限管理,从 SSO 到 OAuth2.0,从 Cookie 到 Session,覆盖完整认证链路

1.2 四种部署模式完整对比

特性 单应用+Redis 单应用+内存 多应用+Redis 多应用+内存
适用场景 生产环境、高并发 开发测试、低并发 微服务、分布式 小型多模块项目
数据持久化 ✅ Redis 持久化 ❌ 重启丢失 ✅ Redis 持久化 ❌ 重启丢失
分布式支持 ✅ 支持 ❌ 不支持 ✅ 支持 ❌ 不支持
性能 非常高 非常高
内存占用
扩展性 优秀 一般 优秀 一般
部署复杂度 中等 中等
推荐场景 生产环境 开发/测试 微服务架构 小型项目

1.3 核心功能矩阵

类别 功能 说明
登录认证 单端登录 一个账号只能在一个设备登录
多端登录 一个账号可在多个设备同时登录
同端互斥登录 同一设备类型只能登录一个
七天内免登录 记住我功能,延长登录有效期
踢人下线 强制指定用户下线
账号封禁 禁止账号登录指定时长
权限认证 权限校验 判断用户是否拥有某权限
角色校验 判断用户是否拥有某角色
二级认证 敏感操作二次身份验证
身份切换 临时切换到其他用户身份
注解式鉴权 通过注解声明鉴权规则
Cookie 管理 自动写入 登录后自动写入 Cookie
自动读取 请求时自动读取 Cookie
安全配置 Secure/HttpOnly/SameSite
跨域共享 多域名间共享 Cookie
Session 会话 账号 Session 同一账号所有设备共享
Token Session 仅当前 Token 独享
自定义 Session 任意 Key 的 Session
自动清理 过期 Session 自动清理
Token 安全 多种风格 uuid/simple-uuid/random-32/random-64/random-128/tik
Token 前缀 如 Bearer 前缀
Token 加密 AES-256/SM4 加密
指纹绑定 IP+UA 防止 Token 盗用
黑名单 手动拉黑 Token
Refresh Token 双 Token 机制 AccessToken + RefreshToken
Token 轮换 刷新时自动轮换
无感刷新 客户端无感知刷新 Token
SSO 单点登录 四种模式 同域/跨域/前后端分离/无 SDK
OAuth2.0 四种授权模式 授权码/隐藏式/密码/客户端凭证
OpenID Connect 支持 OIDC 协议
安全防护 防暴力破解 登录失败次数限制
IP 异常检测 异地登录检测
设备管理 登录设备记录与管理
敏感操作验证 OTP/安全令牌

1.4 核心术语详解

术语 说明
StpUtil 静态工具类,提供所有认证 API
StpLogic 逻辑类,支持多账号体系隔离
SaRouter 路由匹配器,用于 URL 拦截鉴权
Token 身份凭证,由框架生成和管理
LoginId 登录标识,通常为用户 ID
SaTokenDao 数据持久层接口,负责所有会话数据的底层写入和读取
Session 会话对象,存储当前用户的状态数据
Cookie 客户端存储 Token 的机制
SaManager 管理 Sa-Token 所有全局组件的核心类
StpInterface 自定义权限加载接口,需开发者实现

二、环境要求与安装部署

2.1 环境要求

1
2
3
4
5
6
PHP >= 8.1
ThinkPHP >= 8.0
ext-openssl(必需,用于加解密)
ext-json(必需)
ext-redis(Redis 模式必需)
ext-session(可选,传统 PHP Session 兼容)

2.2 创建项目

1
2
3
4
5
6
7
8
# 创建单应用项目
composer create-project topthink/think tp8-sa-token
cd tp8-sa-token

# 或创建多应用项目(推荐用于中大型项目)
composer create-project topthink/think tp8-sa-token
cd tp8-sa-token
composer require topthink/think-multi-app

2.3 安装 Sa-Token

1
composer require pohoc/sa-token

2.4 安装 Redis 扩展(Redis 模式必需)

1
2
3
4
5
# 方式一:使用 predis(推荐)
composer require predis/predis

# 方式二:使用 phpredis 扩展(需在 php.ini 中启用)
# extension=redis.so

2.5 文件清单(使用前需创建)

文件路径 说明 优先级
config/sa_token.php 主配置文件 ✅ 必须
config/sa_token_sso.php SSO 配置(使用 SSO 时) ⚠️ 按需
config/sa_token_oauth2.php OAuth2.0 配置(使用 OAuth2 时) ⚠️ 按需
app/service/PermissionService.php 权限数据提供者 ✅ 必须
app/middleware/SaTokenMiddleware.php 鉴权中间件 ✅ 推荐
app/exception/Handler.php 异常处理器 ✅ 推荐
app/provider/SaTokenProvider.php 服务提供者 ✅ 推荐
config/middleware.php 中间件注册配置 ✅ 必须
config/service.php 服务提供者注册 ✅ 必须

三、四种部署模式详解

3.1 单应用 + Redis 模式

3.1.1 场景说明

适用于生产环境的单体应用,使用 Redis 作为存储介质,支持数据持久化、分布式部署、高并发场景。Token 会话数据存储在 Redis 中,应用重启后用户无需重新登录。

3.1.2 目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
tp8-sa-token/
├── app/
│ ├── controller/
│ │ ├── AuthController.php
│ │ ├── UserController.php
│ │ └── ...
│ ├── middleware/
│ │ └── SaTokenMiddleware.php
│ ├── exception/
│ │ └── Handler.php
│ ├── service/
│ │ └── PermissionService.php
│ └── provider/
│ └── SaTokenProvider.php
├── config/
│ ├── sa_token.php
│ ├── sa_token_sso.php
│ ├── sa_token_oauth2.php
│ ├── middleware.php
│ └── service.php
├── route/
│ └── app.php
├── .env
└── composer.json

3.1.3 创建配置文件 config/sa_token.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?php

return [
// ==================== Token 基础配置 ====================
'tokenName' => 'satoken',
'tokenPrefix' => 'Bearer',
'tokenStyle' => 'uuid',

// ==================== 有效期配置 ====================
'timeout' => 86400,
'activityTimeout' => -1,

// ==================== Cookie 详细配置 ====================
'cookieName' => 'satoken',
'cookieDomain' => '',
'cookiePath' => '/',
'cookieSecure' => false,
'cookieHttpOnly' => true,
'cookieSameSite' => 'Strict',
'cookieMaxAge' => 86400,
'cookiePrefix' => '',

// ==================== 读取/写入配置 ====================
'isReadHeader' => true,
'isReadCookie' => true,
'isReadBody' => false,
'isWriteCookie' => true,
'isWriteHeader' => false,

// ==================== 登录策略 ====================
'concurrent' => true,
'isShare' => true,
'maxLoginCount' => 12,
'maxTryTimes' => 12,

// ==================== 存储配置(Redis 模式) ====================
'storage' => 'redis',
'redis' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', 6379),
'password' => env('REDIS_PASSWORD', ''),
'db' => env('REDIS_DB', 0),
'timeout' => 3,
'prefix' => 'sa_token_',
],

// ==================== Session 配置 ====================
'session' => [
'timeout' => 86400,
'activityTimeout' => 1800,
'maxSize' => 1024,
'prefix' => 'session_',
],

// ==================== 加密配置 ====================
'cryptoType' => 'intl',
'tokenEncrypt' => false,
'tokenEncryptKey' => '',
'tokenFingerprint' => false,

// ==================== Refresh Token ====================
'refreshToken' => false,
'refreshTokenTimeout' => 2592000,
'refreshTokenRotation' => true,

// ==================== 安全配置 ====================
'antiBruteMaxFailures' => 5,
'antiBruteLockDuration' => 600,
'ipAnomalyDetection' => true,
'ipAnomalySensitivity' => 3,
'deviceManagement' => true,
'auditLog' => true,
'auditLogMaxEntries' => 1000,
'auditLogTtlDays' => 30,

// ==================== JWT 配置 ====================
'jwtSecretKey' => env('JWT_SECRET_KEY', ''),
'jwtStateless' => false,

// ==================== 签名配置 ====================
'signKey' => env('SIGN_KEY', ''),
'signTimestampGap' => 600,
'signAlg' => 'sha256',

// ==================== 日志配置 ====================
'isLog' => env('APP_DEBUG', false),
];

3.1.4 创建权限数据提供者 app/service/PermissionService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<?php

declare(strict_types=1);

namespace app\service;

use think\facade\Db;

/**
* 权限数据提供者
* 对应 Java 版 StpInterface 接口
*/
class PermissionService
{
/**
* 获取用户权限码集合
*
* @param int $userId 用户ID
* @return array 权限码列表
*/
public function getPermissionList(int $userId): array
{
$perms = Db::name('user_permission')
->where('user_id', $userId)
->column('permission_code');
return $perms ?: [];
}

/**
* 获取用户角色标识集合
*
* @param int $userId 用户ID
* @return array 角色标识列表
*/
public function getRoleList(int $userId): array
{
$roles = Db::name('user_role')
->alias('ur')
->join('role r', 'ur.role_id = r.id')
->where('ur.user_id', $userId)
->column('r.role_code');
return $roles ?: [];
}

/**
* 获取用户所有权限(含角色继承)
*
* @param int $userId 用户ID
* @return array 所有权限码列表
*/
public function getAllPermissions(int $userId): array
{
$roles = $this->getRoleList($userId);
$perms = [];

if (!empty($roles)) {
$rolePerms = Db::name('role_permission')
->whereIn('role_code', $roles)
->column('permission_code');
$perms = array_merge($perms, $rolePerms);
}

$userPerms = $this->getPermissionList($userId);
$perms = array_merge($perms, $userPerms);

return array_unique($perms);
}

/**
* 验证用户密码
*
* @param string $username 用户名
* @param string $password 密码
* @return array|null 用户信息或 null
*/
public function verifyPassword(string $username, string $password): ?array
{
$user = Db::name('user')
->where('username', $username)
->where('status', 1)
->find();

if ($user && password_verify($password, $user['password'])) {
return $user;
}
return null;
}
}

3.1.5 创建服务提供者 app/provider/SaTokenProvider.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

declare(strict_types=1);

namespace app\provider;

use think\Service;
use SaToken\SaToken;
use SaToken\Dao\SaTokenDaoRedis;
use SaToken\StpUtil;
use app\service\PermissionService;

class SaTokenProvider extends Service
{
public function register(): void
{
// 1. 加载配置
$config = config('sa_token');

// 2. 初始化 Sa-Token
SaToken::init($config);

// 3. 设置 Redis 存储驱动
if ($config['storage'] === 'redis') {
SaToken::setDao(new SaTokenDaoRedis($config['redis']));
}

// 4. 注入权限提供者(对应 StpInterface)
$this->injectPermissionGetter();
}

protected function injectPermissionGetter(): void
{
$service = new PermissionService();

// 设置权限获取回调
StpUtil::setPermissionGetter(function($loginId) use ($service) {
return $service->getAllPermissions((int) $loginId);
});

// 设置角色获取回调
StpUtil::setRoleGetter(function($loginId) use ($service) {
return $service->getRoleList((int) $loginId);
});
}
}

3.1.6 创建中间件 app/middleware/SaTokenMiddleware.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?php

declare(strict_types=1);

namespace app\middleware;

use SaToken\SaRouter;
use SaToken\StpUtil;
use SaToken\Exception\NotLoginException;
use SaToken\Exception\NotPermissionException;
use SaToken\Exception\NotRoleException;
use SaToken\Exception\DisableServiceException;
use think\Response;
use think\facade\Log;

class SaTokenMiddleware
{
public function handle($request, \Closure $next)
{
try {
// ========== 定义路由拦截规则 ==========

// 1. 登录校验:所有 /api/** 接口需要登录(排除登录/注册/刷新)
SaRouter::match('/api/**')
->exclude('/api/login', '/api/register', '/api/refresh')
->check(function() {
StpUtil::checkLogin();
});

// 2. 角色校验:/admin/** 需要 admin 角色
SaRouter::match('/admin/**')
->check(function() {
StpUtil::checkLogin();
StpUtil::checkRole('admin');
});

// 3. 权限校验:/api/user/** 需要 user:read 权限
SaRouter::match('/api/user/**')
->check(function() {
StpUtil::checkPermission('user:read');
});

// 4. 多权限校验:/api/order/** 需要 order:manage 或 order:view
SaRouter::match('/api/order/**')
->check(function() {
StpUtil::checkPermissionOr(['order:manage', 'order:view']);
});

// 5. GET 请求特殊校验
SaRouter::match('/api/search/**', 'GET')
->check(function() {
StpUtil::checkLogin();
});

} catch (NotLoginException $e) {
return $this->error(401, '未登录或登录已过期', ['type' => $e->getType()]);
} catch (NotPermissionException $e) {
return $this->error(403, '权限不足:' . $e->getPermission());
} catch (NotRoleException $e) {
return $this->error(403, '角色不足:' . $e->getRole());
} catch (DisableServiceException $e) {
$remaining = StpUtil::getDisableTime();
return $this->error(403, "账号已被封禁,剩余 {$remaining} 秒");
} catch (\Exception $e) {
Log::error('SaToken 鉴权异常:' . $e->getMessage());
return $this->error(500, '服务器内部错误');
}

return $next($request);
}

protected function error(int $code, string $msg, array $extra = []): Response
{
return json([
'code' => $code,
'msg' => $msg,
'data' => null,
'extra' => $extra
], $code);
}
}

3.1.7 注册服务提供者 config/service.php

1
2
3
4
5
6
7
<?php

return [
'providers' => [
\app\provider\SaTokenProvider::class,
],
];

3.1.8 注册中间件 config/middleware.php

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

return [
// 全局中间件
'global' => [
// ...
],

// 别名中间件(用于路由注册)
'alias' => [
'sa-token' => \app\middleware\SaTokenMiddleware::class,
],
];

3.1.9 单应用路由 route/app.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

use think\facade\Route;

// ========== 公开路由(无需鉴权) ==========
Route::post('api/login', 'Auth/login');
Route::post('api/register', 'Auth/register');
Route::post('api/refresh', 'Auth/refresh');

// ========== 需要鉴权的路由(使用中间件) ==========
Route::group('api', function () {
Route::get('user/info', 'Auth/info');
Route::post('user/logout', 'Auth/logout');
Route::resource('users', 'User');
})->middleware('sa-token');

// ========== 后台管理路由 ==========
Route::group('admin', function () {
Route::get('/', 'Admin/index');
Route::get('/users', 'Admin/users');
Route::get('/settings', 'Admin/settings');
})->middleware('sa-token');

3.1.10 环境变量配置 .env

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Redis 配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0

# Sa-Token 配置
SA_TOKEN_TIMEOUT=86400
SA_TOKEN_REFRESH_TIMEOUT=2592000

# JWT 配置
JWT_SECRET_KEY=your-jwt-secret

# 签名配置
SIGN_KEY=your-sign-key

# 调试模式(生产环境设为 false)
APP_DEBUG=false

3.1.11 创建数据库表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
-- 用户表
CREATE TABLE `user` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL UNIQUE,
`password` VARCHAR(255) NOT NULL,
`nickname` VARCHAR(100),
`email` VARCHAR(100),
`status` TINYINT DEFAULT 1,
`last_login` INT,
`last_ip` VARCHAR(45),
`create_time` INT,
`update_time` INT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用户权限表
CREATE TABLE `user_permission` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`permission_code` VARCHAR(50) NOT NULL,
`create_time` INT,
UNIQUE KEY `uk_user_perm` (`user_id`, `permission_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 角色表
CREATE TABLE `role` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`role_code` VARCHAR(50) NOT NULL UNIQUE,
`role_name` VARCHAR(100) NOT NULL,
`create_time` INT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用户角色关联表
CREATE TABLE `user_role` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`role_id` INT NOT NULL,
`create_time` INT,
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 角色权限表
CREATE TABLE `role_permission` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`role_code` VARCHAR(50) NOT NULL,
`permission_code` VARCHAR(50) NOT NULL,
`create_time` INT,
UNIQUE KEY `uk_role_perm` (`role_code`, `permission_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 审计日志表
CREATE TABLE `audit_log` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT,
`event_type` VARCHAR(50) NOT NULL,
`ip` VARCHAR(45),
`user_agent` VARCHAR(255),
`details` JSON,
`create_time` INT,
KEY `idx_user_id` (`user_id`),
KEY `idx_event_type` (`event_type`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.2 单应用 + 内存模式

3.2.1 场景说明

适用于开发测试环境低并发的小型项目,使用内存作为存储介质。特点是:

  • 无需 Redis:减少依赖,降低部署复杂度
  • 高性能:纯内存操作,读写速度最快
  • 数据易失:应用重启后所有 Token 会话丢失,用户需重新登录
  • 不支持分布式:多实例部署时无法共享会话

3.2.2 配置文件差异

1
2
3
4
5
// config/sa_token.php

// ========== 存储配置(内存模式) ==========
'storage' => 'memory', // 使用内存存储
// 'redis' => [], // 不需要 Redis 配置

3.2.3 服务提供者差异

1
2
3
4
5
6
7
8
9
10
11
// app/provider/SaTokenProvider.php

use SaToken\Dao\SaTokenDaoMemory; // 使用内存 DAO

public function register(): void
{
$config = config('sa_token');
SaToken::init($config);
SaToken::setDao(new SaTokenDaoMemory());
// ...
}

3.2.4 环境变量配置 .env

1
2
3
4
5
6
7
# 内存模式不需要 Redis 配置

# Sa-Token 配置
SA_TOKEN_TIMEOUT=86400

# 调试模式(开发环境可设为 true)
APP_DEBUG=true

3.2.5 内存模式注意事项

1
2
3
4
5
6
7
8
// ========== 内存模式特点 ==========
// 1. 应用重启后所有会话丢失
// 2. 不支持分布式部署
// 3. 适合开发测试环境

// ========== 内存模式下数据存储位置 ==========
// 数据存储在 SaTokenDaoMemory 类的静态属性中
// 进程内共享,进程重启丢失

3.3 多应用 + Redis 模式

3.3.1 场景说明

适用于微服务架构模块化中大型项目,多个应用共享 Redis 存储,实现:

  • Token 共享:用户在一个应用登录后,其他应用自动识别
  • 分布式部署:每个应用可独立部署、独立扩展
  • 数据持久化:Redis 持久化存储,应用重启会话不丢失
  • 应用隔离:通过 Redis key 前缀实现各应用数据隔离

3.3.2 启用多应用模式

1
2
3
4
5
# 安装多应用扩展
composer require topthink/think-multi-app

# 生成应用目录
php think build

3.3.3 目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
tp8-sa-token/
├── app/
│ ├── common/ # 公共模块
│ │ ├── middleware/
│ │ │ └── SaTokenMiddleware.php
│ │ ├── service/
│ │ │ └── PermissionService.php
│ │ ├── exception/
│ │ │ └── Handler.php
│ │ └── provider/
│ │ └── SaTokenProvider.php
│ ├── api/ # API 应用
│ │ ├── controller/
│ │ │ ├── AuthController.php
│ │ │ └── UserController.php
│ │ ├── config/
│ │ │ └── sa_token.php # API 专属配置
│ │ └── route/
│ │ └── app.php
│ ├── admin/ # 管理后台应用
│ │ ├── controller/
│ │ │ ├── AuthController.php
│ │ │ └── AdminController.php
│ │ ├── config/
│ │ │ └── sa_token.php
│ │ └── route/
│ │ └── app.php
│ ├── web/ # 前端应用
│ │ ├── controller/
│ │ │ └── IndexController.php
│ │ ├── config/
│ │ │ └── sa_token.php
│ │ └── route/
│ │ └── app.php
│ └── provider.php # 全局服务提供者
├── config/
│ └── sa_token.php # 全局基础配置
└── .env

3.3.4 全局基础配置 config/sa_token.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php

return [
// ==================== 通用配置 ====================
'tokenName' => 'satoken',
'tokenPrefix' => 'Bearer',
'tokenStyle' => 'uuid',

// ==================== Cookie 通用配置 ====================
'cookieDomain' => '',
'cookiePath' => '/',
'cookieSecure' => false,
'cookieHttpOnly' => true,
'cookieSameSite' => 'Strict',

// ==================== 存储配置(共享 Redis) ====================
'storage' => 'redis',
'redis' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', 6379),
'password' => env('REDIS_PASSWORD', ''),
'db' => env('REDIS_DB', 0),
'timeout' => 3,
// 全局前缀由各应用配置覆盖
],

// ==================== Session 配置(共享) ====================
'session' => [
'timeout' => 86400,
'activityTimeout' => 1800,
'maxSize' => 1024,
],

// ==================== 安全配置(全局通用) ====================
'antiBruteMaxFailures' => 5,
'antiBruteLockDuration' => 600,
'ipAnomalyDetection' => true,
'deviceManagement' => true,
'auditLog' => true,

// ==================== 各应用差异化配置 ====================
'apps' => [
'api' => [
'timeout' => 7200, // API Token 有效期 2 小时
'refreshToken' => true,
'refreshTokenTimeout' => 2592000,
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,
'isWriteHeader' => true,
'tokenFingerprint' => true,
'redis' => ['prefix' => 'sa_token_api_'],
],
'admin' => [
'timeout' => 28800, // Admin Token 有效期 8 小时
'refreshToken' => true,
'concurrent' => false,
'isShare' => false,
'isReadCookie' => true,
'isWriteCookie' => true,
'cookiePath' => '/admin',
'cookieSecure' => true,
'tokenFingerprint' => true,
'redis' => ['prefix' => 'sa_token_admin_'],
],
'web' => [
'timeout' => 86400, // Web Token 有效期 24 小时
'refreshToken' => false,
'concurrent' => true,
'isReadCookie' => true,
'isWriteCookie' => true,
'redis' => ['prefix' => 'sa_token_web_'],
],
],
];

3.3.5 API 应用专属配置 app/api/config/sa_token.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

return [
'timeout' => 7200,
'refreshToken' => true,
'refreshTokenTimeout' => 2592000,
'refreshTokenRotation' => true,
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,
'isWriteHeader' => true,
'concurrent' => true,
'tokenFingerprint' => true,
'redis' => [
'prefix' => 'sa_token_api_',
],
];

3.3.6 Admin 应用专属配置 app/admin/config/sa_token.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

return [
'timeout' => 28800,
'refreshToken' => true,
'concurrent' => false,
'isShare' => false,
'isReadCookie' => true,
'isWriteCookie' => true,
'cookiePath' => '/admin',
'cookieSecure' => true,
'tokenFingerprint' => true,
'redis' => [
'prefix' => 'sa_token_admin_',
],
];

3.3.7 公共服务提供者 app/common/provider/SaTokenProvider.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

declare(strict_types=1);

namespace app\common\provider;

use think\Service;
use SaToken\SaToken;
use SaToken\StpUtil;
use SaToken\Dao\SaTokenDaoRedis;
use app\common\service\PermissionService;

class SaTokenProvider extends Service
{
public function register(): void
{
$baseConfig = config('sa_token');
$appName = app()->http->getName() ?: 'api';

// 获取应用专属配置
$appConfig = $this->getAppConfig($appName, $baseConfig);

SaToken::init($appConfig);

// ========== 使用 Redis 存储 ==========
$redisConfig = $appConfig['redis'] ?? $baseConfig['redis'] ?? [];
SaToken::setDao(new SaTokenDaoRedis($redisConfig));

$this->injectPermission($appName);
}

protected function getAppConfig(string $appName, array $baseConfig): array
{
$appConfigFile = app()->getAppPath() . $appName . '/config/sa_token.php';
if (is_file($appConfigFile)) {
$appConfig = include $appConfigFile;
return array_merge($baseConfig, $appConfig);
}

if (isset($baseConfig['apps'][$appName])) {
return array_merge($baseConfig, $baseConfig['apps'][$appName]);
}

return $baseConfig;
}

protected function injectPermission(string $appName): void
{
$service = match ($appName) {
'admin' => new \app\admin\service\AdminPermissionService(),
'api' => new \app\api\service\ApiPermissionService(),
default => new PermissionService(),
};

StpUtil::setPermissionGetter(function($loginId) use ($service) {
return $service->getAllPermissions((int) $loginId);
});

StpUtil::setRoleGetter(function($loginId) use ($service) {
return $service->getRoleList((int) $loginId);
});
}
}

3.3.8 公共中间件 app/common/middleware/SaTokenMiddleware.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?php

declare(strict_types=1);

namespace app\common\middleware;

use SaToken\SaRouter;
use SaToken\StpUtil;
use SaToken\Exception\NotLoginException;
use SaToken\Exception\NotPermissionException;
use SaToken\Exception\NotRoleException;
use think\Response;
use think\facade\Log;

class SaTokenMiddleware
{
protected array $appRoutes = [
'api' => [
'match' => '/api/**',
'exclude' => ['/api/login', '/api/register', '/api/refresh'],
'check' => 'login',
],
'admin' => [
'match' => '/admin/**',
'exclude' => ['/admin/login'],
'check' => ['login', 'role:admin'],
],
'web' => [
'match' => '/web/**',
'exclude' => ['/web/login', '/web/register'],
'check' => 'login',
],
];

public function handle($request, \Closure $next)
{
try {
$appName = app()->http->getName() ?: 'api';
$rules = $this->appRoutes[$appName] ?? $this->appRoutes['api'];

$router = SaRouter::match($rules['match']);
if (!empty($rules['exclude'])) {
$router->exclude(...$rules['exclude']);
}

$router->check(function() use ($rules) {
$checks = is_array($rules['check']) ? $rules['check'] : [$rules['check']];
foreach ($checks as $check) {
if ($check === 'login') {
StpUtil::checkLogin();
} elseif (str_starts_with($check, 'role:')) {
StpUtil::checkRole(substr($check, 5));
} elseif (str_starts_with($check, 'perm:')) {
StpUtil::checkPermission(substr($check, 5));
}
}
});

} catch (NotLoginException $e) {
return $this->error(401, '请先登录');
} catch (NotPermissionException $e) {
return $this->error(403, '权限不足:' . $e->getPermission());
} catch (NotRoleException $e) {
return $this->error(403, '角色不足:' . $e->getRole());
} catch (\Exception $e) {
Log::error('SaToken 鉴权异常:' . $e->getMessage());
return $this->error(500, '服务器内部错误');
}

return $next($request);
}

protected function error(int $code, string $msg): Response
{
return json(['code' => $code, 'msg' => $msg], $code);
}
}

3.3.9 多应用路由配置

API 应用路由 app/api/route/app.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

use think\facade\Route;

// 公开路由
Route::post('login', 'Auth/login');
Route::post('register', 'Auth/register');
Route::post('refresh', 'Auth/refresh');

// 需要鉴权的路由
Route::group(function () {
Route::get('user/info', 'Auth/info');
Route::post('user/logout', 'Auth/logout');
Route::resource('users', 'User');
})->middleware('sa-token');

Admin 应用路由 app/admin/route/app.php

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

use think\facade\Route;

// 公开路由
Route::post('login', 'Auth/login');

// 需要鉴权的路由
Route::group(function () {
Route::get('dashboard', 'Admin/dashboard');
Route::get('users', 'Admin/users');
Route::get('settings', 'Admin/settings');
})->middleware('sa-token');

3.3.10 多应用 Redis 隔离验证

1
2
3
4
5
6
7
8
9
10
// 不同应用使用不同的 Redis key 前缀
// API 应用:sa_token_api_session_xxx
// Admin 应用:sa_token_admin_session_xxx
// Web 应用:sa_token_web_session_xxx

// 验证 Redis 中的数据
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$apiKeys = $redis->keys('sa_token_api_*');
$adminKeys = $redis->keys('sa_token_admin_*');

3.4 多应用 + 内存模式

3.4.1 场景说明

适用于小型多模块项目开发测试环境的多应用场景:

  • 多应用结构:模块化分离,如 API、Admin、Web 分离
  • 无需 Redis:减少依赖,快速搭建
  • 数据隔离:通过不同的 StpLogic 实例或内存存储实例隔离
  • 数据易失:应用重启后所有会话丢失

3.4.2 全局基础配置 config/sa_token.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php

return [
// ==================== 通用配置 ====================
'tokenName' => 'satoken',
'tokenPrefix' => 'Bearer',
'tokenStyle' => 'uuid',

// ==================== 存储配置(内存模式) ====================
'storage' => 'memory', // 使用内存存储
// 'redis' => [], // 不需要 Redis 配置

// ==================== 安全配置(全局通用) ====================
'antiBruteMaxFailures' => 5,
'antiBruteLockDuration' => 600,
'ipAnomalyDetection' => true,
'deviceManagement' => true,
'auditLog' => true,

// ==================== 各应用差异化配置 ====================
'apps' => [
'api' => [
'timeout' => 7200,
'refreshToken' => true,
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,
'isWriteHeader' => true,
'tokenFingerprint' => true,
],
'admin' => [
'timeout' => 28800,
'refreshToken' => true,
'concurrent' => false,
'isShare' => false,
'cookiePath' => '/admin',
'tokenFingerprint' => true,
],
'web' => [
'timeout' => 86400,
'refreshToken' => false,
'concurrent' => true,
],
],
];

3.4.3 公共服务提供者(内存模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

declare(strict_types=1);

namespace app\common\provider;

use think\Service;
use SaToken\SaToken;
use SaToken\StpUtil;
use SaToken\Dao\SaTokenDaoMemory; // 使用内存 DAO
use app\common\service\PermissionService;

class SaTokenProvider extends Service
{
public function register(): void
{
$baseConfig = config('sa_token');
$appName = app()->http->getName() ?: 'api';

$appConfig = $this->getAppConfig($appName, $baseConfig);

SaToken::init($appConfig);

// ========== 内存存储驱动 ==========
// 每个应用使用独立的内存存储实例
// 注意:多应用内存模式中各应用数据不共享
SaToken::setDao(new SaTokenDaoMemory());

$this->injectPermission($appName);
}

protected function getAppConfig(string $appName, array $baseConfig): array
{
$appConfigFile = app()->getAppPath() . $appName . '/config/sa_token.php';
if (is_file($appConfigFile)) {
$appConfig = include $appConfigFile;
return array_merge($baseConfig, $appConfig);
}

if (isset($baseConfig['apps'][$appName])) {
return array_merge($baseConfig, $baseConfig['apps'][$appName]);
}

return $baseConfig;
}

protected function injectPermission(string $appName): void
{
$service = match ($appName) {
'admin' => new \app\admin\service\AdminPermissionService(),
'api' => new \app\api\service\ApiPermissionService(),
default => new PermissionService(),
};

StpUtil::setPermissionGetter(function($loginId) use ($service) {
return $service->getAllPermissions((int) $loginId);
});

StpUtil::setRoleGetter(function($loginId) use ($service) {
return $service->getRoleList((int) $loginId);
});
}
}

3.4.4 内存模式下的多应用隔离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ========== 方式一:通过不同的 StpLogic 实例隔离 ==========
use SaToken\StpLogic;

// API 应用使用独立的 StpLogic
$apiStp = new StpLogic('api');
$apiStp->login(10001);
$apiToken = $apiStp->getTokenValue();

// Admin 应用使用独立的 StpLogic
$adminStp = new StpLogic('admin');
$adminStp->login(10001);
$adminToken = $adminStp->getTokenValue();

// ========== 方式二:通过应用配置中的 tokenName 区分 ==========
// 各应用使用不同的 tokenName
// app/api/config/sa_token.php
'tokenName' => 'satoken_api',

// app/admin/config/sa_token.php
'tokenName' => 'satoken_admin',

3.4.5 四种模式完整对比总结

对比项 单应用+Redis 单应用+内存 多应用+Redis 多应用+内存
Redis 依赖 ✅ 需要 ❌ 不需要 ✅ 需要 ❌ 不需要
数据持久化
分布式支持
多应用支持
应用间共享会话 N/A N/A
部署复杂度
性能 极高 极高
推荐环境 生产 开发/测试 生产/微服务 开发/小型项目
数据存储位置 Redis 进程内存 Redis 进程内存
集群扩展性 优秀 不可用 优秀 不可用

四、登录认证系统

4.1 创建认证控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
<?php

declare(strict_types=1);

namespace app\controller;

use think\facade\Db;
use SaToken\StpUtil;
use app\service\PermissionService;
use think\response\Json;
use SaToken\Security\SaAuditLog;

class AuthController
{
protected PermissionService $permissionService;

public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}

/**
* 用户登录 - 完整流程演示
*/
public function login(): Json
{
$username = input('post.username', '');
$password = input('post.password', '');
$device = input('post.device', 'web');
$remember = input('post.remember', false);

if (empty($username) || empty($password)) {
return json(['code' => 400, 'msg' => '用户名和密码不能为空']);
}

// ========== 防暴力破解检查 ==========
StpUtil::checkAntiBrute($username);

// ========== 验证用户凭证 ==========
$user = $this->permissionService->verifyPassword($username, $password);
if (!$user) {
StpUtil::recordAntiBruteFailure($username);
$info = StpUtil::getAntiBruteInfo($username);
return json([
'code' => 401,
'msg' => '用户名或密码错误',
'remaining' => $info['remainingAttempts'] ?? 0
]);
}

// 登录成功,清除失败记录
StpUtil::clearAntiBruteFailure($username);

// ========== 执行登录 ==========
// Sa-Token 登录,一行代码搞定
// 内部自动完成:生成Token、绑定LoginId、创建Session、写入Cookie、触发事件
StpUtil::login((string) $user['id'], [
'device' => $device, // 设备标识
'isLasting' => $remember, // 记住我
'extra' => [ // 扩展数据
'nickname' => $user['nickname'],
'ip' => request()->ip(),
'login_time' => time(),
]
]);

// ========== 登录后操作 ==========
// 1. 存储用户信息到 Session
StpUtil::setSession('user_info', [
'id' => $user['id'],
'username' => $user['username'],
'nickname' => $user['nickname'],
'email' => $user['email'] ?? '',
]);

// 2. 存储权限信息到 Session(避免每次查询数据库)
$permissions = $this->permissionService->getAllPermissions((int) $user['id']);
$roles = $this->permissionService->getRoleList((int) $user['id']);
StpUtil::setSession('permissions', $permissions);
StpUtil::setSession('roles', $roles);

// 3. 更新数据库登录信息
Db::name('user')
->where('id', $user['id'])
->update([
'last_login' => time(),
'last_ip' => request()->ip(),
]);

// 4. 记录审计日志
SaAuditLog::logLogin($user['id'], [
'ip' => request()->ip(),
'device' => $device,
]);

// ========== 返回结果 ==========
return json([
'code' => 200,
'msg' => '登录成功',
'data' => [
'token' => StpUtil::getTokenValue(),
'tokenPrefix' => config('sa_token.tokenPrefix'),
'userId' => $user['id'],
'nickname' => $user['nickname'],
'expires' => StpUtil::getTokenTimeout(),
'permissions' => $permissions,
'roles' => $roles,
]
]);
}

/**
* 退出登录
*/
public function logout(): Json
{
// 获取用户信息(用于审计)
$userId = StpUtil::getLoginId(false);

// 执行登出
StpUtil::logout();

// 记录审计日志
if ($userId) {
SaAuditLog::logLogout($userId);
}

return json([
'code' => 200,
'msg' => '退出成功'
]);
}

/**
* 获取当前用户信息
*/
public function info(): Json
{
StpUtil::checkLogin();

$userId = StpUtil::getLoginId();

// 从 Session 读取用户信息
$userInfo = StpUtil::getSession('user_info');
if (empty($userInfo)) {
// Session 中没有,从数据库读取
$user = Db::name('user')->find($userId);
if (!$user) {
return json(['code' => 404, 'msg' => '用户不存在']);
}
$userInfo = $user;
}

// 获取权限和角色
$permissions = StpUtil::getPermissionList();
$roles = StpUtil::getRoleList();

return json([
'code' => 200,
'data' => [
'id' => $userId,
'username' => $userInfo['username'] ?? '',
'nickname' => $userInfo['nickname'] ?? '',
'email' => $userInfo['email'] ?? '',
'permissions' => $permissions,
'roles' => $roles,
'token' => StpUtil::getTokenValue(),
'tokenTimeout' => StpUtil::getTokenTimeout(),
'loginDevice' => StpUtil::getLoginDevice(),
'extra' => StpUtil::getExtra(),
]
]);
}

/**
* 刷新 Token(启用 RefreshToken 时使用)
*/
public function refresh(): Json
{
if (!config('sa_token.refreshToken')) {
return json(['code' => 400, 'msg' => 'RefreshToken 未启用']);
}

try {
$newToken = StpUtil::refreshToken();
return json([
'code' => 200,
'msg' => 'Token 刷新成功',
'data' => [
'token' => $newToken,
'expires' => StpUtil::getTokenTimeout(),
]
]);
} catch (\Exception $e) {
return json([
'code' => 401,
'msg' => '刷新失败:' . $e->getMessage()
]);
}
}
}

4.2 登录参数详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use SaToken\SaLoginParameter;

// ========== 方式一:数组参数 ==========
StpUtil::login(10001, [
'device' => 'web', // 设备标识,用于同端互斥
'isLasting' => true, // 记住我,延长有效期
'timeout' => 3600, // 自定义有效期(秒),覆盖全局配置
'extra' => [ // 扩展数据,可通过 StpUtil::getExtra() 获取
'nickname' => '张三',
'avatar' => 'https://...',
'role' => 'admin',
]
]);

// ========== 方式二:登录参数对象 ==========
$param = SaLoginParameter::create()
->setDevice('mobile')
->setIsLasting(true)
->setTimeout(7200)
->setExtra([
'nickname' => '张三',
'app_version' => '2.3.1',
]);

StpUtil::login(10001, $param);

// ========== 方式三:简单登录(使用默认参数) ==========
StpUtil::login(10001);

4.3 获取当前登录信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// ========== 基础信息 ==========
// 获取当前登录用户 ID
$userId = StpUtil::getLoginId();

// 获取当前 Token 值
$token = StpUtil::getTokenValue();

// 获取 Token 剩余有效期(秒)
$timeout = StpUtil::getTokenTimeout();

// 获取 Token 创建时间
$createTime = StpUtil::getTokenCreateTime();

// 获取 Token 上次活动时间
$lastActivity = StpUtil::getTokenLastActivityTime();

// ========== 扩展信息 ==========
// 获取 Token 扩展信息
$extra = StpUtil::getExtra();
$nickname = StpUtil::getExtra('nickname');

// 更新扩展信息
StpUtil::updateExtra(['nickname' => '李四']);
StpUtil::updateExtra('level', 5);

// ========== 设备信息 ==========
// 获取当前登录设备
$device = StpUtil::getLoginDevice();

// ========== 登录状态 ==========
// 判断是否登录
if (StpUtil::isLogin()) {
echo '已登录,用户ID:' . StpUtil::getLoginId();
}

// 获取登录类型
$loginType = StpUtil::getLoginType();

// ========== 完整 Token 信息 ==========
$tokenInfo = StpUtil::getTokenInfo();
// [
// 'tokenValue' => 'xxx',
// 'loginId' => 10001,
// 'loginType' => 'login',
// 'device' => 'web',
// 'timeout' => 86400,
// 'createTime' => 1700000000,
// 'lastActivity' => 1700003600,
// 'extra' => ['nickname' => '张三'],
// ]

4.4 Token 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ========== Token 刷新 ==========
// 刷新当前 Token(延长有效期)
$newToken = StpUtil::refreshToken();

// 刷新指定用户的 Token
$newToken = StpUtil::refreshToken(10001);

// ========== Token 检查 ==========
// 检查 Token 是否有效
$valid = StpUtil::checkToken($tokenValue);

// 获取 Token 对应的 LoginId
$loginId = StpUtil::getLoginIdByToken($tokenValue);

// ========== Token 替换 ==========
// 替换 Token(生成新 Token,旧 Token 失效)
$newToken = StpUtil::replaceToken();

// ========== Token 黑名单 ==========
// 加入黑名单
StpUtil::addTokenToBlacklist($tokenValue);

// 移除黑名单
StpUtil::removeTokenFromBlacklist($tokenValue);

// 判断是否在黑名单
$isBlacklisted = StpUtil::isTokenBlacklisted($tokenValue);

4.5 踢人下线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ========== 踢人操作 ==========
// 踢指定用户下线(所有设备)
StpUtil::kickout(10001);

// 踢指定用户的指定设备下线
StpUtil::kickout(10001, 'mobile');

// 踢当前用户下线(退出登录)
StpUtil::kickout();

// ========== 获取被踢信息 ==========
$info = StpUtil::getKickoutInfo();
// [
// 'isKickout' => true,
// 'reason' => '管理员强制下线',
// 'time' => 1700000000,
// ]

// ========== 批量踢人 ==========
$userIds = [10001, 10002, 10003];
foreach ($userIds as $userId) {
StpUtil::kickout($userId);
}

4.6 账号封禁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ========== 封禁操作 ==========
// 封禁账号(禁止登录),默认永久
StpUtil::disable(10001);

// 封禁指定时长(秒)
StpUtil::disable(10001, 3600);

// 封禁并指定原因
StpUtil::disable(10001, 86400, '违规操作');

// 封禁特定服务(如:评论、发帖)
StpUtil::disableService(10001, 'comment', 3600, '违规评论');

// ========== 检查封禁 ==========
// 判断账号是否被封禁
if (StpUtil::isDisable(10001)) {
echo '账号已被封禁';
}

// 判断账号是否被封禁特定服务
if (StpUtil::isDisableService(10001, 'comment')) {
echo '评论功能被封禁';
}

// ========== 获取封禁信息 ==========
// 获取封禁剩余时间
$remaining = StpUtil::getDisableTime(10001);

// 获取封禁原因
$reason = StpUtil::getDisableReason(10001);

// 获取服务封禁剩余时间
$remaining = StpUtil::getDisableServiceTime(10001, 'comment');

// ========== 解封 ==========
// 解封账号
StpUtil::untieDisable(10001);

// 解封特定服务
StpUtil::untieDisableService(10001, 'comment');

4.7 多账号体系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use SaToken\StpLogic;

// ========== 创建不同账号体系 ==========
$adminStp = new StpLogic('admin');
$userStp = new StpLogic('user');
$guestStp = new StpLogic('guest');

// ========== 管理员登录 ==========
$adminStp->login(10001);
$adminToken = $adminStp->getTokenValue();
$adminStp->checkRole('super_admin');

// ========== 用户登录 ==========
$userStp->login(20002);
$userStp->checkPermission('order:view');

// ========== 获取各自信息 ==========
$adminId = $adminStp->getLoginId();
$userId = $userStp->getLoginId();

// ========== 各体系独立配置 ==========
// 管理员体系:有效期 8 小时
$adminStp->setConfig('timeout', 28800);

// 用户体系:有效期 24 小时
$userStp->setConfig('timeout', 86400);

4.8 身份切换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ========== 切换到指定用户 ==========
// 切换到用户 10002 的身份
StpUtil::switchTo(10002);

// 执行操作(此时所有校验都基于用户 10002)
$currentId = StpUtil::getLoginId(); // 返回 10002

// ========== 切回原身份 ==========
StpUtil::switchBack();

// ========== 切换状态检查 ==========
if (StpUtil::isSwitch()) {
$originalId = StpUtil::getSwitchOriginalId();
$currentId = StpUtil::getLoginId();
echo "当前以 {$originalId} 的身份切换到 {$currentId}";
}

// ========== 临时切换并执行 ==========
StpUtil::switchTo(10002, function() {
// 在切换状态下执行操作
$id = StpUtil::getLoginId(); // 10002
// 操作完成后自动切回
});

五、权限认证系统

5.1 权限校验方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use SaToken\StpUtil;

// ========== 校验(不通过抛异常 NotPermissionException) ==========
// 必须有此权限
StpUtil::checkPermission('user:add');

// 必须同时拥有多个权限
StpUtil::checkPermissionAnd(['user:add', 'user:edit']);

// 拥有任意一个权限即可
StpUtil::checkPermissionOr(['user:add', 'user:delete']);

// ========== 判断(返回 bool) ==========
if (StpUtil::hasPermission('user:delete')) {
// 拥有删除用户权限
}

if (StpUtil::hasPermissionAnd(['user:add', 'user:edit'])) {
// 同时拥有两个权限
}

if (StpUtil::hasPermissionOr(['user:add', 'user:delete'])) {
// 拥有任意一个权限
}

// ========== 获取所有权限 ==========
$permissions = StpUtil::getPermissionList();

// ========== 检查是否有任意权限 ==========
$has = StpUtil::hasAnyPermission(['user:add', 'user:edit']);

5.2 角色校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ========== 校验(不通过抛异常 NotRoleException) ==========
// 必须有此角色
StpUtil::checkRole('admin');

// 必须同时拥有多个角色
StpUtil::checkRoleAnd(['admin', 'editor']);

// 拥有任意一个角色
StpUtil::checkRoleOr(['admin', 'super']);

// ========== 判断(返回 bool) ==========
if (StpUtil::hasRole('admin')) {
// 是管理员
}

if (StpUtil::hasRoleOr(['admin', 'editor'])) {
// 有管理或编辑角色
}

// ========== 获取所有角色 ==========
$roles = StpUtil::getRoleList();

5.3 二级认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ========== 二级认证概述 ==========
// 二级认证用于敏感操作前的二次身份确认
// 如:修改密码、删除数据、转账等

// ========== 开启二级认证 ==========
// 在敏感操作前开启
StpUtil::openSafe();

// ========== 校验二级认证 ==========
// 不通过抛异常 NotSafeException
StpUtil::checkSafe();

// ========== 判断二级认证状态 ==========
if (StpUtil::isSafe()) {
// 已通过二级认证,执行敏感操作
}

// ========== 二级认证 + 权限校验 ==========
// 需要二级认证且拥有权限
StpUtil::openSafeAndCheck('user:delete');

// ========== 关闭二级认证 ==========
StpUtil::closeSafe();

// ========== 获取二级认证信息 ==========
// 获取剩余时间
$timeout = StpUtil::getSafeTimeout();

// 获取二级认证状态
$status = StpUtil::getSafeStatus();

// ========== 带自定义过期时间的二级认证 ==========
StpUtil::openSafe(300); // 5分钟有效期
StpUtil::checkSafe();

5.4 权限数据注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ========== 在服务提供者中注入权限获取逻辑 ==========
// 在 SaTokenProvider 中配置

StpUtil::setPermissionGetter(function($loginId) {
// 从数据库获取权限
$service = new PermissionService();
return $service->getAllPermissions((int) $loginId);
});

StpUtil::setRoleGetter(function($loginId) {
// 从数据库获取角色
$service = new PermissionService();
return $service->getRoleList((int) $loginId);
});

// ========== 权限缓存优化 ==========
// 使用 Session 缓存权限数据
StpUtil::setPermissionGetter(function($loginId) {
$cacheKey = 'permissions_' . $loginId;
$permissions = StpUtil::getSession($cacheKey);
if (empty($permissions)) {
$service = new PermissionService();
$permissions = $service->getAllPermissions((int) $loginId);
StpUtil::setSession($cacheKey, $permissions);
}
return $permissions;
});

六、Cookie 与 Session 管理

Sa-Token 中的 Cookie 管理是认证流程的核心环节。Token 通过 Cookie 在客户端和服务端之间传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
// config/sa_token.php

return [
// ========== Cookie 完整配置 ==========
'cookieName' => 'satoken', // Cookie 名称
'cookieDomain' => '', // Cookie 域名
'cookiePath' => '/', // Cookie 路径
'cookieSecure' => false, // 是否仅 HTTPS 传输
'cookieHttpOnly' => true, // 是否禁止 JS 读取
'cookieSameSite' => 'Strict', // SameSite 策略
'cookieMaxAge' => 86400, // Cookie 最大生命周期(秒)
'cookiePrefix' => '', // Cookie 前缀
];
配置项 类型 默认值 详细说明
cookieName string satoken Cookie 的名称,用于存储 Token。建议与 tokenName 保持一致。多应用环境下可不同。
cookieDomain string '' Cookie 的作用域。空字符串表示当前域名。设置如 .example.com 可在子域名间共享。
cookiePath string '/' Cookie 的路径。'/' 表示整个网站都有效。可设置为 /admin 限制仅后台使用。
cookieSecure bool false 是否仅通过 HTTPS 传输。生产环境建议 true
cookieHttpOnly bool true 是否禁止 JavaScript 读取 Cookie。建议 true 防止 XSS 攻击。
cookieSameSite string 'Strict' CSRF 防护策略。Strict 最安全,Lax 兼容性更好,None 需要配合 Secure
cookieMaxAge int 86400 Cookie 在客户端的最大存活时间(秒)。与 Token timeout 配合使用。
cookiePrefix string '' Cookie 前缀。可设置如 __Secure- 增强安全性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ========== Sa-Token 自动 Cookie 操作 ==========
// 登录时自动写入 Cookie(当 isWriteCookie = true)
StpUtil::login(10001);

// 登出时自动清除 Cookie
StpUtil::logout();

// 每次请求自动从 Cookie 读取 Token(当 isReadCookie = true)
// 框架自动完成,无需手动操作

// ========== 手动 Cookie 操作 ==========
// 获取 Cookie 中的 Token
$tokenFromCookie = cookie('satoken');

// 手动设置 Cookie(不推荐,建议由 Sa-Token 自动管理)
cookie('satoken', $tokenValue, 86400);

// 手动删除 Cookie
cookie('satoken', null);

// ========== 获取所有 Cookie ==========
$allCookies = request()->cookie();

// ========== Cookie 安全操作 ==========
// 设置安全 Cookie(HTTPS + HttpOnly + Secure)
cookie('satoken', $tokenValue, [
'expire' => 86400,
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);

// 跨域 Cookie 设置(前后端分离)
cookie('satoken', $tokenValue, [
'expire' => 86400,
'secure' => true,
'samesite' => 'None',
]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ========== 场景1:前后端分离(API 模式) ==========
// 不使用 Cookie,改用 Header
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,
'isWriteHeader' => true,

// ========== 场景2:传统 Web 应用(Session 模式) ==========
// 使用 Cookie,自动管理
'isReadCookie' => true,
'isWriteCookie' => true,
'cookieHttpOnly' => true,
'cookieSecure' => false, // 开发环境
'cookieSameSite' => 'Lax',

// ========== 场景3:多应用共享域名 ==========
// 在子域名间共享 Cookie
'cookieDomain' => '.example.com',
'cookiePath' => '/',
'cookieSameSite' => 'Lax',

// ========== 场景4:后台管理系统(高安全) ==========
// 强制 HTTPS,仅 Admin 路径有效
'cookieSecure' => true,
'cookiePath' => '/admin',
'cookieHttpOnly' => true,
'cookieSameSite' => 'Strict',

// ========== 场景5:移动端 API ==========
// 不使用 Cookie,使用 Header
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,

// ========== 场景6:HTTPS 环境 ==========
'cookieSecure' => true,
'cookieSameSite' => 'Strict',
'cookieHttpOnly' => true,
1
2
3
4
5
6
7
8
9
// 使用 Cookie 前缀可以增强安全性
'cookiePrefix' => '__Secure-',

// 实际写入的 Cookie 名称为:__Secure-satoken
// 这样浏览器会强制要求 HTTPS 传输

// 常用的 Cookie 前缀:
// __Secure- : 强制 HTTPS
// __Host- : 强制 HTTPS + 仅主机
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ========== 后端配置 ==========
// config/sa_token.php
'cookieSameSite' => 'None',
'cookieSecure' => true, // SameSite=None 时必须为 true

// ========== 前端请求配置 ==========
// 前端请求时需要携带 Cookie
fetch('/api/user/info', {
credentials: 'include', // 携带 Cookie
headers: {
'Content-Type': 'application/json',
}
});

// Axios 配置
axios.defaults.withCredentials = true;

6.2 Session 管理详解

Sa-Token 的 Session 机制用于在用户登录期间存储状态数据。

6.2.1 Session 类型

Sa-Token 提供三种 Session:

Session 类型 作用范围 说明
账号 Session 当前账号的所有 Token 登录后,同一账号的所有设备共享此 Session
Token Session 当前 Token 只对当前 Token 有效,不同设备相互隔离
自定义 Session 自定义 Key 开发者任意指定的 Session 对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// ========== 账号 Session(所有设备共享) ==========
// 存储数据(登录后自动创建)
StpUtil::setSession('user_info', ['name' => '张三']);

// 读取数据
$userInfo = StpUtil::getSession('user_info');

// 删除数据
StpUtil::deleteSession('user_info');

// 判断是否存在
if (StpUtil::hasSession('user_info')) {
// ...
}

// 获取整个 Session 对象
$session = StpUtil::getSession();

// ========== Token Session(仅当前 Token 独享) ==========
// 存储 Token 私有数据
StpUtil::setTokenSession('temp_data', ['key' => 'value']);

// 读取 Token 私有数据
$tempData = StpUtil::getTokenSession('temp_data');

// 删除 Token 私有数据
StpUtil::deleteTokenSession('temp_data');

// ========== 自定义 Session(任意 Key) ==========
// 使用任意 Key 创建 Session
$customSession = StpUtil::getCustomSession('my_custom_key');
$customSession->set('data', 'value');

// 或直接操作
StpUtil::setCustomSession('my_key', 'my_value');
$value = StpUtil::getCustomSession('my_key');
StpUtil::deleteCustomSession('my_key');

6.2.2 Session 操作详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// ========== Session 存储 ==========
// 存储简单值
StpUtil::setSession('user_id', 10001);
StpUtil::setSession('username', 'admin');

// 存储数组
StpUtil::setSession('user_info', [
'id' => 10001,
'name' => '张三',
'email' => '[email protected]',
'roles' => ['admin', 'editor'],
'settings' => [
'theme' => 'dark',
'language' => 'zh-CN',
],
]);

// 存储对象
$userObj = new User();
$userObj->id = 10001;
$userObj->name = '张三';
StpUtil::setSession('user_obj', $userObj);

// ========== Session 读取 ==========
// 读取值(不存在返回 null)
$userId = StpUtil::getSession('user_id');

// 读取并设置默认值
$username = StpUtil::getSession('username', 'guest');

// 读取整个 Session 对象
$session = StpUtil::getSession();
$allData = $session->getAll();

// 获取 Session 所有键
$keys = StpUtil::getSessionKeys();

// ========== Session 判断 ==========
if (StpUtil::hasSession('user_info')) {
// Session 存在
}

if (StpUtil::hasSessionKey('user_info', 'name')) {
// Session 中存在指定键
}

// ========== Session 删除 ==========
// 删除指定 Key
StpUtil::deleteSession('temp_data');

// 删除多个 Key
StpUtil::deleteSession(['key1', 'key2', 'key3']);

// 清空所有 Session
StpUtil::clearSession();

// ========== Session 有效期 ==========
// 设置 Session 有效期(秒)
StpUtil::setSessionTimeout('user_info', 3600);

// 获取 Session 剩余时间
$timeout = StpUtil::getSessionTimeout('user_info');

// 刷新 Session 有效期
StpUtil::refreshSession('user_info');

// 批量刷新 Session
StpUtil::refreshSession(['user_info', 'permissions']);

6.2.3 Session 配置

1
2
3
4
5
6
7
8
9
10
11
12
// config/sa_token.php

return [
// ========== Session 配置 ==========
'session' => [
'timeout' => 86400, // Session 默认有效期(秒)
'activityTimeout' => 1800, // Session 活跃超时(秒)
'maxSize' => 1024, // Session 最大存储大小(KB)
'prefix' => 'session_', // Session key 前缀
'cleanInterval' => 3600, // 清理间隔(秒)
],
];

6.2.4 Session 清理机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ========== 自动清理 ==========
// 配置了 activityTimeout 后,框架会自动清理过期 Session
// 每个请求会检查当前 Session 是否过期

// ========== 手动清理 ==========
// 清理过期 Session
SaToken::getDao()->cleanExpiredSession();

// 清理指定用户的 Session
StpUtil::clearUserSession(10001);

// 清理指定设备类型的 Session
StpUtil::clearDeviceSession('mobile');

// ========== Session 清理定时任务 ==========
// 配合定时任务定期清理过期 Session
// app/command/CleanSession.php
namespace app\command;

use think\console\Command;
use think\console\Input;
use think\console\Output;
use SaToken\SaToken;

class CleanSession extends Command
{
protected function configure()
{
$this->setName('clean:session')
->setDescription('清理过期 Session');
}

protected function execute(Input $input, Output $output)
{
$startTime = microtime(true);
$count = SaToken::getDao()->cleanExpiredSession();
$cost = microtime(true) - $startTime;

$output->writeln("清理了 {$count} 个过期 Session,耗时 {$cost} 秒");
}
}

// 注册命令:config/console.php
return [
'commands' => [
'clean:session' => \app\command\CleanSession::class,
],
];

// 设置定时任务(cron)
// 0 2 * * * cd /path/to/project && php think clean:session

6.2.5 Session 存储引擎对比

存储引擎 持久化 分布式 性能 适用场景
内存(Memory) 极高 开发测试
Redis 生产环境
PSR-16(Cache) 通用适配

6.2.6 Session 实战示例 - 购物车

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
<?php

namespace app\controller;

use SaToken\StpUtil;
use think\facade\Db;

class CartController
{
/**
* 购物车 - 使用 Session 存储
*/
public function cart()
{
StpUtil::checkLogin();

// 获取购物车数据
$cart = StpUtil::getSession('cart', []);

// 计算总价
$total = 0;
foreach ($cart as $item) {
$total += $item['price'] * $item['quantity'];
}

return json([
'code' => 200,
'data' => [
'items' => $cart,
'count' => count($cart),
'total' => $total,
]
]);
}

/**
* 添加商品到购物车
*/
public function add()
{
StpUtil::checkLogin();

$productId = input('post.product_id');
$quantity = input('post.quantity', 1);

// 获取商品信息
$product = Db::name('product')->find($productId);
if (!$product) {
return json(['code' => 404, 'msg' => '商品不存在']);
}

// 获取当前购物车
$cart = StpUtil::getSession('cart', []);

// 更新购物车
if (isset($cart[$productId])) {
$cart[$productId]['quantity'] += $quantity;
} else {
$cart[$productId] = [
'id' => $product['id'],
'name' => $product['name'],
'price' => $product['price'],
'quantity' => $quantity,
'image' => $product['image'],
];
}

// 保存到 Session
StpUtil::setSession('cart', $cart);

return json([
'code' => 200,
'msg' => '添加成功',
'data' => $cart,
]);
}

/**
* 更新购物车数量
*/
public function update()
{
StpUtil::checkLogin();

$productId = input('post.product_id');
$quantity = input('post.quantity', 0);

$cart = StpUtil::getSession('cart', []);

if ($quantity <= 0) {
unset($cart[$productId]);
} elseif (isset($cart[$productId])) {
$cart[$productId]['quantity'] = $quantity;
}

StpUtil::setSession('cart', $cart);

return json([
'code' => 200,
'msg' => '更新成功',
'data' => $cart,
]);
}

/**
* 清空购物车
*/
public function clear()
{
StpUtil::checkLogin();

StpUtil::deleteSession('cart');

return json([
'code' => 200,
'msg' => '已清空',
]);
}

/**
* 结算 - 使用 Session 存储订单信息
*/
public function checkout()
{
StpUtil::checkLogin();

$cart = StpUtil::getSession('cart', []);
if (empty($cart)) {
return json(['code' => 400, 'msg' => '购物车为空']);
}

// 生成订单号
$orderNo = date('YmdHis') . rand(1000, 9999);

// 计算总价
$total = 0;
foreach ($cart as $item) {
$total += $item['price'] * $item['quantity'];
}

// 存储订单信息到 Session
$orderInfo = [
'order_no' => $orderNo,
'user_id' => StpUtil::getLoginId(),
'items' => $cart,
'total' => $total,
'status' => 'pending',
'create_time' => time(),
];
StpUtil::setSession('current_order', $orderInfo);

return json([
'code' => 200,
'data' => [
'order_no' => $orderNo,
'total' => $total,
]
]);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
┌─────────────────────────────────────────────────────────────────────────────┐
│ 用户登录完整流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户提交用户名/密码 │
│ ↓ │
│ 2. 验证用户凭证 │
│ ↓ │
│ 3. StpUtil::login(10001) │
│ │ │
│ ├── 生成 Token (根据 tokenStyle 配置) │
│ │ ├── uuid: 550e8400-e29b-41d4-a716-446655440000 │
│ │ ├── simple-uuid: 550e8400e29b41d4a716446655440000 │
│ │ ├── random-32: abc123def456... │
│ │ └── tik: 随机24位字符串 │
│ │ │
│ ├── Token 与 LoginId 绑定 → Redis/内存 │
│ │ ├── key: sa_token_session_xxx │
│ │ └── value: {loginId, device, timeout, extra, ...} │
│ │ │
│ ├── 创建 Session 对象 (账号 Session) │
│ │ └── key: sa_token_session_{loginId} │
│ │ │
│ ├── 写入 Cookie (如果 isWriteCookie = true) │
│ │ └── Cookie: satoken=550e8400-e29b-41d4-a716-446655440000 │
│ │ ├── Domain: .example.com │
│ │ ├── Path: / │
│ │ ├── Secure: true/false │
│ │ ├── HttpOnly: true │
│ │ └── SameSite: Strict/Lax/None │
│ │ │
│ ├── 写入响应头 (如果 isWriteHeader = true) │
│ │ └── Header: Authorization: Bearer 550e8400... │
│ │ │
│ ├── 触发 onLogin 事件 │
│ │ └── 执行自定义监听器逻辑 │
│ │ │
│ └── 记录审计日志 │
│ └── 写入 audit_log 表 │
│ ↓ │
│ 4. 返回 Token 给客户端 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│ 用户请求鉴权流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 客户端发起请求 │
│ └── 携带 Cookie: satoken=550e8400-e29b-41d4-a716-446655440000 │
│ ↓ │
│ 2. 中间件拦截 (SaTokenMiddleware) │
│ ↓ │
│ 3. 从 Cookie/Header/Body 读取 Token │
│ ├── 优先级: Header > Cookie > Body │
│ └── 由配置 isReadHeader/isReadCookie/isReadBody 决定 │
│ ↓ │
│ 4. 验证 Token │
│ ├── 查询 Redis/内存: sa_token_session_{token} │
│ ├── Token 不存在 → 抛出 NotLoginException │
│ ├── Token 已过期 → 抛出 NotLoginException (TOKEN_TIMEOUT) │
│ ├── Token 已被踢 → 抛出 NotLoginException (BE_KICKOUT) │
│ └── Token 在黑名单 → 抛出 NotLoginException (INVALID_TOKEN) │
│ ↓ │
│ 5. 获取 LoginId │
│ └── 从 Token 记录中获取绑定的 LoginId │
│ ↓ │
│ 6. 刷新 Token 有效期 (如果 activityTimeout > 0) │
│ └── 更新 Redis/内存中的 lastActivity 时间 │
│ ↓ │
│ 7. 加载 Session 数据 │
│ └── 从 Redis/内存读取: sa_token_session_{loginId} │
│ ↓ │
│ 8. 检查账号状态 │
│ ├── 是否被封禁 → 抛出 DisableServiceException │
│ └── 是否被限制服务 → 抛出 DisableServiceException │
│ ↓ │
│ 9. 加载权限/角色信息 │
│ └── 通过 PermissionService 获取 │
│ ↓ │
│ 10. 执行路由鉴权 (SaRouter) │
│ ├── 检查是否需要登录 │
│ ├── 检查是否需要角色 │
│ └── 检查是否需要权限 │
│ ↓ │
│ 11. 执行业务逻辑 │
│ ↓ │
│ 12. 返回响应 → 自动续签 Cookie 有效期 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│ 退出登录完整流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户调用登出接口 │
│ ↓ │
│ 2. StpUtil::logout() │
│ │ │
│ ├── 删除 Token 与 LoginId 的绑定 │
│ │ └── Redis/内存: DEL sa_token_session_{token} │
│ │ │
│ ├── 删除 Session 数据 (如果配置了清理) │
│ │ └── Redis/内存: DEL sa_token_session_{loginId} │
│ │ │
│ ├── 清除 Cookie (如果 isWriteCookie = true) │
│ │ └── Set-Cookie: satoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT │
│ │ │
│ ├── 触发 onLogout 事件 │
│ │ └── 执行自定义监听器逻辑 │
│ │ │
│ └── 记录审计日志 │
│ └── 写入 audit_log 表 │
│ ↓ │
│ 3. 返回登出成功 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

七、注解式鉴权

7.1 注解概览

Sa-Token 提供 9 个注解,用于优雅地将鉴权与业务代码分离:

注解 说明 示例
@SaCheckLogin 登录校验 #[SaCheckLogin]
@SaCheckRole 角色校验 #[SaCheckRole('admin')]
@SaCheckPermission 权限校验 #[SaCheckPermission('user:add')]
@SaCheckSafe 二级认证校验 #[SaCheckSafe]
@SaCheckHttpBasic HttpBasic 认证 #[SaCheckHttpBasic]
@SaCheckHttpDigest HttpDigest 认证 #[SaCheckHttpDigest]
@SaCheckDisable 服务封禁校验 #[SaCheckDisable('comment')]
@SaCheckSign API 签名校验 #[SaCheckSign]
@SaIgnore 忽略校验 #[SaIgnore]

7.2 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php

namespace app\controller;

use SaToken\Annotation\SaCheckLogin;
use SaToken\Annotation\SaCheckRole;
use SaToken\Annotation\SaCheckPermission;
use SaToken\Annotation\SaCheckSafe;
use SaToken\Annotation\SaCheckDisable;
use SaToken\Annotation\SaIgnore;
use SaToken\Annotation\SaCheckOr;

class UserController
{
/**
* 登录才能访问
*/
#[SaCheckLogin]
public function info()
{
return '查询用户信息';
}

/**
* 必须有 super-admin 角色
*/
#[SaCheckRole('super-admin')]
public function add()
{
return '用户增加';
}

/**
* 必须有 user-add 权限
*/
#[SaCheckPermission('user-add')]
public function delete()
{
return '删除用户';
}

/**
* 二级认证(修改密码前要再验证一次身份)
*/
#[SaCheckSafe]
public function updatePwd()
{
return '修改密码';
}

/**
* 评论服务封禁校验
*/
#[SaCheckDisable('comment')]
public function comment()
{
return '发表评论';
}

/**
* 忽略校验(优先级最高,跳过所有鉴权)
*/
#[SaIgnore]
public function getList()
{
return '公开数据';
}
}

7.3 多权限场景:AND 和 OR 模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use SaToken\Annotation\SaCheckPermission;
use SaToken\SaMode;

/**
* OR 模式:有 user-add 或 user-all 权限的人都能访问
*/
#[SaCheckPermission(value: ['user-add', 'user-all', 'user-delete'], mode: SaMode::OR)]
public function userManage()
{
return '用户管理';
}

/**
* AND 模式(默认):必须同时具备所有权限
*/
#[SaCheckPermission(value: ['user-add', 'user-edit'], mode: SaMode::AND)]
public function userEdit()
{
return '编辑用户';
}

7.4 角色和权限混着校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 有 user.add 权限 或 admin 角色 即可访问
*/
#[SaCheckPermission(value: 'user.add', orRole: 'admin')]
public function userAdd()
{
return '用户信息';
}

/**
* 多个角色:有 admin、manager、staff 中任意一个即可
*/
#[SaCheckPermission(value: 'user.add', orRole: ['admin', 'manager', 'staff'])]
public function userAdd2()
{
return '用户信息';
}

7.5 复杂组合校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use SaToken\Annotation\SaCheckOr;
use SaToken\Annotation\SaCheckLogin;
use SaToken\Annotation\SaCheckRole;
use SaToken\Annotation\SaCheckPermission;
use SaToken\Annotation\SaCheckSafe;

/**
* 满足以下任意一种条件就放行:
* - 已登录 或
* - 有 admin 角色 或
* - 有 user.add 权限
*/
#[SaCheckOr(
login: new SaCheckLogin(),
role: new SaCheckRole('admin'),
permission: new SaCheckPermission('user.add')
)]
public function test()
{
return 'OK';
}

八、路由拦截鉴权

8.1 基础匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use SaToken\SaRouter;
use SaToken\StpUtil;

// 匹配单个路径
SaRouter::match('/api/user/info')->check(function() {
StpUtil::checkLogin();
});

// 匹配多个路径
SaRouter::match(['/api/user/**', '/api/order/**'])->check(function() {
StpUtil::checkLogin();
});

// 匹配路径 + 请求方法
SaRouter::match('/api/user/**', 'POST')->check(function() {
StpUtil::checkPermission('user:add');
});

// 匹配路径 + 多个请求方法
SaRouter::match('/api/user/**', ['POST', 'PUT', 'DELETE'])->check(function() {
StpUtil::checkPermission('user:edit');
});

// 匹配路径 + 多个请求方法 + 多个路径
SaRouter::match(
['/api/user/**', '/api/admin/**'],
['POST', 'PUT', 'DELETE']
)->check(function() {
StpUtil::checkPermission('user:edit');
});

8.2 排除路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 排除指定路径
SaRouter::match('/api/**')
->exclude('/api/login', '/api/register')
->check(function() {
StpUtil::checkLogin();
});

// 排除匹配规则
SaRouter::match('/api/**')
->exclude('/api/public/**')
->exclude('/api/health')
->check(function() {
StpUtil::checkLogin();
});

// 排除多个路径
SaRouter::match('/api/**')
->exclude(['/api/login', '/api/register', '/api/refresh'])
->check(fn() => StpUtil::checkLogin());

8.3 复杂鉴权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 组合校验
SaRouter::match('/admin/**')->check(function() {
StpUtil::checkLogin();
StpUtil::checkRole('admin');
});

// 权限校验 + 二级认证
SaRouter::match('/api/payment/**')->check(function() {
StpUtil::checkLogin();
StpUtil::checkPermission('payment:operate');
StpUtil::checkSafe();
});

// 条件鉴权(根据请求方法)
SaRouter::match('/api/**')->check(function() {
if (request()->isGet()) {
StpUtil::checkPermission('read');
} else {
StpUtil::checkPermission('write');
}
});

// 条件鉴权(根据用户状态)
SaRouter::match('/api/**')->check(function() {
if (StpUtil::isLogin()) {
$userId = StpUtil::getLoginId();
if ($userId > 10000) {
StpUtil::checkPermission('vip');
}
}
});

8.4 链式匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 多个规则链式匹配
SaRouter::match('/api/**')
->exclude('/api/login', '/api/register')
->check(fn() => StpUtil::checkLogin());

SaRouter::match('/api/admin/**')
->check(function() {
StpUtil::checkRole('admin');
});

SaRouter::match('/api/super/**')
->check(function() {
StpUtil::checkRole('super');
});

8.5 在中间件中使用 SaRouter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// app/middleware/SaTokenMiddleware.php

public function handle($request, \Closure $next)
{
try {
// 配置路由拦截规则
SaRouter::match('/**')
->exclude('/login', '/register')
->check(fn() => StpUtil::checkLogin());

SaRouter::match('/admin/**')
->check(function() {
StpUtil::checkLogin();
StpUtil::checkRole('admin');
});

SaRouter::match('/api/v1/**', 'POST')
->check(fn() => StpUtil::checkPermission('api:write'));

} catch (NotLoginException $e) {
return json(['code' => 401, 'msg' => '请先登录']);
} catch (NotPermissionException $e) {
return json(['code' => 403, 'msg' => '权限不足']);
}

return $next($request);
}

九、SSO 单点登录

9.1 SSO 配置 config/sa_token_sso.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

return [
// ========== SSO 模式 ==========
// same-domain: 同域 SSO(所有应用共享父级域名)
// cross-domain: 跨域 SSO(不同域名)
// front-separate: 前后端分离 SSO
// no-sdk: 无 SDK 模式(第三方系统无需集成 SDK)
'mode' => 'cross-domain',

// ========== SSO 服务端配置 ==========
'serverUrl' => 'https://sso.example.com',
'clientId' => 'your-client-id',
'clientSecret' => 'your-client-secret',

// ========== SSO 端点配置 ==========
'loginUrl' => 'https://sso.example.com/login',
'authUrl' => 'https://sso.example.com/oauth/authorize',
'tokenUrl' => 'https://sso.example.com/oauth/token',
'userInfoUrl' => 'https://sso.example.com/oauth/userinfo',
'logoutUrl' => 'https://sso.example.com/logout',

// ========== 回调配置 ==========
'backUrl' => 'https://app.example.com/sso/callback',
'allowDomains' => [
'example.com',
'*.example.com',
'app.example.com',
],

// ========== Token 配置 ==========
'ssoTimeout' => 86400,
'ssoTokenName' => 'ssotoken',

// ========== JWT 配置(无 SDK 模式) ==========
'jwtSecretKey' => 'your-jwt-secret',
'jwtExpire' => 3600,

// ========== 其他配置 ==========
'autoLogin' => true, // 自动登录
'autoRegister' => false, // 自动注册
];

9.2 SSO 控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
<?php

namespace app\controller;

use SaToken\StpUtil;
use SaToken\Sso\SaSsoManager;
use think\facade\Db;

class SsoController
{
/**
* SSO 登录入口
*/
public function login()
{
$sso = SaSsoManager::getInstance();

// 检查是否已有 SSO 会话
if ($sso->checkSsoSession()) {
$userInfo = $sso->getUserInfo();

// 检查本地用户是否存在
$user = Db::name('user')
->where('sso_id', $userInfo['id'])
->find();

if (!$user && config('sa_token_sso.autoRegister')) {
// 自动注册
$userId = Db::name('user')->insertGetId([
'sso_id' => $userInfo['id'],
'username' => $userInfo['username'],
'nickname' => $userInfo['nickname'],
'email' => $userInfo['email'] ?? '',
'status' => 1,
'create_time' => time(),
]);
$user = ['id' => $userId];
}

if ($user) {
// 本地登录
StpUtil::login((string) $user['id']);
return redirect('/dashboard');
}

return redirect('/login?error=user_not_found');
}

// 跳转到 SSO 登录页
return redirect($sso->buildLoginUrl());
}

/**
* SSO 回调
*/
public function callback()
{
$sso = SaSsoManager::getInstance();

try {
$result = $sso->handleCallback();

if ($result['success']) {
// 登录成功
$ssoUser = $result['user'];

// 查找或创建本地用户
$user = Db::name('user')
->where('sso_id', $ssoUser['id'])
->find();

if (!$user) {
// 自动注册
$userId = Db::name('user')->insertGetId([
'sso_id' => $ssoUser['id'],
'username' => $ssoUser['username'] ?? '',
'nickname' => $ssoUser['nickname'] ?? '',
'email' => $ssoUser['email'] ?? '',
'status' => 1,
'create_time' => time(),
]);
} else {
$userId = $user['id'];

// 更新用户信息
Db::name('user')
->where('id', $userId)
->update([
'nickname' => $ssoUser['nickname'] ?? $user['nickname'],
'email' => $ssoUser['email'] ?? $user['email'],
'last_login' => time(),
]);
}

// 本地登录
StpUtil::login((string) $userId);

return json([
'code' => 200,
'msg' => '登录成功',
'data' => [
'token' => StpUtil::getTokenValue(),
'userId' => $userId,
]
]);
}

return json([
'code' => 401,
'msg' => $result['message'] ?? '登录失败'
]);

} catch (\Exception $e) {
return json([
'code' => 500,
'msg' => 'SSO 登录失败:' . $e->getMessage()
]);
}
}

/**
* SSO 登出
*/
public function logout()
{
// 本地登出
StpUtil::logout();

$sso = SaSsoManager::getInstance();
$logoutUrl = $sso->buildLogoutUrl();

return json([
'code' => 200,
'msg' => '已登出',
'data' => ['logoutUrl' => $logoutUrl]
]);
}
}

十、OAuth2.0 认证

10.1 OAuth2 配置 config/sa_token_oauth2.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

return [
// ========== 客户端配置 ==========
'clients' => [
'web-app' => [
'clientId' => 'web-app',
'clientSecret' => 'web-app-secret',
'redirectUris' => [
'https://app.example.com/callback',
'http://localhost:8080/callback',
],
'grantTypes' => ['authorization_code', 'refresh_token'],
'scopes' => ['openid', 'profile', 'email', 'user:read'],
],
'mobile-app' => [
'clientId' => 'mobile-app',
'clientSecret' => 'mobile-app-secret',
'redirectUris' => [
'com.example.app://oauth/callback',
],
'grantTypes' => ['authorization_code', 'password'],
'scopes' => ['openid', 'profile'],
],
'third-party' => [
'clientId' => 'third-party',
'clientSecret' => 'third-party-secret',
'redirectUris' => [
'https://third-party.example.com/callback',
],
'grantTypes' => ['authorization_code'],
'scopes' => ['openid', 'profile', 'email'],
],
],

// ========== Token 配置 ==========
'accessTokenTimeout' => 3600, // AccessToken 有效期(秒)
'refreshTokenTimeout' => 2592000, // RefreshToken 有效期(秒),30天
'codeTimeout' => 300, // 授权码有效期(秒),5分钟

// ========== OpenID Connect 配置 ==========
'openIdMode' => true,
'idTokenTimeout' => 3600,
'jwtSecretKey' => 'your-jwt-secret',
'issuer' => 'https://auth.example.com',
];

10.2 OAuth2 控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
<?php

namespace app\controller;

use SaToken\StpUtil;
use SaToken\OAuth2\SaOAuth2Manager;
use think\facade\Db;

class OAuth2Controller
{
/**
* 授权页面
*/
public function authorize()
{
$oauth2 = SaOAuth2Manager::getInstance();

$clientId = input('get.client_id', '');
$redirectUri = input('get.redirect_uri', '');
$scope = input('get.scope', '');
$state = input('get.state', '');
$responseType = input('get.response_type', 'code');

// 验证客户端
$client = $oauth2->validateClient($clientId, $redirectUri);
if (!$client) {
return json(['error' => 'invalid_client'], 400);
}

// 检查用户是否已登录
if (!StpUtil::isLogin()) {
return redirect('/login?redirect=' . urlencode(request()->url()));
}

// 检查 Scope 是否合法
if ($scope && !$oauth2->validateScope($scope, $client['scopes'])) {
return json(['error' => 'invalid_scope'], 400);
}

// 检查是否是隐式授权模式
if ($responseType === 'token') {
// 隐式模式:直接返回 AccessToken
$accessToken = $oauth2->createAccessToken(
$clientId,
StpUtil::getLoginId(),
$redirectUri,
$scope
);

$redirectUrl = $redirectUri . '#access_token=' . $accessToken;
if ($state) {
$redirectUrl .= '&state=' . $state;
}
return redirect($redirectUrl);
}

// 显示授权确认页面
return view('oauth2/authorize', [
'client' => $client,
'scope' => $scope,
'state' => $state,
'user' => Db::name('user')->find(StpUtil::getLoginId()),
]);
}

/**
* 授权确认(用户同意授权)
*/
public function approve()
{
$oauth2 = SaOAuth2Manager::getInstance();

$clientId = input('post.client_id', '');
$redirectUri = input('post.redirect_uri', '');
$scope = input('post.scope', '');
$state = input('post.state', '');
$approved = input('post.approved', false);

if (!$approved) {
$redirectUrl = $redirectUri . '?error=access_denied';
if ($state) {
$redirectUrl .= '&state=' . $state;
}
return redirect($redirectUrl);
}

try {
// 生成授权码
$code = $oauth2->createAuthorizationCode(
$clientId,
StpUtil::getLoginId(),
$redirectUri,
$scope
);

// 重定向到回调地址
$redirectUrl = $redirectUri . '?code=' . $code;
if ($state) {
$redirectUrl .= '&state=' . $state;
}

return json([
'code' => 200,
'data' => ['redirectUrl' => $redirectUrl]
]);

} catch (\Exception $e) {
return json([
'code' => 400,
'msg' => $e->getMessage()
]);
}
}

/**
* Token 端点(授权码换 Token)
*/
public function token()
{
$oauth2 = SaOAuth2Manager::getInstance();

$grantType = input('post.grant_type', '');
$code = input('post.code', '');
$clientId = input('post.client_id', '');
$clientSecret = input('post.client_secret', '');
$redirectUri = input('post.redirect_uri', '');
$refreshToken = input('post.refresh_token', '');
$username = input('post.username', '');
$password = input('post.password', '');
$scope = input('post.scope', '');

try {
switch ($grantType) {
case 'authorization_code':
// 授权码模式
$result = $oauth2->exchangeTokenByCode(
$code,
$clientId,
$clientSecret,
$redirectUri
);
break;

case 'refresh_token':
// 刷新 Token
$result = $oauth2->refreshToken(
$refreshToken,
$clientId,
$clientSecret
);
break;

case 'password':
// 密码模式
$user = Db::name('user')
->where('username', $username)
->where('status', 1)
->find();

if (!$user || !password_verify($password, $user['password'])) {
return json(['error' => 'invalid_credentials'], 401);
}

$result = $oauth2->createTokenByPassword(
$user['id'],
$clientId,
$clientSecret,
$scope
);
break;

case 'client_credentials':
// 客户端凭证模式
$result = $oauth2->createTokenByClientCredentials(
$clientId,
$clientSecret,
$scope
);
break;

default:
throw new \Exception('unsupported_grant_type');
}

return json($result);

} catch (\Exception $e) {
return json(['error' => $e->getMessage()], 400);
}
}

/**
* 用户信息端点(OIDC)
*/
public function userinfo()
{
$oauth2 = SaOAuth2Manager::getInstance();

$accessToken = input('get.access_token', '');
if (!$accessToken) {
// 尝试从 Header 读取
$authHeader = request()->header('Authorization');
if ($authHeader && preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
$accessToken = $matches[1];
}
}

if (!$accessToken) {
return json(['error' => 'invalid_token'], 401);
}

try {
$userInfo = $oauth2->getUserInfo($accessToken);
return json($userInfo);
} catch (\Exception $e) {
return json(['error' => $e->getMessage()], 401);
}
}
}

十一、安全防护体系

11.1 防暴力破解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* 登录 - 带防暴力破解
*/
public function login(): Json
{
$username = input('post.username', '');
$password = input('post.password', '');

// ========== 防暴力破解检查 ==========
// 检查账号是否被锁定
StpUtil::checkAntiBrute($username);

$user = Db::name('user')
->where('username', $username)
->find();

if (!$user || !password_verify($password, $user['password'])) {
// 记录失败次数
StpUtil::recordAntiBruteFailure($username);

// 获取防暴力破解信息
$info = StpUtil::getAntiBruteInfo($username);

return json([
'code' => 401,
'msg' => "密码错误,剩余尝试次数:{$info['remainingAttempts']}",
'data' => [
'failCount' => $info['failCount'],
'isLocked' => $info['isLocked'],
'remainingAttempts' => $info['remainingAttempts'],
'maxFailures' => $info['maxFailures'],
]
]);
}

// 登录成功,清除失败记录
StpUtil::clearAntiBruteFailure($username);
StpUtil::login((string) $user['id']);

return json(['code' => 200, 'msg' => '登录成功']);
}

// ========== 防暴力破解配置 ==========
// config/sa_token.php
'antiBruteMaxFailures' => 5, // 最大失败次数,超过后锁定
'antiBruteLockDuration' => 600, // 锁定持续时间(秒)

// ========== 防暴力破解 API ==========
// 检查账号是否被锁定
$isLocked = StpUtil::isAccountLocked($username);

// 获取剩余锁定时间
$remaining = StpUtil::getRemainingLockTime($username);

// 手动解锁
StpUtil::unlockAccount($username);

// 获取防暴力破解信息
$info = StpUtil::getAntiBruteInfo($username);

11.2 Token 黑名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ========== Token 黑名单操作 ==========
// 拉黑 Token
StpUtil::addTokenToBlacklist($tokenValue);

// 拉黑 Token 并指定原因
StpUtil::addTokenToBlacklist($tokenValue, '异常登录');

// 移除黑名单
StpUtil::removeTokenFromBlacklist($tokenValue);

// 判断是否在黑名单
$isBlacklisted = StpUtil::isTokenBlacklisted($tokenValue);

// 获取黑名单列表
$blacklist = StpUtil::getBlacklist();

// ========== 批量操作 ==========
$tokens = ['token1', 'token2', 'token3'];
foreach ($tokens as $token) {
StpUtil::addTokenToBlacklist($token);
}

// ========== 自动清理黑名单 ==========
// 黑名单中的 Token 过期后自动清理
// 可以通过定时任务清理

11.3 IP 异常检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use SaToken\StpUtil;

// ========== IP 异常检测 ==========
// 获取登录信息
$info = StpUtil::getLoginInfo(10001);
// [
// 'currentIp' => '192.168.1.1',
// 'lastLoginIp' => '10.0.0.1',
// 'anomalyCount' => 2,
// 'loginHistory' => [
// ['ip' => '192.168.1.1', 'time' => 1700000000],
// ['ip' => '10.0.0.1', 'time' => 1699990000],
// ]
// ]

// 获取异常次数
$count = StpUtil::getAnomalyCount(10001);

// 获取 IP 历史
$history = StpUtil::getIpHistory(10001);

// 清除登录历史
StpUtil::clearLoginHistory(10001);

// ========== 判断当前 IP 是否异常 ==========
if (StpUtil::isIpAnomalous()) {
// 异地登录告警
Log::warning('异地登录:用户 ' . StpUtil::getLoginId() . ' 从 ' . request()->ip() . ' 登录');

// 发送告警通知
$this->sendAlert(StpUtil::getLoginId(), request()->ip());
}

// ========== IP 异常检测配置 ==========
// config/sa_token.php
'ipAnomalyDetection' => true,
'ipAnomalySensitivity' => 3, // 灵敏度:1-5,数值越大越敏感

11.4 设备管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
use SaToken\StpUtil;

// ========== 设备管理 ==========
// 获取设备列表
$devices = StpUtil::getDeviceList(10001);
// [
// [
// 'deviceId' => 'device-xxx',
// 'deviceName' => 'Chrome on Windows',
// 'deviceType' => 'PC',
// 'os' => 'Windows 10',
// 'browser' => 'Chrome 120',
// 'loginIp' => '192.168.1.1',
// 'loginTime' => 1700000000,
// 'lastActivity' => 1700003600,
// 'isCurrent' => true,
// ]
// ]

// 获取设备数量
$count = StpUtil::getDeviceCount(10001);

// 查找设备
$device = StpUtil::findDevice(10001, 'device-id-xxx');

// ========== 设备操作 ==========
// 踢出指定设备
StpUtil::kickoutDevice(10001, 'device-id-xxx');

// 踢出所有设备(保留当前)
$kickedCount = StpUtil::kickoutAllDevices(10001, StpUtil::getTokenValue());

// 重命名设备
StpUtil::renameDevice(10001, 'device-id-xxx', '我的手机');

// ========== 设备管理配置 ==========
// config/sa_token.php
'deviceManagement' => true,

11.5 敏感操作验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
use SaToken\StpUtil;

// ========== OTP 验证码 ==========
// 生成 OTP 验证码
$code = StpUtil::generateOtpCode('payment');
// 发送验证码到用户邮箱/手机...

// 发送 OTP 验证码(自动生成并发送)
$code = StpUtil::sendOtpCode('payment');

// 验证 OTP 验证码
StpUtil::verifyOtpCode('payment', input('post.code'));

// 检查是否已验证
$isVerified = StpUtil::isSensitiveVerified('payment');

// 获取剩余尝试次数
$remaining = StpUtil::getSensitiveVerifyRemainingAttempts('payment');

// 清除验证状态
StpUtil::clearSensitiveVerify('payment');

// ========== 安全令牌 ==========
// 开启安全令牌(有效期 600 秒)
$token = StpUtil::openSensitiveVerify('payment', 600);

// 校验安全令牌
StpUtil::checkSensitiveVerify('payment', input('post.stoken'));

// 获取安全令牌状态
$status = StpUtil::getSensitiveVerifyStatus('payment');

// ========== 敏感操作示例 ==========
public function transfer()
{
StpUtil::checkLogin();
StpUtil::checkPermission('account:transfer');

$amount = input('post.amount');
$targetAccount = input('post.target_account');

// 检查是否已完成敏感操作验证
if (!StpUtil::isSensitiveVerified('transfer')) {
// 生成 OTP 验证码
$code = StpUtil::sendOtpCode('transfer');
return json([
'code' => 403,
'msg' => '需要验证身份',
'data' => ['need_verify' => true]
]);
}

// 执行转账操作
// ...

// 清除验证状态
StpUtil::clearSensitiveVerify('transfer');

return json(['code' => 200, 'msg' => '转账成功']);
}

11.6 Token 指纹绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ========== 启用 Token 指纹绑定 ==========
// config/sa_token.php
'tokenFingerprint' => true,

// ========== 指纹说明 ==========
// 指纹 = IP + User-Agent 的哈希值
// 每次请求自动校验指纹,防止 Token 盗用

// ========== 手动操作 ==========
// 获取当前指纹
$fingerprint = StpUtil::getCurrentFingerprint();

// 手动校验指纹
StpUtil::checkFingerprint();

// 更新指纹(更换设备后)
StpUtil::updateFingerprint();

// ========== 指纹异常处理 ==========
try {
StpUtil::checkFingerprint();
} catch (\Exception $e) {
// 指纹校验失败,可能是 Token 被盗用
Log::alert('Token 指纹校验失败:' . StpUtil::getTokenValue());
StpUtil::kickout(); // 强制下线
return json(['code' => 403, 'msg' => '设备异常,请重新登录']);
}

十二、高级功能

12.1 HTTP Basic/Digest 认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use SaToken\SaToken;

// ========== HTTP Basic 认证 ==========
$auth = SaToken::getHttpAuth();

$auth->setBasicValidator(function (string $username, string $password): mixed {
$user = Db::name('user')
->where('username', $username)
->where('status', 1)
->find();

if ($user && password_verify($password, $user['password'])) {
return $user['id'];
}
return null;
});

// 校验(不通过自动返回 401)
$auth->checkBasic('My API');

// ========== HTTP Digest 认证 ==========
$auth->setDigestValidator(function (string $username): ?string {
// 返回 HA1 = md5(username:realm:password)
$user = Db::name('user')
->where('username', $username)
->find();

if ($user) {
return md5($username . ':My API:' . $user['password']);
}
return null;
});

$auth->checkDigest('My API');

12.2 参数签名校验(SaSign)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use SaToken\SaToken;

$sign = SaToken::getSign();

// ========== 配置 ==========
$sign->setSignKey(config('sa_token.signKey'));
$sign->setSignAlg('sha256'); // md5 / sha256 / sha1
$sign->setTimestampGap(600); // 时间容差 600 秒
$sign->setNonceValidator(function (string $nonce): bool {
// 检查 nonce 是否已使用(防重放攻击)
$key = 'nonce_' . $nonce;
if (StpUtil::getCustomSession($key)) {
return false; // nonce 已使用
}
StpUtil::setCustomSession($key, time(), 3600);
return true;
});

// ========== 生成签名 ==========
$params = [
'userId' => '10001',
'action' => 'query',
'timestamp' => time(),
'nonce' => uniqid(),
'data' => ['key' => 'value'],
];

$signed = $sign->signParams($params);
// 返回:原参数 + sign 字段

// ========== 验证签名 ==========
$params = input('get.');
if (!$sign->verifySign($params)) {
return json(['code' => 403, 'msg' => '签名无效']);
}

// ========== 签名算法说明 ==========
// 1. 参数按 key 升序排列
// 2. 拼接成字符串: key1=value1&key2=value2
// 3. 末尾追加 &signKey=xxx
// 4. 计算哈希值作为签名

12.3 API Key 秘钥授权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use SaToken\SaToken;

$apiKey = SaToken::getApiKey();

// ========== 注册 API Key ==========
// 方式一:直接注册
$apiKey->registerKey('ak-123456', 'sk-abcdef', 10001);
$apiKey->registerKey('ak-789012', 'sk-ghijkl', 10002);

// 方式二:自定义验证器
$apiKey->setValidator(function (string $apiKey, string $apiSecret): mixed {
$key = Db::name('api_keys')
->where('api_key', $apiKey)
->where('status', 1)
->find();

if ($key && hash_equals($key->api_secret, $apiSecret)) {
return $key->user_id;
}
return null;
});

// ========== 校验 API Key ==========
// 从请求头读取:X-Api-Key 和 X-Api-Secret
$apiKey->checkApiKey();

// 校验失败自动返回 401
// 校验成功自动注入用户 ID
$userId = StpUtil::getLoginId();

// ========== 在路由中使用 ==========
SaRouter::match('/api/v2/**')->check(function() {
$apiKey = SaToken::getApiKey();
$apiKey->checkApiKey();
});

12.4 全局过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
use SaToken\SaToken;
use SaToken\SaRouter;
use SaToken\StpUtil;

$filter = SaToken::getGlobalFilter();

// ========== CORS 配置 ==========
$filter->setCors([
'allowOrigin' => '*',
'allowMethods' => 'GET, POST, PUT, DELETE, OPTIONS',
'allowHeaders' => 'Content-Type, Authorization, X-Requested-With',
'allowCredentials' => 'true',
'maxAge' => '3600',
]);

// 动态允许来源
$filter->setCorsOrigin(function($origin) {
$allowOrigins = ['https://example.com', 'https://app.example.com'];
if (in_array($origin, $allowOrigins)) {
return $origin;
}
return '';
});

// ========== 前置过滤器 ==========
$filter->addBeforeFilter(function () {
// 记录请求日志
Log::info('Request: ' . request()->method() . ' ' . request()->url());

// 路由拦截
SaRouter::match('/api/**')->check(fn() => StpUtil::checkLogin());
});

// ========== 后置过滤器 ==========
$filter->addAfterFilter(function ($response) {
// 添加安全响应头
$response->header('X-Content-Type-Options', 'nosniff');
$response->header('X-Frame-Options', 'DENY');
$response->header('X-XSS-Protection', '1; mode=block');

// 记录响应日志
Log::info('Response: ' . $response->getCode());
});

// ========== 执行过滤器 ==========
$filter->execute();

// 处理 CORS 预检请求
if ($filter->isCorsRequest()) {
$filter->handlePreflight();
return;
}

12.5 自定义存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
use SaToken\SaToken;
use SaToken\Dao\SaTokenDaoMemory;
use SaToken\Dao\SaTokenDaoRedis;
use SaToken\Dao\SaTokenDaoPsr16;

// ========== 内存存储(默认) ==========
SaToken::setDao(new SaTokenDaoMemory());

// ========== Redis 存储 ==========
$redisConfig = [
'host' => '127.0.0.1',
'port' => 6379,
'password' => '',
'db' => 0,
'timeout' => 3,
'prefix' => 'sa_token_',
];
SaToken::setDao(new SaTokenDaoRedis($redisConfig));

// ========== 独立 Redis(权限缓存与业务缓存分离) ==========
$dao = SaTokenDaoRedis::createWithSeparateRedis(
['host' => '127.0.0.1', 'port' => 6379, 'db' => 0], // 主 Redis
['host' => '127.0.0.1', 'port' => 6379, 'db' => 1], // 独立 Redis
);
SaToken::setDao($dao);

// ========== PSR-16 适配 ==========
$psr16Cache = new SomePsr16Cache();
SaToken::setDao(new SaTokenDaoPsr16($psr16Cache));

// ========== 实现自定义 DAO ==========
class MyCustomDao implements SaTokenDaoInterface
{
public function get(string $key): ?string
{
// 从自定义存储读取
return MyStorage::get($key);
}

public function set(string $key, string $value, int $ttl): void
{
MyStorage::set($key, $value, $ttl);
}

public function delete(string $key): void
{
MyStorage::delete($key);
}

public function exists(string $key): bool
{
return MyStorage::exists($key);
}

// 实现所有接口方法...
}

12.6 JWT 集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
use SaToken\Plugin\SaTokenJwt;

// ========== 生成 JWT ==========
$payload = [
'userId' => 10001,
'username' => 'admin',
'role' => 'admin',
'exp' => time() + 3600,
'iat' => time(),
'iss' => 'https://example.com',
'aud' => 'https://api.example.com',
];

$jwt = SaTokenJwt::generate($payload);

// ========== 验证 JWT ==========
try {
$payload = SaTokenJwt::verify($jwt);
// 验证成功,获取 payload
$userId = $payload['userId'];
$username = $payload['username'];
} catch (\Exception $e) {
// JWT 无效或过期
return json(['code' => 401, 'msg' => 'JWT 无效']);
}

// ========== 配置 JWT ==========
// config/sa_token.php
return [
'jwtSecretKey' => 'your-jwt-secret',
'jwtStateless' => false, // 无状态模式,不存储到 Redis
];

// ========== JWT + Sa-Token 混合使用 ==========
// 使用 JWT 作为 Token 载体
StpUtil::setTokenGenerator(function($loginId, $extra) {
$payload = [
'loginId' => $loginId,
'extra' => $extra,
'exp' => time() + config('sa_token.timeout'),
];
return SaTokenJwt::generate($payload);
});

12.7 密码加密工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
use SaToken\Plugin\SaTokenCrypto;

// ========== 哈希 ==========
$md5 = SaTokenCrypto::md5('password');
$sha1 = SaTokenCrypto::sha1('password');
$sha256 = SaTokenCrypto::sha256('password');
$sha512 = SaTokenCrypto::sha512('password');

// ========== HMAC ==========
$hmacSha256 = SaTokenCrypto::hmacSha256('data', 'key');
$hmacSha1 = SaTokenCrypto::hmacSha1('data', 'key');

// ========== bcrypt ==========
$hash = SaTokenCrypto::bcryptHash('password', 12);
$valid = SaTokenCrypto::bcryptVerify('password', $hash);

// ========== AES 加解密 ==========
$encrypted = SaTokenCrypto::aesEncrypt('data', '16-byte-key');
$decrypted = SaTokenCrypto::aesDecrypt($encrypted, '16-byte-key');

// AES-256-CBC
$encrypted = SaTokenCrypto::aes256Encrypt('data', '32-byte-key-32-byte-key-');
$decrypted = SaTokenCrypto::aes256Decrypt($encrypted, '32-byte-key-32-byte-key-');

// ========== RSA 加解密 ==========
$publicKey = '...';
$privateKey = '...';
$encrypted = SaTokenCrypto::rsaEncrypt('data', $publicKey);
$decrypted = SaTokenCrypto::rsaDecrypt($encrypted, $privateKey);

// RSA 签名
$signature = SaTokenCrypto::rsaSign('data', $privateKey);
$valid = SaTokenCrypto::rsaVerify('data', $signature, $publicKey);

// ========== 国密 SM ==========
// SM3 哈希
$sm3 = SaTokenCrypto::sm3('data');

// SM4 加解密(需要 ext-openssl 支持)
$encrypted = SaTokenCrypto::sm4Encrypt('data', '16-byte-key-16-byte');
$decrypted = SaTokenCrypto::sm4Decrypt($encrypted, '16-byte-key-16-byte');

// SM2 签名验签
$signature = SaTokenCrypto::sm2Sign('data', $privateKey);
$valid = SaTokenCrypto::sm2Verify('data', $signature, $publicKey);

12.8 审计日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
use SaToken\StpUtil;
use SaToken\Security\SaAuditLog;

// ========== 自动记录 ==========
// 以下操作自动记录审计日志:
// - StpUtil::login() → 登录
// - StpUtil::logout() → 登出
// - StpUtil::kickout() → 踢人
// - StpUtil::disable() → 封禁
// - StpUtil::switchTo() → 身份切换

// ========== 手动记录 ==========
SaAuditLog::logLogin(10001, [
'ip' => request()->ip(),
'userAgent' => request()->header('User-Agent'),
'device' => 'web',
]);

SaAuditLog::logLogout(10001);
SaAuditLog::logKickout(10001, 'admin', '违规操作');
SaAuditLog::logDisable(10001, 'login', '账号异常');
SaAuditLog::logSwitchTo(10001, 20002);
SaAuditLog::logCustom('user_action', 10001, '导出数据', ['count' => 100]);

// ========== 查询日志 ==========
// 获取最新 N 条日志
$logs = StpUtil::getAuditLogs(50);

// 获取单条日志
$log = StpUtil::getAuditLog('log-id-xxx');

// 按事件类型查询
$recentLogs = SaAuditLog::getRecentLogs('login', 100);

// 按 IP 查询
$logsByIp = SaAuditLog::getLogsByIp('192.168.1.1');

// 按事件类型查询
$logsByEvent = SaAuditLog::getLogsByEvent('login');

// 按用户 ID 查询
$userLogs = SaAuditLog::getLogsByUserId(10001);

// 按时间范围查询
$timeRangeLogs = SaAuditLog::getLogsByTimeRange(
strtotime('-7 days'),
time()
);

// ========== 清空日志 ==========
SaAuditLog::clearLogs();

// 清空指定用户日志
SaAuditLog::clearLogsByUser(10001);

// 清空指定事件日志
SaAuditLog::clearLogsByEvent('login');

// ========== 日志结构 ==========
// [
// 'id' => 'xxx',
// 'userId' => 10001,
// 'eventType' => 'login',
// 'ip' => '192.168.1.1',
// 'userAgent' => 'Mozilla/5.0...',
// 'details' => ['device' => 'web'],
// 'timestamp' => 1700000000,
// ]

12.9 事件监听(全局侦听器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
use SaToken\Listener\SaTokenListenerInterface;
use SaToken\SaToken;
use think\facade\Log;

class MySaTokenListener implements SaTokenListenerInterface
{
/**
* 登录事件
*/
public function onLogin(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void
{
Log::info("用户 {$loginId} 登录成功,IP: " . request()->ip());

// 发送欢迎邮件
$this->sendWelcomeEmail($loginId);

// 更新在线状态
$this->updateOnlineStatus($loginId, true);
}

/**
* 登出事件
*/
public function onLogout(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void
{
Log::info("用户 {$loginId} 已登出");

// 更新在线状态
$this->updateOnlineStatus($loginId, false);
}

/**
* 被踢下线事件
*/
public function onKickout(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void
{
Log::warning("用户 {$loginId} 被踢下线,原因:" . ($extra['reason'] ?? '未知'));

// 发送踢下线通知
$this->sendKickoutNotification($loginId, $extra['reason'] ?? '');
}

/**
* 被顶下线事件(同端互斥登录)
*/
public function onReplaced(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void
{
Log::warning("用户 {$loginId} 被顶下线,设备:" . ($extra['device'] ?? '未知'));

// 发送被顶通知
$this->sendReplacedNotification($loginId);
}

/**
* 账号封禁事件
*/
public function onDisable(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void
{
Log::alert("用户 {$loginId} 被封禁,时长:" . ($extra['duration'] ?? '永久'));

// 发送封禁通知
$this->sendDisableNotification($loginId, $extra['duration'] ?? 0);
}

/**
* 账号解封事件
*/
public function onUntieDisable(string $loginType, mixed $loginId, string $tokenValue, array $extra = []): void
{
Log::info("用户 {$loginId} 已解封");
}

/**
* 身份切换事件
*/
public function onSwitchTo(string $loginType, mixed $loginId, mixed $targetId, array $extra = []): void
{
Log::info("用户 {$loginId} 切换到身份 {$targetId}");

// 记录切换日志
$this->logSwitchOperation($loginId, $targetId);
}

/**
* Token 刷新事件
*/
public function onRefresh(string $loginType, mixed $loginId, string $oldToken, string $newToken): void
{
Log::info("用户 {$loginId} 刷新了 Token");
}

private function sendWelcomeEmail($userId): void
{
// 发送欢迎邮件...
}

private function updateOnlineStatus($userId, bool $online): void
{
// 更新在线状态...
}

private function sendKickoutNotification($userId, string $reason): void
{
// 发送踢下线通知...
}

private function sendReplacedNotification($userId): void
{
// 发送被顶通知...
}

private function sendDisableNotification($userId, int $duration): void
{
// 发送封禁通知...
}

private function logSwitchOperation($userId, $targetId): void
{
// 记录切换日志...
}
}

// ========== 注册监听器 ==========
SaToken::addListener(new MySaTokenListener());

// ========== 移除监听器 ==========
SaToken::removeListener(MySaTokenListener::class);

// ========== 清空所有监听器 ==========
SaToken::clearListeners();

十三、异常处理体系

13.1 异常类列表

异常类 触发场景 关联方法
NotLoginException 未登录/Token 无效/过期/被踢 StpUtil::checkLogin()
NotPermissionException 权限校验不通过 StpUtil::checkPermission()
NotRoleException 角色校验不通过 StpUtil::checkRole()
DisableServiceException 账号被封禁 StpUtil::checkLogin()
NotSafeException 二级认证校验不通过 StpUtil::checkSafe()
SaTokenException 其他异常 所有方法

13.2 异常类型详解

1
2
3
4
5
6
7
8
9
use SaToken\Exception\NotLoginException;

// NotLoginException 类型常量
const NOT_LOGIN = 1; // 未登录
const INVALID_TOKEN = 2; // Token 无效
const TOKEN_TIMEOUT = 3; // Token 已过期
const BE_KICKOUT = 4; // 已被踢下线
const BE_REPLACED = 5; // 已被顶下线
const TOKEN_BLACKLIST = 6; // Token 在黑名单

13.3 异常处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<?php

declare(strict_types=1);

namespace app\exception;

use think\exception\Handle;
use think\Response;
use Throwable;
use SaToken\Exception\NotLoginException;
use SaToken\Exception\NotPermissionException;
use SaToken\Exception\NotRoleException;
use SaToken\Exception\DisableServiceException;
use SaToken\Exception\NotSafeException;
use think\facade\Log;

class Handler extends Handle
{
protected array $ignoreReport = [
NotLoginException::class,
NotPermissionException::class,
NotRoleException::class,
DisableServiceException::class,
NotSafeException::class,
];

public function render($request, Throwable $e): Response
{
// ========== Sa-Token 异常统一处理 ==========
if ($e instanceof NotLoginException) {
$messages = [
NotLoginException::NOT_LOGIN => '请先登录',
NotLoginException::INVALID_TOKEN => 'Token 无效',
NotLoginException::TOKEN_TIMEOUT => 'Token 已过期,请重新登录',
NotLoginException::BE_KICKOUT => '已被踢下线',
NotLoginException::BE_REPLACED => '已被顶下线',
NotLoginException::TOKEN_BLACKLIST => 'Token 已被拉黑',
];

return json([
'code' => 401,
'msg' => $messages[$e->getType()] ?? '未登录',
'type' => $e->getType(),
], 401);
}

if ($e instanceof NotPermissionException) {
return json([
'code' => 403,
'msg' => '权限不足:' . $e->getPermission(),
'need' => $e->getPermission(),
], 403);
}

if ($e instanceof NotRoleException) {
return json([
'code' => 403,
'msg' => '角色不足:' . $e->getRole(),
'need' => $e->getRole(),
], 403);
}

if ($e instanceof DisableServiceException) {
$remaining = StpUtil::getDisableTime();
return json([
'code' => 403,
'msg' => "账号已被封禁,剩余 {$remaining} 秒",
'remaining' => $remaining,
], 403);
}

if ($e instanceof NotSafeException) {
return json([
'code' => 403,
'msg' => '需要二级认证',
'need_safe' => true,
], 403);
}

// ========== 其他异常 ==========
if (!$this->isDebug()) {
Log::error('系统异常:' . $e->getMessage() . "\n" . $e->getTraceAsString());
return json([
'code' => 500,
'msg' => '服务器内部错误',
], 500);
}

return parent::render($request, $e);
}

protected function isDebug(): bool
{
return (bool) config('app.debug');
}
}

十四、完整配置参考

14.1 完整配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<?php

return [
// ==================== Token 基础配置 ====================
'tokenName' => 'satoken',
'tokenPrefix' => 'Bearer',
'tokenStyle' => 'uuid',

// ==================== 有效期配置 ====================
'timeout' => 86400,
'activityTimeout' => -1,

// ==================== Cookie 配置 ====================
'cookieName' => 'satoken',
'cookieDomain' => '',
'cookiePath' => '/',
'cookieSecure' => false,
'cookieHttpOnly' => true,
'cookieSameSite' => 'Strict',
'cookieMaxAge' => 86400,
'cookiePrefix' => '',

// ==================== 读取/写入配置 ====================
'isReadHeader' => true,
'isReadCookie' => true,
'isReadBody' => false,
'isWriteCookie' => true,
'isWriteHeader' => false,

// ==================== 登录策略 ====================
'concurrent' => true,
'isShare' => true,
'maxLoginCount' => 12,
'maxTryTimes' => 12,

// ==================== 存储配置 ====================
'storage' => 'memory',
'redis' => [
'host' => '127.0.0.1',
'port' => 6379,
'password' => '',
'db' => 0,
'timeout' => 3,
'prefix' => 'sa_token_',
],

// ==================== Session 配置 ====================
'session' => [
'timeout' => 86400,
'activityTimeout' => 1800,
'maxSize' => 1024,
'prefix' => 'session_',
'cleanInterval' => 3600,
],

// ==================== 加密配置 ====================
'cryptoType' => 'intl',
'tokenEncrypt' => false,
'tokenEncryptKey' => '',
'tokenFingerprint' => false,

// ==================== Refresh Token ====================
'refreshToken' => false,
'refreshTokenTimeout' => 2592000,
'refreshTokenRotation' => true,

// ==================== 安全配置 ====================
'antiBruteMaxFailures' => 5,
'antiBruteLockDuration' => 600,
'ipAnomalyDetection' => true,
'ipAnomalySensitivity' => 3,
'deviceManagement' => true,
'auditLog' => true,
'auditLogMaxEntries' => 1000,
'auditLogTtlDays' => 30,

// ==================== JWT 配置 ====================
'jwtSecretKey' => '',
'jwtStateless' => false,

// ==================== 签名配置 ====================
'signKey' => '',
'signTimestampGap' => 600,
'signAlg' => 'sha256',

// ==================== 日志配置 ====================
'isLog' => false,
];

// ==================== 多应用配置 ====================
// config/sa_token.php 中可添加
'apps' => [
'api' => [
'timeout' => 7200,
'isReadCookie' => false,
'redis' => ['prefix' => 'sa_token_api_'],
],
'admin' => [
'timeout' => 28800,
'concurrent' => false,
'redis' => ['prefix' => 'sa_token_admin_'],
],
],

十五、项目结构与部署指南

15.1 单应用项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
tp8-sa-token/
├── app/
│ ├── controller/
│ │ ├── AuthController.php
│ │ ├── UserController.php
│ │ └── ...
│ ├── middleware/
│ │ └── SaTokenMiddleware.php
│ ├── exception/
│ │ └── Handler.php
│ ├── service/
│ │ └── PermissionService.php
│ └── provider/
│ └── SaTokenProvider.php
├── config/
│ ├── sa_token.php
│ ├── sa_token_sso.php
│ ├── sa_token_oauth2.php
│ ├── middleware.php
│ └── service.php
├── route/
│ └── app.php
├── public/
│ └── index.php
├── .env
└── composer.json

15.2 多应用项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
tp8-sa-token/
├── app/
│ ├── common/
│ │ ├── middleware/
│ │ │ └── SaTokenMiddleware.php
│ │ ├── service/
│ │ │ └── PermissionService.php
│ │ ├── exception/
│ │ │ └── Handler.php
│ │ └── provider/
│ │ └── SaTokenProvider.php
│ ├── api/
│ │ ├── controller/
│ │ │ ├── AuthController.php
│ │ │ └── UserController.php
│ │ ├── config/
│ │ │ └── sa_token.php
│ │ └── route/
│ │ └── app.php
│ ├── admin/
│ │ ├── controller/
│ │ │ ├── AuthController.php
│ │ │ └── AdminController.php
│ │ ├── config/
│ │ │ └── sa_token.php
│ │ └── route/
│ │ └── app.php
│ └── provider.php
├── config/
│ └── sa_token.php
└── .env

15.3 四种模式部署检查清单

检查项 单应用+Redis 单应用+内存 多应用+Redis 多应用+内存
配置文件 config/sa_token.php
Redis 服务运行
Redis 扩展安装
服务提供者注册
权限服务实现
中间件注册
路由配置
异常处理器
多应用扩展
数据库迁移

十六、API 快速参考手册

16.1 StpUtil 核心方法

方法 说明
login($loginId, $extra) 登录
logout() 登出
isLogin() 判断是否登录
getLoginId() 获取登录 ID
getTokenValue() 获取 Token
getTokenTimeout() 获取 Token 剩余有效期
refreshToken() 刷新 Token
kickout($loginId) 踢人下线
disable($loginId, $time) 封禁账号
untieDisable($loginId) 解封账号
checkPermission($perm) 校验权限
checkRole($role) 校验角色
getPermissionList() 获取权限列表
getRoleList() 获取角色列表
switchTo($loginId) 身份切换
switchBack() 切回原身份
setSession($key, $value) 设置 Session
getSession($key) 获取 Session
getExtra($key) 获取扩展信息
updateExtra($key, $value) 更新扩展信息
getTokenInfo() 获取 Token 完整信息
getLoginDevice() 获取登录设备
getLoginType() 获取登录类型
checkToken($token) 检查 Token 有效性
getLoginIdByToken($token) 通过 Token 获取 LoginId
replaceToken() 替换 Token
addTokenToBlacklist($token) 加入黑名单
removeTokenFromBlacklist($token) 移除黑名单
isTokenBlacklisted($token) 判断是否在黑名单
disableService($loginId, $service) 封禁服务
untieDisableService($loginId, $service) 解封服务
isDisableService($loginId, $service) 判断服务是否被封禁
getDisableServiceTime($loginId, $service) 获取服务封禁剩余时间
openSafe() 开启二级认证
checkSafe() 校验二级认证
isSafe() 判断二级认证状态
closeSafe() 关闭二级认证
getSafeTimeout() 获取二级认证剩余时间

16.2 Session 操作方法

方法 说明
setSession($key, $value) 设置 Session
getSession($key, $default) 获取 Session
hasSession($key) 判断 Session 是否存在
deleteSession($key) 删除 Session
clearSession() 清空所有 Session
getSessionKeys() 获取所有 Session Key
setTokenSession($key, $value) 设置 Token Session
getTokenSession($key) 获取 Token Session
deleteTokenSession($key) 删除 Token Session
setSessionTimeout($key, $timeout) 设置 Session 有效期
getSessionTimeout($key) 获取 Session 剩余时间
refreshSession($key) 刷新 Session 有效期
clearUserSession($loginId) 清理用户 Session

16.3 安全防护方法

方法 说明
checkAntiBrute($key) 检查防暴力破解
recordAntiBruteFailure($key) 记录失败次数
clearAntiBruteFailure($key) 清除失败记录
getAntiBruteInfo($key) 获取防暴力破解信息
isAccountLocked($key) 判断账号是否锁定
getRemainingLockTime($key) 获取剩余锁定时间
unlockAccount($key) 解锁账号
getLoginInfo($loginId) 获取登录信息
getAnomalyCount($loginId) 获取异常次数
getIpHistory($loginId) 获取 IP 历史
clearLoginHistory($loginId) 清除登录历史
isIpAnomalous() 判断 IP 是否异常
getDeviceList($loginId) 获取设备列表
getDeviceCount($loginId) 获取设备数量
kickoutDevice($loginId, $deviceId) 踢出设备
kickoutAllDevices($loginId, $currentToken) 踢出所有设备
generateOtpCode($service) 生成 OTP 验证码
sendOtpCode($service) 发送 OTP 验证码
verifyOtpCode($service, $code) 验证 OTP 验证码
isSensitiveVerified($service) 检查是否已验证
clearSensitiveVerify($service) 清除验证状态
openSensitiveVerify($service, $timeout) 开启安全令牌
checkSensitiveVerify($service, $token) 校验安全令牌

16.4 SaRouter 核心方法

方法 说明
match($path, $method) 匹配路由
exclude($path) 排除路径
check($callback) 执行校验
addBeforeFilter($callback) 添加前置过滤器
addAfterFilter($callback) 添加后置过滤器

16.5 异常类

异常类 说明
NotLoginException 未登录异常
NotPermissionException 无权限异常
NotRoleException 无角色异常
DisableServiceException 账号封禁异常
NotSafeException 二级认证异常
SaTokenException 基础异常

十七、常见问题与解决方案

17.1 配置与初始化问题

Q1: 为什么 Sa-Token 不生效?

A: 检查以下几点:

  1. 是否正确注册了服务提供者 app/provider/SaTokenProvider.php
  2. 是否在 config/service.php 中注册了服务提供者
  3. 是否正确加载了配置文件 config/sa_token.php
  4. 是否注册了中间件 config/middleware.php
  5. 是否在路由中正确使用了中间件

Q2: Token 没有自动写入 Cookie?

A: 检查配置:

1
2
3
4
'isWriteCookie' => true,   // 必须为 true
'cookieSecure' => false, // 如果不是 HTTPS,必须为 false
'cookieDomain' => '', // 确保域名正确
'cookiePath' => '/', // 确保路径匹配

Q3: 如何在多应用中使用不同的配置?

A: 使用多应用+Redis/内存模式,各应用在 app/{app}/config/sa_token.php 中配置差异化参数。配置会与全局配置合并。

Q4: 注解鉴权为什么不生效?

A: 注解鉴权需要确保:

  1. 正确使用了注解标签
  2. 注解类已正确导入
  3. 中间件已正确注册
  4. PHP 版本支持注解(PHP 8+)

Q5: Cookie 和 Session 的数据存储在哪里?

A: Cookie 存储在客户端浏览器中,Session 存储在服务端(Redis 或内存)。具体位置由 storage 配置决定。

Q6: Session 数据会丢失吗?

A: 内存模式下应用重启会丢失,Redis 模式下不会丢失。生产环境强烈推荐使用 Redis 模式。

Q7: 如何跨域共享 Cookie?

A:

  1. 配置 cookieDomain = '.example.com'
  2. 配置 cookieSameSite = 'Lax''None'
  3. 如使用 None,必须设置 cookieSecure = true
  4. 前端请求需配置 credentials: 'include'

Q8: 前后端分离如何使用 Session?

A: 建议关闭 Cookie,使用 Header 传递 Token:

1
2
3
4
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,
'isWriteHeader' => true,

17.3 Token 相关

Q9: Token 过期了怎么办?

A:

  1. 启用 RefreshToken:refreshToken => true
  2. 客户端调用刷新接口获取新 Token
  3. 或重新登录

Q10: 如何实现 Token 自动续签?

A: 配置 activityTimeout > 0,每次请求自动刷新 Token 有效期。

Q11: 如何实现同端互斥登录?

A: 设置 concurrent => false,同一账号在同一设备类型只能登录一个。

Q12: Token 风格有哪些?

A: 支持以下风格:

  • uuid: UUID 格式 (550e8400-e29b-41d4-a716-446655440000)
  • simple-uuid: 简化 UUID (550e8400e29b41d4a716446655440000)
  • random-32: 32位随机字符串
  • random-64: 64位随机字符串
  • random-128: 128位随机字符串
  • tik: 24位随机字符串

17.4 性能与安全

Q13: 生产环境推荐哪种模式?

A: 强烈推荐 单应用+Redis多应用+Redis,支持数据持久化和分布式部署。

Q14: 如何防止暴力破解?

A: 启用防暴力破解:

1
2
'antiBruteMaxFailures' => 5,
'antiBruteLockDuration' => 600,

Q15: 如何防止 Token 盗用?

A:

  1. 启用 Token 指纹绑定:tokenFingerprint => true
  2. 使用 HTTPS + Secure Cookie
  3. 设置合理的 Token 有效期
  4. 敏感操作使用二级认证

Q16: 性能优化建议?

A:

  1. 使用 Redis 存储替代内存存储
  2. 权限数据使用 Session 缓存
  3. 合理设置 activityTimeout
  4. 生产环境关闭调试日志
  5. 使用独立 Redis 实例存储认证数据

17.5 多应用

Q17: 多应用如何共享用户信息?

A: 使用多应用+Redis 模式,各应用共享 Redis 存储。Token 在应用间自动共享。

Q18: 多应用如何隔离数据?

A: 使用不同的 Redis key 前缀:

1
'redis' => ['prefix' => 'sa_token_api_'],

各应用数据通过前缀隔离,但 Token 验证逻辑共享。

Q19: 多应用如何实现各自独立的权限体系?

A: 在 app/{app}/service/ 中创建各自的权限服务类,在服务提供者中根据应用名注入不同的服务。

Q20: 内存模式可以跨应用共享会话吗?

A: 不可以。内存模式各应用数据独立,无法共享。多应用内存模式仅适合开发测试。


项目地址: https://github.com/pohoc/sa-token
ThinkPHP 8 文档: https://www.thinkphp.cn/doc
Sa-Token 官方文档: https://sa-token.cc
问题反馈: GitHub Issues