Sa-Token PHP 使用手册

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

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

目录

一、框架概述

1.1 什么是 Sa-Token?

Sa-Token 是一个轻量级权限认证框架,专为 PHP 8.1+ 设计,完美适配 ThinkPHP 8.0+。登录认证只需一行代码:

1
StpUtil::login(10001);

权限校验也只需一行:

1
StpUtil::checkPermission('user:add');

1.2 核心功能矩阵

类别 功能 说明
登录认证 单端登录 一个账号只能在一个设备登录
多端登录 一个账号可在多个设备同时登录
同端互斥登录 同一设备类型只能登录一个
记住我 延长登录有效期
踢人下线 强制指定用户下线
账号封禁 禁止账号登录指定时长
服务封禁 封禁特定服务如评论、发帖
权限认证 权限校验 判断用户是否拥有某权限
角色校验 判断用户是否拥有某角色
二级认证 敏感操作二次身份验证
身份切换 临时切换到其他用户身份
注解式鉴权 通过注解声明鉴权规则
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.3 核心术语

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

二、环境要求与安装

2.1 环境要求

1
2
3
4
5
PHP >= 8.1
ThinkPHP >= 8.0
ext-openssl(必需)
ext-json(必需)
ext-redis(Redis 模式必需)

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
composer require predis/predis

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

序号 文件路径 说明 优先级
1 config/sa_token.php 主配置文件 ✅ 必须
2 config/sa_token_sso.php SSO 配置 ⚠️ 按需
3 config/sa_token_oauth2.php OAuth2.0 配置 ⚠️ 按需
4 app/service/PermissionService.php 权限数据提供者 ✅ 必须
5 app/middleware/SaTokenMiddleware.php 鉴权中间件 ✅ 推荐
6 app/exception/Handler.php 异常处理器 ✅ 推荐
7 app/provider/SaTokenProvider.php 服务提供者 ✅ 推荐
8 config/middleware.php 中间件注册配置 ✅ 必须
9 config/service.php 服务提供者注册 ✅ 必须
10 route/app.php 路由配置 ✅ 必须

三、四种部署模式详解

3.1 单应用 + Redis 模式

3.1.1 场景说明

适用于生产环境的单体应用,使用 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
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
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
<?php

return [
// ==================== Token 基础配置 ====================
// tokenName: Token 名称,同时也是 Cookie 名称
'tokenName' => 'satoken',

// tokenPrefix: Token 前缀,如 Bearer
'tokenPrefix' => 'Bearer',

// tokenStyle: Token 风格
// uuid: UUID 格式 (550e8400-e29b-41d4-a716-446655440000)
// simple-uuid: 简化 UUID (550e8400e29b41d4a716446655440000)
// random-32: 32位随机字符串
// random-64: 64位随机字符串
// random-128: 128位随机字符串
// tik: 24位随机字符串
'tokenStyle' => 'uuid',

// ==================== 有效期配置 ====================
// timeout: Token 有效期(秒),默认30天,-1代表永不过期
'timeout' => 86400,

// activityTimeout: 最低活跃频率(秒),-1不限制
// 设置为正数时,每次请求会刷新 Token 有效期
'activityTimeout' => -1,

// ==================== Cookie 详细配置 ====================
// cookieName: Cookie 名称(默认与 tokenName 一致)
'cookieName' => 'satoken',

// cookieDomain: Cookie 域名,空表示当前域名
// 设置为 .example.com 可在子域名间共享
'cookieDomain' => '',

// cookiePath: Cookie 路径,'/' 表示整个网站都有效
'cookiePath' => '/',

// cookieSecure: 是否仅通过 HTTPS 传输,生产环境建议 true
'cookieSecure' => false,

// cookieHttpOnly: 是否禁止 JavaScript 读取 Cookie,建议 true 防止 XSS
'cookieHttpOnly' => true,

// cookieSameSite: CSRF 防护策略
// Strict: 最安全,Lax: 兼容性更好,None: 需要配合 Secure
'cookieSameSite' => 'Strict',

// cookieMaxAge: Cookie 在客户端的最大存活时间(秒)
'cookieMaxAge' => 86400,

// cookiePrefix: Cookie 前缀,如 __Secure- 增强安全性
'cookiePrefix' => '',

// ==================== 读取/写入配置 ====================
// isReadHeader: 是否从 Header 读取 Token
'isReadHeader' => true,

// isReadCookie: 是否从 Cookie 读取 Token
'isReadCookie' => true,

// isReadBody: 是否从请求体读取 Token
'isReadBody' => false,

// isWriteCookie: 登录后是否写入 Cookie
'isWriteCookie' => true,

// isWriteHeader: 登录后是否写入响应头
'isWriteHeader' => false,

// ==================== 登录策略 ====================
// concurrent: 是否允许多端同时登录
'concurrent' => true,

// isShare: 同端是否复用 Token
'isShare' => true,

// maxLoginCount: 同账号最大登录数,-1不限
'maxLoginCount' => 12,

// maxTryTimes: 创建 Token 最高循环次数
'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(国际)/ sm(国密)
'cryptoType' => 'intl',

// tokenEncrypt: 是否启用 Token 内容加密
'tokenEncrypt' => false,

// tokenEncryptKey: Token 加密密钥(16/24/32 字节)
'tokenEncryptKey' => '',

// tokenFingerprint: 是否启用 Token 指纹绑定(IP + User-Agent)
'tokenFingerprint' => false,

// ==================== Refresh Token ====================
// refreshToken: 是否启用 RefreshToken
'refreshToken' => false,

// refreshTokenTimeout: RefreshToken 有效期(秒),默认30天
'refreshTokenTimeout' => 2592000,

// refreshTokenRotation: 刷新时是否轮换 RefreshToken
'refreshTokenRotation' => true,

// ==================== 安全配置 ====================
// antiBruteMaxFailures: 防暴力破解最大失败次数,0不限制
'antiBruteMaxFailures' => 5,

// antiBruteLockDuration: 锁定持续时间(秒)
'antiBruteLockDuration' => 600,

// ipAnomalyDetection: IP 异常检测
'ipAnomalyDetection' => true,

// ipAnomalySensitivity: IP 异常灵敏度(1-5)
'ipAnomalySensitivity' => 3,

// deviceManagement: 设备管理
'deviceManagement' => true,

// auditLog: 审计日志
'auditLog' => true,

// auditLogMaxEntries: 每种事件最大日志条数
'auditLogMaxEntries' => 1000,

// auditLogTtlDays: 日志保留天数
'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
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
<?php

declare(strict_types=1);

namespace app\service;

use think\facade\Db;

/**
* 权限数据提供者
* 负责从数据库获取用户的权限和角色信息
*/
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;
}

/**
* 根据用户ID获取用户信息
*
* @param int $userId 用户ID
* @return array|null 用户信息
*/
public function getUserById(int $userId): ?array
{
return Db::name('user')->find($userId);
}

/**
* 检查用户是否有指定权限
*
* @param int $userId 用户ID
* @param string $permission 权限码
* @return bool
*/
public function hasPermission(int $userId, string $permission): bool
{
$permissions = $this->getAllPermissions($userId);
return in_array($permission, $permissions);
}

/**
* 检查用户是否有指定角色
*
* @param int $userId 用户ID
* @param string $role 角色码
* @return bool
*/
public function hasRole(int $userId, string $role): bool
{
$roles = $this->getRoleList($userId);
return in_array($role, $roles);
}
}

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
47
48
49
50
51
52
53
54
55
<?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. 注入权限提供者
$this->injectPermissionGetter();

// 5. 注册事件监听器(可选)
$this->registerListeners();
}

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);
});
}

protected function registerListeners(): void
{
// 注册事件监听器
// SaToken::addListener(new MySaTokenListener());
}
}

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
82
83
84
85
86
87
88
<?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 请求特殊校验:/api/search/** 需要登录
SaRouter::match('/api/search/**', 'GET')
->check(function() {
StpUtil::checkLogin();
});

// 6. POST 请求特殊校验:/api/upload/** 需要 upload 权限
SaRouter::match('/api/upload/**', 'POST')
->check(function() {
StpUtil::checkPermission('upload');
});

} 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
23
24
25
<?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');

// ========== 公开API ==========
Route::get('api/public', 'Public/index');

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
63
64
65
66
67
68
69
70
71
-- 用户表
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),
`avatar` VARCHAR(255),
`status` TINYINT DEFAULT 1,
`last_login` INT,
`last_ip` VARCHAR(45),
`create_time` INT,
`update_time` INT,
INDEX `idx_username` (`username`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 用户权限表
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`),
INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户权限表';

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

-- 用户角色关联表
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`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';

-- 角色权限表
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`),
INDEX `idx_role_code` (`role_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限表';

-- 审计日志表
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,
INDEX `idx_user_id` (`user_id`),
INDEX `idx_event_type` (`event_type`),
INDEX `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志表';

3.2 单应用 + 内存模式

3.2.1 场景说明

适用于开发测试环境,使用内存作为存储介质,无需 Redis。

3.2.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
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
<?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,

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

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

// ==================== 存储配置(内存模式) ====================
'storage' => 'memory',

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

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

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

// ==================== 安全配置 ====================
'antiBruteMaxFailures' => 5,
'antiBruteLockDuration' => 600,
'ipAnomalyDetection' => true,
'deviceManagement' => true,
'auditLog' => true,

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

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

'isLog' => true,
];

3.2.3 服务提供者 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
<?php

declare(strict_types=1);

namespace app\provider;

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

class SaTokenProvider extends Service
{
public function register(): void
{
$config = config('sa_token');
SaToken::init($config);

// ========== 使用内存存储 ==========
// 所有会话数据存储在 PHP 内存中
// 应用重启后数据全部丢失
SaToken::setDao(new SaTokenDaoMemory());

$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.2.4 内存模式注意事项

1
2
3
4
5
6
7
8
9
10
11
12
// ========== 内存模式特点 ==========
// 1. 应用重启后所有会话丢失,用户需重新登录
// 2. 不支持分布式部署
// 3. 适合开发测试环境
// 4. 数据存储在 SaTokenDaoMemory 类的静态属性中
// 5. 进程内共享,进程重启丢失

// ========== 内存模式适用场景 ==========
// - 本地开发调试
// - 单元测试
// - 演示/原型项目
// - 低并发内部工具

其余文件(app/service/PermissionService.phpapp/middleware/SaTokenMiddleware.phpconfig/service.phpconfig/middleware.phproute/app.php)与 3.1 节相同。

3.3 多应用 + Redis 模式

3.3.1 场景说明

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

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
tp8-sa-token/
├── app/
│ ├── common/
│ │ ├── middleware/
│ │ │ └── SaTokenMiddleware.php
│ │ ├── service/
│ │ │ └── PermissionService.php
│ │ ├── exception/
│ │ │ └── Handler.php
│ │ └── provider/
│ │ └── SaTokenProvider.php
│ ├── api/
│ │ ├── controller/
│ │ │ └── AuthController.php
│ │ ├── config/
│ │ │ └── sa_token.php
│ │ └── route/
│ │ └── app.php
│ ├── admin/
│ │ ├── controller/
│ │ │ └── AuthController.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
<?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' => [
// API Token 有效期 2 小时
'timeout' => 7200,
'refreshToken' => true,
'refreshTokenTimeout' => 2592000,
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,
'isWriteHeader' => true,
'tokenFingerprint' => true,
'redis' => ['prefix' => 'sa_token_api_'],
],
'admin' => [
// Admin Token 有效期 8 小时
'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.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
64
<?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
<?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'],
],
];

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 json(['code' => 401, 'msg' => '请先登录'], 401);
} catch (NotPermissionException $e) {
return json(['code' => 403, 'msg' => '权限不足:' . $e->getPermission()], 403);
} catch (NotRoleException $e) {
return json(['code' => 403, 'msg' => '角色不足:' . $e->getRole()], 403);
} catch (\Exception $e) {
Log::error('SaToken 鉴权异常:' . $e->getMessage());
return json(['code' => 500, 'msg' => '服务器内部错误'], 500);
}

return $next($request);
}
}

3.3.9 注册服务提供者 app/provider.php

1
2
3
4
5
6
7
<?php

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

3.3.10 API 应用路由 app/api/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::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');

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

1
2
3
4
5
6
7
8
9
10
11
<?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.12 API 权限服务 app/api/service/ApiPermissionService.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
<?php

declare(strict_types=1);

namespace app\api\service;

use think\facade\Db;

class ApiPermissionService
{
public function getPermissionList(int $userId): array
{
return Db::name('user_permission')
->where('user_id', $userId)
->column('permission_code') ?: [];
}

public function getRoleList(int $userId): array
{
return Db::name('user_role')
->alias('ur')
->join('role r', 'ur.role_id = r.id')
->where('ur.user_id', $userId)
->column('r.role_code') ?: [];
}

public function getAllPermissions(int $userId): array
{
return $this->getPermissionList($userId);
}

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.3.13 Admin 权限服务 app/admin/service/AdminPermissionService.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
<?php

declare(strict_types=1);

namespace app\admin\service;

use think\facade\Db;

class AdminPermissionService
{
public function getPermissionList(int $adminId): array
{
$perms = Db::name('admin_permission')
->where('admin_id', $adminId)
->column('permission_code');
return $perms ?: [];
}

public function getRoleList(int $adminId): array
{
$roles = Db::name('admin_role')
->alias('ar')
->join('role r', 'ar.role_id = r.id')
->where('ar.admin_id', $adminId)
->column('r.role_code');
return $roles ?: [];
}

public function getAllPermissions(int $adminId): array
{
$roles = $this->getRoleList($adminId);
$perms = [];

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

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

return array_unique($perms);
}

public function verifyPassword(string $username, string $password): ?array
{
$user = Db::name('admin')
->where('username', $username)
->where('status', 1)
->find();

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

3.4 多应用 + 内存模式

3.4.1 全局配置 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
<?php

return [
'tokenName' => 'satoken',
'tokenPrefix' => 'Bearer',
'tokenStyle' => 'uuid',
'cookieDomain' => '',
'cookiePath' => '/',
'cookieSecure' => false,
'cookieHttpOnly' => true,
'cookieSameSite' => 'Strict',

// ========== 存储配置(内存模式) ==========
'storage' => 'memory',

'session' => [
'timeout' => 86400,
'activityTimeout' => 1800,
'maxSize' => 1024,
],
'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,
],
],
];

3.4.2 公共服务提供者 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
<?php

declare(strict_types=1);

namespace app\common\provider;

use think\Service;
use SaToken\SaToken;
use SaToken\StpUtil;
use SaToken\Dao\SaTokenDaoMemory;
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)) {
return array_merge($baseConfig, include $appConfigFile);
}
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.5 四种模式完整对比

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

四、登录认证

4.1 创建认证控制器 app/controller/AuthController.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
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
<?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()
]);
}
}

/**
* 注册账号
*/
public function register(): Json
{
$username = input('post.username', '');
$password = input('post.password', '');
$nickname = input('post.nickname', '');

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

if (strlen($password) < 6) {
return json(['code' => 400, 'msg' => '密码长度不能少于6位']);
}

$exist = Db::name('user')->where('username', $username)->find();
if ($exist) {
return json(['code' => 400, 'msg' => '用户名已存在']);
}

$userId = Db::name('user')->insertGetId([
'username' => $username,
'password' => password_hash($password, PASSWORD_BCRYPT),
'nickname' => $nickname ?: $username,
'status' => 1,
'create_time' => time(),
'update_time' => time(),
]);

StpUtil::login((string) $userId);
$token = StpUtil::getTokenValue();

return json([
'code' => 200,
'msg' => '注册成功',
'data' => [
'userId' => $userId,
'token' => $token,
]
]);
}
}

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 管理

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

return [
'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
cookieDomain string '' Cookie 的作用域,设置如 .example.com 可在子域名间共享
cookiePath string '/' Cookie 的路径,'/' 表示整个网站都有效
cookieSecure bool false 是否仅通过 HTTPS 传输,生产环境建议 true
cookieHttpOnly bool true 是否禁止 JavaScript 读取 Cookie,建议 true 防止 XSS
cookieSameSite string 'Strict' CSRF 防护策略:Strict/Lax/None
cookieMaxAge int 86400 Cookie 在客户端的最大存活时间(秒)
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
// ========== 场景1:前后端分离(API 模式) ==========
'isReadCookie' => false,
'isWriteCookie' => false,
'isReadHeader' => true,
'isWriteHeader' => true,

// ========== 场景2:传统 Web 应用(Session 模式) ==========
'isReadCookie' => true,
'isWriteCookie' => true,
'cookieHttpOnly' => true,
'cookieSecure' => false,
'cookieSameSite' => 'Lax',

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

// ========== 场景4:后台管理系统(高安全) ==========
'cookieSecure' => true,
'cookiePath' => '/admin',
'cookieHttpOnly' => true,
'cookieSameSite' => 'Strict',

// ========== 场景5: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
// ========== 后端配置 ==========
'cookieSameSite' => 'None',
'cookieSecure' => true, // SameSite=None 时必须为 true

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

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

6.2 Session 管理详解

6.2.1 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
// ========== 账号 Session(所有设备共享) ==========
StpUtil::setSession('user_info', ['name' => '张三']);
$userInfo = StpUtil::getSession('user_info');
StpUtil::deleteSession('user_info');
if (StpUtil::hasSession('user_info')) {}
$session = StpUtil::getSession();

// ========== Token Session(仅当前 Token 独享) ==========
StpUtil::setTokenSession('temp_data', ['key' => 'value']);
$tempData = StpUtil::getTokenSession('temp_data');
StpUtil::deleteTokenSession('temp_data');

// ========== 自定义 Session(任意 Key) ==========
$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
// ========== Session 存储 ==========
StpUtil::setSession('user_id', 10001);
StpUtil::setSession('username', 'admin');
StpUtil::setSession('user_info', [
'id' => 10001,
'name' => '张三',
'email' => 'zhangsan@example.com',
'roles' => ['admin', 'editor'],
'settings' => [
'theme' => 'dark',
'language' => 'zh-CN',
],
]);

// ========== Session 读取 ==========
$userId = StpUtil::getSession('user_id');
$username = StpUtil::getSession('username', 'guest');
$session = StpUtil::getSession();
$allData = $session->getAll();
$keys = StpUtil::getSessionKeys();

// ========== Session 判断 ==========
if (StpUtil::hasSession('user_info')) {}
if (StpUtil::hasSessionKey('user_info', 'name')) {}

// ========== Session 删除 ==========
StpUtil::deleteSession('temp_data');
StpUtil::deleteSession(['key1', 'key2', 'key3']);
StpUtil::clearSession();

// ========== Session 有效期 ==========
StpUtil::setSessionTimeout('user_info', 3600);
$timeout = StpUtil::getSessionTimeout('user_info');
StpUtil::refreshSession('user_info');
StpUtil::refreshSession(['user_info', 'permissions']);

6.2.3 Session 配置

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

'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
// ========== 自动清理 ==========
// 配置了 activityTimeout 后,框架会自动清理过期 Session

// ========== 手动清理 ==========
SaToken::getDao()->cleanExpiredSession();
StpUtil::clearUserSession(10001);
StpUtil::clearDeviceSession('mobile');

// ========== 定时任务清理 ==========
// 创建 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
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
┌─────────────────────────────────────────────────────────────────────────────┐
│ 用户登录完整流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户提交用户名/密码 │
│ ↓ │
│ 2. 验证用户凭证 │
│ ↓ │
│ 3. StpUtil::login(10001) │
│ │ │
│ ├── 生成 Token │
│ │ ├── uuid: 550e8400-e29b-41d4-a716-446655440000 │
│ │ ├── simple-uuid: 550e8400e29b41d4a716446655440000 │
│ │ ├── random-32: abc123def456... │
│ │ └── tik: 随机24位字符串 │
│ │ │
│ ├── Token 与 LoginId 绑定 → Redis/内存 │
│ │ ├── key: sa_token_session_{token} │
│ │ └── value: {loginId, device, timeout, extra, ...} │
│ │ │
│ ├── 创建 Session 对象 (账号 Session) │
│ │ └── key: sa_token_session_{loginId} │
│ │ │
│ ├── 写入 Cookie │
│ │ └── Cookie: satoken=550e8400-e29b-41d4-a716-446655440000 │
│ │ ├── Domain: .example.com │
│ │ ├── Path: / │
│ │ ├── Secure: true/false │
│ │ ├── HttpOnly: true │
│ │ └── SameSite: Strict/Lax/None │
│ │ │
│ ├── 写入响应头 │
│ │ └── Header: Authorization: Bearer 550e8400... │
│ │ │
│ ├── 触发 onLogin 事件 │
│ │ └── 执行自定义监听器逻辑 │
│ │ │
│ └── 记录审计日志 │
│ └── 写入 audit_log 表 │
│ ↓ │
│ 4. 返回 Token 给客户端 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

七、注解式鉴权

7.1 注解概览

注解 说明 示例
@SaCheckLogin 登录校验 #[SaCheckLogin]
@SaCheckRole 角色校验 #[SaCheckRole('admin')]
@SaCheckPermission 权限校验 #[SaCheckPermission('user:add')]
@SaCheckSafe 二级认证 #[SaCheckSafe]
@SaCheckDisable 服务封禁 #[SaCheckDisable('comment')]
@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
<?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;

class UserController
{
#[SaCheckLogin]
public function info()
{
return '查询用户信息';
}

#[SaCheckRole('super-admin')]
public function add()
{
return '用户增加';
}

#[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 多权限场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use SaToken\Annotation\SaCheckPermission;
use SaToken\SaMode;

#[SaCheckPermission(value: ['user-add', 'user-all'], mode: SaMode::OR)]
public function userManage()
{
return '用户管理';
}

#[SaCheckPermission(value: ['user-add', 'user-edit'], mode: SaMode::AND)]
public function userEdit()
{
return '编辑用户';
}

八、路由拦截鉴权

8.1 基础匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use SaToken\SaRouter;
use SaToken\StpUtil;

SaRouter::match('/api/user/info')->check(fn() => StpUtil::checkLogin());

SaRouter::match(['/api/user/**', '/api/order/**'])->check(fn() => StpUtil::checkLogin());

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

SaRouter::match('/api/user/**', ['POST', 'PUT', 'DELETE'])->check(fn() => StpUtil::checkPermission('user:edit'));

SaRouter::match(
['/api/user/**', '/api/admin/**'],
['POST', 'PUT', 'DELETE']
)->check(fn() => StpUtil::checkPermission('user:edit'));

8.2 排除路径

1
2
3
4
5
6
7
8
9
10
11
12
SaRouter::match('/api/**')
->exclude('/api/login', '/api/register')
->check(fn() => StpUtil::checkLogin());

SaRouter::match('/api/**')
->exclude('/api/public/**')
->exclude('/api/health')
->check(fn() => 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
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
SaRouter::match('/api/**')
->exclude('/api/login', '/api/register')
->check(fn() => StpUtil::checkLogin());

SaRouter::match('/api/admin/**')
->check(fn() => StpUtil::checkRole('admin'));

SaRouter::match('/api/super/**')
->check(fn() => StpUtil::checkRole('super'));

九、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
<?php

return [
'mode' => 'cross-domain',
'serverUrl' => 'https://sso.example.com',
'clientId' => 'your-client-id',
'clientSecret' => 'your-client-secret',
'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'],
'ssoTimeout' => 86400,
'ssoTokenName' => 'ssotoken',
'jwtSecretKey' => 'your-jwt-secret',
'jwtExpire' => 3600,
'autoLogin' => true,
'autoRegister' => false,
];

9.2 SSO 控制器 app/controller/SsoController.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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<?php

namespace app\controller;

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

class SsoController
{
public function login()
{
$sso = SaSsoManager::getInstance();

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');
}

return redirect($sso->buildLoginUrl());
}

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()]);
}
}

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
<?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'],
],
],
'accessTokenTimeout' => 3600,
'refreshTokenTimeout' => 2592000,
'codeTimeout' => 300,
'openIdMode' => true,
'idTokenTimeout' => 3600,
'jwtSecretKey' => 'your-jwt-secret',
'issuer' => 'https://auth.example.com',
];

10.2 OAuth2 控制器 app/controller/OAuth2Controller.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
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
<?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()));
}

if ($scope && !$oauth2->validateScope($scope, $client['scopes'])) {
return json(['error' => 'invalid_scope'], 400);
}

if ($responseType === 'token') {
$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()]);
}
}

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':
$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);
}
}

public function userinfo()
{
$oauth2 = SaOAuth2Manager::getInstance();

$accessToken = input('get.access_token', '');
if (!$accessToken) {
$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
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' => '登录成功']);
}

// ========== 防暴力破解配置 ==========
'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
StpUtil::addTokenToBlacklist($tokenValue);
StpUtil::addTokenToBlacklist($tokenValue, '异常登录');
StpUtil::removeTokenFromBlacklist($tokenValue);
$isBlacklisted = StpUtil::isTokenBlacklisted($tokenValue);
$blacklist = StpUtil::getBlacklist();

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

11.3 IP 异常检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use SaToken\StpUtil;

$info = StpUtil::getLoginInfo(10001);
$count = StpUtil::getAnomalyCount(10001);
$history = StpUtil::getIpHistory(10001);
StpUtil::clearLoginHistory(10001);

if (StpUtil::isIpAnomalous()) {
Log::warning('异地登录:用户 ' . StpUtil::getLoginId() . ' 从 ' . request()->ip() . ' 登录');
$this->sendAlert(StpUtil::getLoginId(), request()->ip());
}

// ========== IP 异常检测配置 ==========
'ipAnomalyDetection' => true,
'ipAnomalySensitivity' => 3,

11.4 设备管理

1
2
3
4
5
6
7
8
9
10
11
use SaToken\StpUtil;

$devices = StpUtil::getDeviceList(10001);
$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', '我的手机');

// ========== 设备管理配置 ==========
'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
use SaToken\StpUtil;

// ========== OTP 验证码 ==========
$code = StpUtil::generateOtpCode('payment');
$code = StpUtil::sendOtpCode('payment');
StpUtil::verifyOtpCode('payment', input('post.code'));
$isVerified = StpUtil::isSensitiveVerified('payment');
$remaining = StpUtil::getSensitiveVerifyRemainingAttempts('payment');
StpUtil::clearSensitiveVerify('payment');

// ========== 安全令牌 ==========
$token = StpUtil::openSensitiveVerify('payment', 600);
StpUtil::checkSensitiveVerify('payment', input('post.stoken'));
$status = StpUtil::getSensitiveVerifyStatus('payment');

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

if (!StpUtil::isSensitiveVerified('transfer')) {
$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
// ========== 启用 Token 指纹绑定 ==========
'tokenFingerprint' => true,

// ========== 手动操作 ==========
$fingerprint = StpUtil::getCurrentFingerprint();
StpUtil::checkFingerprint();
StpUtil::updateFingerprint();

// ========== 指纹异常处理 ==========
try {
StpUtil::checkFingerprint();
} catch (\Exception $e) {
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
use SaToken\SaToken;

$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;
});

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

// ========== HTTP Digest 认证 ==========
$auth->setDigestValidator(function (string $username): ?string {
$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
use SaToken\SaToken;

$sign = SaToken::getSign();

$sign->setSignKey(config('sa_token.signKey'));
$sign->setSignAlg('sha256');
$sign->setTimestampGap(600);
$sign->setNonceValidator(function (string $nonce): bool {
$key = 'nonce_' . $nonce;
if (StpUtil::getCustomSession($key)) {
return false;
}
StpUtil::setCustomSession($key, time(), 3600);
return true;
});

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

$signed = $sign->signParams($params);

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

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
use SaToken\SaToken;

$apiKey = SaToken::getApiKey();

$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;
});

$apiKey->checkApiKey();
$userId = StpUtil::getLoginId();

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
use SaToken\SaToken;
use SaToken\SaRouter;
use SaToken\StpUtil;

$filter = SaToken::getGlobalFilter();

$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();

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
use SaToken\SaToken;
use SaToken\Dao\SaTokenDaoMemory;
use SaToken\Dao\SaTokenDaoRedis;
use SaToken\Dao\SaTokenDaoPsr16;

SaToken::setDao(new SaTokenDaoMemory());
SaToken::setDao(new SaTokenDaoRedis($redisConfig));

$dao = SaTokenDaoRedis::createWithSeparateRedis(
['host' => '127.0.0.1', 'port' => 6379, 'db' => 0],
['host' => '127.0.0.1', 'port' => 6379, 'db' => 1],
);
SaToken::setDao($dao);

$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
use SaToken\Plugin\SaTokenJwt;

$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);

try {
$payload = SaTokenJwt::verify($jwt);
$userId = $payload['userId'];
$username = $payload['username'];
} catch (\Exception $e) {
return json(['code' => 401, 'msg' => 'JWT 无效']);
}

// 配置
'jwtSecretKey' => 'your-jwt-secret',
'jwtStateless' => false,

// 使用 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
use SaToken\Plugin\SaTokenCrypto;

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

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

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

$encrypted = SaTokenCrypto::aesEncrypt('data', '16-byte-key');
$decrypted = SaTokenCrypto::aesDecrypt($encrypted, '16-byte-key');

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

$encrypted = SaTokenCrypto::rsaEncrypt('data', $publicKey);
$decrypted = SaTokenCrypto::rsaDecrypt($encrypted, $privateKey);

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

$sm3 = SaTokenCrypto::sm3('data');
$encrypted = SaTokenCrypto::sm4Encrypt('data', '16-byte-key-16-byte');
$decrypted = SaTokenCrypto::sm4Decrypt($encrypted, '16-byte-key-16-byte');
$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
use SaToken\StpUtil;
use SaToken\Security\SaAuditLog;

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

$logs = StpUtil::getAuditLogs(50);
$log = StpUtil::getAuditLog('log-id-xxx');
$recentLogs = SaAuditLog::getRecentLogs('login', 100);
$logsByIp = SaAuditLog::getLogsByIp('192.168.1.1');
$logsByEvent = SaAuditLog::getLogsByEvent('login');
$userLogs = SaAuditLog::getLogsByUserId(10001);
$timeRangeLogs = SaAuditLog::getLogsByTimeRange(strtotime('-7 days'), time());

SaAuditLog::clearLogs();
SaAuditLog::clearLogsByUser(10001);
SaAuditLog::clearLogsByEvent('login');

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
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);
}

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 异常处理器 app/exception/Handler.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
89
90
91
<?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
{
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');
}
}

十四、完整配置参考

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

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,
];

十五、项目结构

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
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

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
tp8-sa-token/
├── app/
│ ├── common/
│ │ ├── middleware/
│ │ │ └── SaTokenMiddleware.php
│ │ ├── service/
│ │ │ └── PermissionService.php
│ │ ├── exception/
│ │ │ └── Handler.php
│ │ └── provider/
│ │ └── SaTokenProvider.php
│ ├── api/
│ │ ├── controller/
│ │ │ └── AuthController.php
│ │ ├── config/
│ │ │ └── sa_token.php
│ │ └── route/
│ │ └── app.php
│ ├── admin/
│ │ ├── controller/
│ │ │ └── AuthController.php
│ │ ├── config/
│ │ │ └── sa_token.php
│ │ └── route/
│ │ └── app.php
│ └── provider.php
├── config/
│ └── sa_token.php
└── .env

十六、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) 更新扩展信息

16.2 SaRouter 核心方法

方法 说明
match($path, $method) 匹配路由
exclude($path) 排除路径
check($callback) 执行校验

16.3 异常类

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

十七、常见问题

Q1: 四种模式如何选择?

场景 推荐模式
生产环境、高并发 单应用+Redis
开发测试、快速验证 单应用+内存
微服务、分布式 多应用+Redis
小型多模块项目 多应用+内存

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

检查:

  1. app/provider/SaTokenProvider.php 是否正确注册
  2. config/service.php 是否注册了服务提供者
  3. config/sa_token.php 是否正确加载
  4. config/middleware.php 是否注册了中间件

Q3: Token 没有自动写入 Cookie?

检查配置:

1
2
'isWriteCookie' => true,
'cookieSecure' => false,

Q4: 如何跨域共享 Cookie?

1
2
'cookieDomain' => '.example.com',
'cookieSameSite' => 'Lax',

Q5: 前后端分离如何使用?

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

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

强烈推荐 单应用+Redis多应用+Redis

Q7: Session 数据会丢失吗?

内存模式下应用重启会丢失,Redis 模式下不会。

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

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


项目地址: https://github.com/pohoc/sa-token
ThinkPHP 8 文档: https://www.thinkphp.cn/doc
问题反馈: https://github.com/pohoc/sa-token/issues