From 601d8b1373691e85b8ec7d33cf8a8e52b8b2820b Mon Sep 17 00:00:00 2001
From: click33 <2393584716@qq.com>
Date: Fri, 4 Apr 2025 23:36:59 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20API=20Key=20?=
=?UTF-8?q?=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../main/java/cn/dev33/satoken/SaManager.java | 44 ++
.../satoken/annotation/SaCheckApiKey.java | 49 ++
.../handler/SaCheckApiKeyHandler.java | 53 ++
.../satoken/apikey/SaApiKeyTemplate.java | 537 ++++++++++++++++++
.../cn/dev33/satoken/apikey/SaApiKeyUtil.java | 200 +++++++
.../apikey/loader/SaApiKeyDataLoader.java | 48 ++
.../loader/SaApiKeyDataLoaderDefaultImpl.java | 28 +
.../satoken/apikey/model/ApiKeyModel.java | 380 +++++++++++++
.../dev33/satoken/config/SaApiKeyConfig.java | 110 ++++
.../dev33/satoken/config/SaTokenConfig.java | 25 +
.../cn/dev33/satoken/error/SaErrorCode.java | 23 +
.../satoken/exception/ApiKeyException.java | 73 +++
.../exception/ApiKeyScopeException.java | 87 +++
.../cn/dev33/satoken/secure/SaBase32Util.java | 2 +-
.../satoken/secure/totp/SaTotpTemplate.java | 2 +-
.../dev33/satoken/secure/totp/SaTotpUtil.java | 2 +-
.../satoken/session/SaSessionRawUtil.java | 95 ++++
.../strategy/SaAnnotationStrategy.java | 1 +
sa-token-demo/pom.xml | 1 +
sa-token-demo/sa-token-demo-apikey/pom.xml | 67 +++
.../java/com/pj/SaTokenApiKeyApplication.java | 16 +
.../com/pj/mock/SaApiKeyDataLoaderImpl.java | 31 +
.../java/com/pj/mock/SaApiKeyMockMapper.java | 42 ++
.../java/com/pj/satoken/GlobalException.java | 24 +
.../java/com/pj/satoken/SaTokenConfigure.java | 76 +++
.../java/com/pj/test/ApiKeyController.java | 65 +++
.../pj/test/ApiKeyResourcesController.java | 55 ++
.../java/com/pj/test/LoginController.java | 37 ++
.../src/main/resources/application.yml | 43 ++
.../src/main/resources/static/common.js | 137 +++++
.../src/main/resources/static/index.html | 197 +++++++
sa-token-doc/_sidebar.md | 1 +
sa-token-doc/plugin/api-key.md | 227 ++++++++
sa-token-doc/start/download.md | 1 +
sa-token-doc/use/config.md | 50 +-
.../cn/dev33/satoken/solon/SaBeanInject.java | 24 +
.../cn/dev33/satoken/spring/SaBeanInject.java | 22 +
37 files changed, 2868 insertions(+), 7 deletions(-)
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/annotation/SaCheckApiKey.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/annotation/handler/SaCheckApiKeyHandler.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyTemplate.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyUtil.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoader.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoaderDefaultImpl.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/apikey/model/ApiKeyModel.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/config/SaApiKeyConfig.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyException.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyScopeException.java
create mode 100644 sa-token-core/src/main/java/cn/dev33/satoken/session/SaSessionRawUtil.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/pom.xml
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/SaTokenApiKeyApplication.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyDataLoaderImpl.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyMockMapper.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/GlobalException.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/SaTokenConfigure.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyController.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyResourcesController.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/LoginController.java
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/resources/application.yml
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/resources/static/common.js
create mode 100644 sa-token-demo/sa-token-demo-apikey/src/main/resources/static/index.html
create mode 100644 sa-token-doc/plugin/api-key.md
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/SaManager.java b/sa-token-core/src/main/java/cn/dev33/satoken/SaManager.java
index 0db3727a..b24795e0 100644
--- a/sa-token-core/src/main/java/cn/dev33/satoken/SaManager.java
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/SaManager.java
@@ -15,6 +15,9 @@
*/
package cn.dev33.satoken;
+import cn.dev33.satoken.apikey.SaApiKeyTemplate;
+import cn.dev33.satoken.apikey.loader.SaApiKeyDataLoader;
+import cn.dev33.satoken.apikey.loader.SaApiKeyDataLoaderDefaultImpl;
import cn.dev33.satoken.config.SaTokenConfig;
import cn.dev33.satoken.config.SaTokenConfigFactory;
import cn.dev33.satoken.context.SaTokenContext;
@@ -317,6 +320,47 @@ public class SaManager {
return totpTemplate;
}
+ /**
+ * ApiKey 数据加载器
+ */
+ private volatile static SaApiKeyDataLoader apiKeyDataLoader;
+ public static void setSaApiKeyDataLoader(SaApiKeyDataLoader apiKeyDataLoader) {
+ SaManager.apiKeyDataLoader = apiKeyDataLoader;
+ SaTokenEventCenter.doRegisterComponent("SaApiKeyDataLoader", apiKeyDataLoader);
+ }
+ public static SaApiKeyDataLoader getSaApiKeyDataLoader() {
+ if (apiKeyDataLoader == null) {
+ synchronized (SaManager.class) {
+ if (apiKeyDataLoader == null) {
+ SaManager.apiKeyDataLoader = new SaApiKeyDataLoaderDefaultImpl();
+ }
+ }
+ }
+ return apiKeyDataLoader;
+ }
+
+ /**
+ * ApiKey 操作类
+ */
+ private volatile static SaApiKeyTemplate apiKeyTemplate;
+ public static void setSaApiKeyTemplate(SaApiKeyTemplate apiKeyTemplate) {
+ SaManager.apiKeyTemplate = apiKeyTemplate;
+ SaTokenEventCenter.doRegisterComponent("SaApiKeyTemplate", apiKeyTemplate);
+ }
+ public static SaApiKeyTemplate getSaApiKeyTemplate() {
+ if (apiKeyTemplate == null) {
+ synchronized (SaManager.class) {
+ if (apiKeyTemplate == null) {
+ SaManager.apiKeyTemplate = new SaApiKeyTemplate();
+ }
+ }
+ }
+ return apiKeyTemplate;
+ }
+
+
+ // ------------------- StpLogic 相关 -------------------
+
/**
* StpLogic 集合, 记录框架所有成功初始化的 StpLogic
*/
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/annotation/SaCheckApiKey.java b/sa-token-core/src/main/java/cn/dev33/satoken/annotation/SaCheckApiKey.java
new file mode 100644
index 00000000..96ea3d3f
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/annotation/SaCheckApiKey.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * API Key 校验:指定请求中必须包含有效的 ApiKey ,并且包含指定的 scope
+ *
+ *
可标注在方法、类上(效果等同于标注在此类的所有方法上)
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD,ElementType.TYPE})
+public @interface SaCheckApiKey {
+
+ /**
+ * 指定 API key 必须包含的权限 [ 数组 ]
+ *
+ * @return /
+ */
+ String [] scope() default {};
+
+ /**
+ * 验证模式:AND | OR,默认AND
+ *
+ * @return /
+ */
+ SaMode mode() default SaMode.AND;
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/annotation/handler/SaCheckApiKeyHandler.java b/sa-token-core/src/main/java/cn/dev33/satoken/annotation/handler/SaCheckApiKeyHandler.java
new file mode 100644
index 00000000..b4587821
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/annotation/handler/SaCheckApiKeyHandler.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.annotation.handler;
+
+import cn.dev33.satoken.annotation.SaCheckApiKey;
+import cn.dev33.satoken.annotation.SaMode;
+import cn.dev33.satoken.apikey.SaApiKeyUtil;
+import cn.dev33.satoken.context.SaHolder;
+
+import java.lang.reflect.Method;
+
+/**
+ * 注解 SaCheckApiKey 的处理器
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class SaCheckApiKeyHandler implements SaAnnotationHandlerInterface {
+
+ @Override
+ public Class getHandlerAnnotationClass() {
+ return SaCheckApiKey.class;
+ }
+
+ @Override
+ public void checkMethod(SaCheckApiKey at, Method method) {
+ _checkMethod(at.scope(), at.mode());
+ }
+
+ public static void _checkMethod(String[] scope, SaMode mode) {
+ String apiKey = SaApiKeyUtil.readApiKeyValue(SaHolder.getRequest());
+ if(mode == SaMode.AND) {
+ SaApiKeyUtil.checkApiKeyScope(apiKey, scope);
+ } else {
+ SaApiKeyUtil.checkApiKeyScopeOr(apiKey, scope);
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyTemplate.java b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyTemplate.java
new file mode 100644
index 00000000..398232a0
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyTemplate.java
@@ -0,0 +1,537 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.apikey;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.apikey.model.ApiKeyModel;
+import cn.dev33.satoken.config.SaTokenConfig;
+import cn.dev33.satoken.context.SaHolder;
+import cn.dev33.satoken.context.model.SaRequest;
+import cn.dev33.satoken.dao.SaTokenDao;
+import cn.dev33.satoken.error.SaErrorCode;
+import cn.dev33.satoken.exception.ApiKeyException;
+import cn.dev33.satoken.exception.ApiKeyScopeException;
+import cn.dev33.satoken.httpauth.basic.SaHttpBasicUtil;
+import cn.dev33.satoken.session.SaSession;
+import cn.dev33.satoken.session.SaSessionRawUtil;
+import cn.dev33.satoken.strategy.SaStrategy;
+import cn.dev33.satoken.util.SaFoxUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * API Key 操作类
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class SaApiKeyTemplate {
+
+ /**
+ * ApiKey 的 raw-session 类型
+ */
+ public static final String SESSION_TYPE = "apikey";
+
+ /**
+ * 在 raw-session 中的保存索引列表使用的 key
+ */
+ public static final String API_KEY_LIST = "__HD_API_KEY_LIST";
+
+ /**
+ * 网络传输时的参数名称 (字母全小写)
+ */
+ public static final String API_KEY_PARAMETER_NAME = "apikey";
+
+ // ------------------- ApiKey
+
+ /**
+ * 根据 apiKey 从 Cache 获取 ApiKeyModel 信息
+ * @param apiKey /
+ * @return /
+ */
+ public ApiKeyModel getApiKeyModelFromCache(String apiKey) {
+ return getSaTokenDao().getObject(splicingApiKeySaveKey(apiKey), ApiKeyModel.class);
+ }
+
+ /**
+ * 根据 apiKey 从 Database 获取 ApiKeyModel 信息
+ * @param apiKey /
+ * @return /
+ */
+ public ApiKeyModel getApiKeyModelFromDatabase(String apiKey) {
+ return SaManager.getSaApiKeyDataLoader().getApiKeyModelFromDatabase(apiKey);
+ }
+
+ /**
+ * 获取 ApiKeyModel,无效的 ApiKey 会返回 null
+ * @param apiKey /
+ * @return /
+ */
+ public ApiKeyModel getApiKey(String apiKey) {
+ if(apiKey == null) {
+ return null;
+ }
+ // 先从缓存中获取,缓存中找不到就尝试从数据库获取
+ ApiKeyModel apiKeyModel = getApiKeyModelFromCache(apiKey);
+ if(apiKeyModel == null) {
+ apiKeyModel = getApiKeyModelFromDatabase(apiKey);
+ saveApiKey(apiKeyModel);
+ }
+ return apiKeyModel;
+ }
+
+ /**
+ * 校验 ApiKey,成功返回 ApiKeyModel,失败则抛出异常
+ * @param apiKey /
+ * @return /
+ */
+ public ApiKeyModel checkApiKey(String apiKey) {
+ ApiKeyModel ak = getApiKey(apiKey);
+ if(ak == null) {
+ throw new ApiKeyException("无效 API Key: " + apiKey).setApiKey(apiKey).setCode(SaErrorCode.CODE_12301);
+ }
+ if(ak.timeExpired()) {
+ throw new ApiKeyException("API Key 已过期: " + apiKey).setApiKey(apiKey).setCode(SaErrorCode.CODE_12302);
+ }
+ if(! ak.getIsValid()) {
+ throw new ApiKeyException("API Key 已被禁用: " + apiKey).setApiKey(apiKey).setCode(SaErrorCode.CODE_12303);
+ }
+ return ak;
+ }
+
+ /**
+ * 持久化:ApiKeyModel
+ * @param ak /
+ */
+ public void saveApiKey(ApiKeyModel ak) {
+ if(ak == null) {
+ return;
+ }
+ // 数据自检
+ ak.checkByCanSaved();
+
+ // 保存 ApiKeyModel
+ String saveKey = splicingApiKeySaveKey(ak.getApiKey());
+ if(ak.timeExpired()) {
+ getSaTokenDao().deleteObject(saveKey);
+ } else {
+ getSaTokenDao().setObject(saveKey, ak, ak.expiresIn());
+ }
+
+ // 调整索引
+ if (SaManager.getSaApiKeyDataLoader().getIsRecordIndex()) {
+ // 记录索引
+ SaSession session = SaSessionRawUtil.getSessionById(SESSION_TYPE, ak.getLoginId());
+ ArrayList apiKeyList = session.get(API_KEY_LIST, ArrayList::new);
+ if(! apiKeyList.contains(ak.getApiKey())) {
+ apiKeyList.add(ak.getApiKey());
+ session.set(API_KEY_LIST, apiKeyList);
+ }
+
+ // 调整 ttl
+ adjustIndex(ak.getLoginId(), session);
+ }
+
+ }
+
+ /**
+ * 获取 ApiKey 所代表的 LoginId
+ * @param apiKey ApiKey
+ * @return LoginId
+ */
+ public Object getLoginIdByApiKey(String apiKey) {
+ return checkApiKey(apiKey).getLoginId();
+ }
+
+ /**
+ * 删除 ApiKey
+ * @param apiKey ApiKey
+ */
+ public void deleteApiKey(String apiKey) {
+ // 删 ApiKeyModel
+ ApiKeyModel ak = getApiKeyModelFromCache(apiKey);
+ if(ak == null) {
+ return;
+ }
+ getSaTokenDao().deleteObject(splicingApiKeySaveKey(apiKey));
+
+ // 删索引
+ if(SaManager.getSaApiKeyDataLoader().getIsRecordIndex()) {
+ // RawSession 中不存在,提前退出
+ SaSession session = SaSessionRawUtil.getSessionById(SESSION_TYPE, ak.getLoginId(), false);
+ if(session == null) {
+ return;
+ }
+ // 索引无记录,提前退出
+ ArrayList apiKeyList = session.get(API_KEY_LIST, ArrayList::new);
+ if(! apiKeyList.contains(apiKey)) {
+ return;
+ }
+
+ // 如果只有一个 ApiKey,则整个 RawSession 删掉
+ if (apiKeyList.size() == 1) {
+ SaSessionRawUtil.deleteSessionById(SESSION_TYPE, ak.getLoginId());
+ } else {
+ // 否则移除此 ApiKey 并保存
+ apiKeyList.remove(apiKey);
+ session.set(API_KEY_LIST, apiKeyList);
+ }
+ }
+ }
+
+ /**
+ * 删除指定 loginId 的所有 ApiKey
+ * @param loginId /
+ */
+ public void deleteApiKeyByLoginId(Object loginId) {
+ // 先判断是否开启索引
+ if(! SaManager.getSaApiKeyDataLoader().getIsRecordIndex()) {
+ SaManager.getLog().warn("当前 API Key 模块未开启索引记录功能,无法执行 deleteApiKeyByLoginId 操作");
+ return;
+ }
+
+ // RawSession 中不存在,提前退出
+ SaSession session = SaSessionRawUtil.getSessionById(SESSION_TYPE, loginId, false);
+ if(session == null) {
+ return;
+ }
+
+ // 先删 ApiKeyModel
+ ArrayList apiKeyList = session.get(API_KEY_LIST, ArrayList::new);
+ for (String apiKey : apiKeyList) {
+ getSaTokenDao().deleteObject(splicingApiKeySaveKey(apiKey));
+ }
+
+ // 再删索引
+ SaSessionRawUtil.deleteSessionById(SESSION_TYPE, loginId);
+ }
+
+ // ------- 创建
+
+ /**
+ * 创建一个 ApiKeyModel 对象
+ *
+ * @return /
+ */
+ public ApiKeyModel createApiKeyModel() {
+ String apiKey = SaStrategy.instance.generateUniqueToken.execute(
+ "API Key",
+ SaManager.getConfig().getMaxTryTimes(),
+ this::randomApiKeyValue,
+ _apiKey -> getApiKey(_apiKey) == null
+ );
+ return new ApiKeyModel().setApiKey(apiKey);
+ }
+
+ /**
+ * 创建一个 ApiKeyModel 对象
+ *
+ * @return /
+ */
+ public ApiKeyModel createApiKeyModel(Object loginId) {
+ long timeout = SaManager.getConfig().getApiKey().getTimeout();
+ long expiresTime = (timeout == SaTokenDao.NEVER_EXPIRE) ? SaTokenDao.NEVER_EXPIRE : System.currentTimeMillis() + timeout * 1000;
+ return createApiKeyModel()
+ .setLoginId(loginId)
+ .setIsValid(true)
+ .setExpiresTime(expiresTime)
+ ;
+ }
+
+ /**
+ * 随机一个 ApiKey 码
+ *
+ * @return /
+ */
+ public String randomApiKeyValue() {
+ return SaManager.getConfig().getApiKey().getPrefix() + SaFoxUtil.getRandomString(36);
+ }
+
+
+ // ------------------- 校验
+
+ /**
+ * 判断:指定 ApiKey 是否具有指定 Scope 列表 (AND 模式,需要全部具备),返回 true 或 false
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public boolean hasApiKeyScope(String apiKey, String... scopes) {
+ try {
+ checkApiKeyScope(apiKey, scopes);
+ return true;
+ } catch (ApiKeyException e) {
+ return false;
+ }
+ }
+
+ /**
+ * 校验:指定 ApiKey 是否具有指定 Scope 列表 (AND 模式,需要全部具备),如果不具备则抛出异常
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public void checkApiKeyScope(String apiKey, String... scopes) {
+ ApiKeyModel ak = checkApiKey(apiKey);
+ if(SaFoxUtil.isEmptyArray(scopes)) {
+ return;
+ }
+ for (String scope : scopes) {
+ if(! ak.getScopes().contains(scope)) {
+ throw new ApiKeyScopeException("该 API Key 不具备 Scope:" + scope)
+ .setApiKey(apiKey)
+ .setScope(scope)
+ .setCode(SaErrorCode.CODE_12311);
+ }
+ }
+ }
+
+ /**
+ * 判断:指定 ApiKey 是否具有指定 Scope 列表 (OR 模式,具备其一即可),返回 true 或 false
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public boolean hasApiKeyScopeOr(String apiKey, String... scopes) {
+ try {
+ checkApiKeyScopeOr(apiKey, scopes);
+ return true;
+ } catch (ApiKeyException e) {
+ return false;
+ }
+ }
+
+ /**
+ * 校验:指定 ApiKey 是否具有指定 Scope 列表 (OR 模式,具备其一即可),如果不具备则抛出异常
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public void checkApiKeyScopeOr(String apiKey, String... scopes) {
+ ApiKeyModel ak = checkApiKey(apiKey);
+ if(SaFoxUtil.isEmptyArray(scopes)) {
+ return;
+ }
+ for (String scope : scopes) {
+ if(ak.getScopes().contains(scope)) {
+ return;
+ }
+ }
+ throw new ApiKeyScopeException("该 API Key 不具备 Scope:" + scopes[0])
+ .setApiKey(apiKey)
+ .setScope(scopes[0])
+ .setCode(SaErrorCode.CODE_12311);
+ }
+
+ /**
+ * 判断:指定 ApiKey 是否属于指定 LoginId,返回 true 或 false
+ * @param apiKey /
+ * @param loginId /
+ */
+ public boolean isApiKeyLoginId(String apiKey, Object loginId) {
+ try {
+ checkApiKeyLoginId(apiKey, loginId);
+ return true;
+ } catch (ApiKeyException e) {
+ return false;
+ }
+ }
+
+ /**
+ * 校验:指定 ApiKey 是否属于指定 LoginId,如果不是则抛出异常
+ *
+ * @param apiKey /
+ * @param loginId /
+ */
+ public void checkApiKeyLoginId(String apiKey, Object loginId) {
+ ApiKeyModel ak = getApiKey(apiKey);
+ if(ak == null) {
+ throw new ApiKeyException("无效 API Key: " + apiKey).setApiKey(apiKey).setCode(SaErrorCode.CODE_12301);
+ }
+ if (SaFoxUtil.notEquals(String.valueOf(ak.getLoginId()), String.valueOf(loginId))) {
+ throw new ApiKeyException("该 API Key 不属于用户: " + loginId)
+ .setApiKey(apiKey)
+ .setCode(SaErrorCode.CODE_12312);
+ }
+ }
+
+
+ // ------------------- 索引操作
+
+ /**
+ * 调整指定 SaSession 的 TTL 值,以保证最小化内存占用
+ * @param loginId /
+ * @param session 可填写 null,代表使用 loginId 现场查询
+ */
+ public void adjustIndex(Object loginId, SaSession session) {
+ // 先判断是否开启索引
+ if(! SaManager.getSaApiKeyDataLoader().getIsRecordIndex()) {
+ SaManager.getLog().warn("当前 API Key 模块未开启索引记录功能,无法执行 adjustIndex 操作");
+ return;
+ }
+
+ // 未提供则现场查询
+ if(session == null) {
+ session = SaSessionRawUtil.getSessionById(SESSION_TYPE, loginId, false);
+ if(session == null) {
+ return;
+ }
+ }
+
+ // 重新整理索引列表
+ ArrayList apiKeyList = session.get(API_KEY_LIST, ArrayList::new);
+ ArrayList apiKeyNewList = new ArrayList<>();
+ ArrayList apiKeyModelList = new ArrayList<>();
+ for (String apikey : apiKeyList) {
+ ApiKeyModel ak = getApiKeyModelFromCache(apikey);
+ if(ak == null || ak.timeExpired()) {
+ continue;
+ }
+ apiKeyNewList.add(apikey);
+ apiKeyModelList.add(ak);
+ }
+ session.set(API_KEY_LIST, apiKeyNewList);
+
+ // 调整 SaSession TTL
+ long maxTtl = 0;
+ for (ApiKeyModel ak : apiKeyModelList) {
+ long ttl = ak.expiresIn();
+ if(ttl == SaTokenDao.NEVER_EXPIRE) {
+ maxTtl = SaTokenDao.NEVER_EXPIRE;
+ break;
+ }
+ if(ttl > maxTtl) {
+ maxTtl = ttl;
+ }
+ }
+ if(maxTtl != 0) {
+ session.updateTimeout(maxTtl);
+ }
+ }
+
+ /**
+ * 获取指定 loginId 的 ApiKey 列表记录
+ * @param loginId /
+ * @return /
+ */
+ public List getApiKeyList(Object loginId) {
+ // 先判断是否开启索引
+ if(! SaManager.getSaApiKeyDataLoader().getIsRecordIndex()) {
+ SaManager.getLog().warn("当前 API Key 模块未开启索引记录功能,无法执行 getApiKeyList 操作");
+ return new ArrayList<>();
+ }
+
+ // 先查 RawSession
+ List apiKeyModelList = new ArrayList<>();
+ SaSession session = SaSessionRawUtil.getSessionById(SESSION_TYPE, loginId, false);
+ if(session == null) {
+ return apiKeyModelList;
+ }
+
+ // 从 RawSession 遍历查询
+ ArrayList apiKeyList = session.get(API_KEY_LIST, ArrayList::new);
+ for (String apikey : apiKeyList) {
+ ApiKeyModel ak = getApiKeyModelFromCache(apikey);
+ if(ak == null || ak.timeExpired()) {
+ continue;
+ }
+ apiKeyModelList.add(ak);
+ }
+ return apiKeyModelList;
+ }
+
+
+ // ------------------- 请求查询
+
+ /**
+ * 数据读取:从请求对象中读取 ApiKey,获取不到返回 null
+ */
+ public String readApiKeyValue(SaRequest request) {
+
+ // 优先从请求参数中获取
+ String apiKey = request.getParam(API_KEY_PARAMETER_NAME);
+ if(SaFoxUtil.isNotEmpty(apiKey)) {
+ return apiKey;
+ }
+
+ // 然后请求头
+ apiKey = request.getHeader(API_KEY_PARAMETER_NAME);
+ if(SaFoxUtil.isNotEmpty(apiKey)) {
+ return apiKey;
+ }
+
+ // 最后从 Authorization 中获取
+ apiKey = SaHttpBasicUtil.getAuthorizationValue();
+ if(SaFoxUtil.isNotEmpty(apiKey)) {
+ if(apiKey.endsWith(":")) {
+ apiKey = apiKey.substring(0, apiKey.length() - 1);
+ }
+ return apiKey;
+ }
+
+ return null;
+ }
+
+ /**
+ * 数据读取:从请求对象中读取 ApiKey,并查询到 ApiKeyModel 信息
+ */
+ public ApiKeyModel currentApiKey() {
+ String readApiKeyValue = readApiKeyValue(SaHolder.getRequest());
+ return checkApiKey(readApiKeyValue);
+ }
+
+
+
+ // ------------------- 拼接key
+
+ /**
+ * 拼接key:ApiKey 持久化
+ * @param apiKey ApiKey
+ * @return key
+ */
+ public String splicingApiKeySaveKey(String apiKey) {
+ return getSaTokenConfig().getTokenName() + ":apikey:" + apiKey;
+ }
+
+
+ // -------- bean 对象代理
+
+ /**
+ * 获取使用的 getSaTokenDao 实例
+ *
+ * @return /
+ */
+ public SaTokenDao getSaTokenDao() {
+ return SaManager.getSaTokenDao();
+ }
+
+ /**
+ * 获取使用的 SaTokenConfig 实例
+ *
+ * @return /
+ */
+ public SaTokenConfig getSaTokenConfig() {
+ return SaManager.getConfig();
+ }
+
+ /**
+ * 校验是否开启了索引记录功能,如果未开启则抛出异常
+ */
+// protected void checkOpenRecordIndex() {
+// if(! SaManager.getSaApiKeyDataLoader().getIsRecordIndex()) {
+// SaManager.getLog().warn("当前 API Key 模块未开启索引记录功能,无法执行此操作");
+// throw new ApiKeyException("当前 API Key 模块未开启索引记录功能,无法执行此操作").setCode(SaErrorCode.CODE_12305);
+// }
+// }
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyUtil.java b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyUtil.java
new file mode 100644
index 00000000..40873fad
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/SaApiKeyUtil.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.apikey;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.apikey.model.ApiKeyModel;
+import cn.dev33.satoken.context.model.SaRequest;
+import cn.dev33.satoken.session.SaSession;
+
+import java.util.List;
+
+/**
+ * API Key 操作工具类
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class SaApiKeyUtil {
+
+ /**
+ * 获取 ApiKeyModel,无效的 ApiKey 会返回 null
+ * @param apiKey /
+ * @return /
+ */
+ public static ApiKeyModel getApiKey(String apiKey) {
+ return SaManager.getSaApiKeyTemplate().getApiKey(apiKey);
+ }
+
+ /**
+ * 校验 ApiKey,成功返回 ApiKeyModel,失败则抛出异常
+ * @param apiKey /
+ * @return /
+ */
+ public static ApiKeyModel checkApiKey(String apiKey) {
+ return SaManager.getSaApiKeyTemplate().checkApiKey(apiKey);
+ }
+
+ /**
+ * 持久化:ApiKeyModel
+ * @param ak /
+ */
+ public static void saveApiKey(ApiKeyModel ak) {
+ SaManager.getSaApiKeyTemplate().saveApiKey(ak);
+ }
+
+ /**
+ * 获取 ApiKey 所代表的 LoginId
+ * @param apiKey ApiKey
+ * @return LoginId
+ */
+ public static Object getLoginIdByApiKey(String apiKey) {
+ return SaManager.getSaApiKeyTemplate().getLoginIdByApiKey(apiKey);
+ }
+
+ /**
+ * 删除 ApiKey
+ * @param apiKey ApiKey
+ */
+ public static void deleteApiKey(String apiKey) {
+ SaManager.getSaApiKeyTemplate().deleteApiKey(apiKey);
+ }
+
+ /**
+ * 删除指定 loginId 的所有 ApiKey
+ * @param loginId /
+ */
+ public static void deleteApiKeyByLoginId(Object loginId) {
+ SaManager.getSaApiKeyTemplate().deleteApiKeyByLoginId(loginId);
+ }
+
+ // ------- 创建
+
+ /**
+ * 创建一个 ApiKeyModel 对象
+ *
+ * @return /
+ */
+ public static ApiKeyModel createApiKeyModel() {
+ return SaManager.getSaApiKeyTemplate().createApiKeyModel();
+ }
+
+ /**
+ * 创建一个 ApiKeyModel 对象
+ *
+ * @return /
+ */
+ public static ApiKeyModel createApiKeyModel(Object loginId) {
+ return SaManager.getSaApiKeyTemplate().createApiKeyModel(loginId);
+ }
+
+
+ // ------------------- Scope
+
+ /**
+ * 判断:指定 ApiKey 是否具有指定 Scope 列表 (AND 模式,需要全部具备),返回 true 或 false
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public static boolean hasApiKeyScope(String apiKey, String... scopes) {
+ return SaManager.getSaApiKeyTemplate().hasApiKeyScope(apiKey, scopes);
+ }
+
+ /**
+ * 校验:指定 ApiKey 是否具有指定 Scope 列表 (AND 模式,需要全部具备),如果不具备则抛出异常
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public static void checkApiKeyScope(String apiKey, String... scopes) {
+ SaManager.getSaApiKeyTemplate().checkApiKeyScope(apiKey, scopes);
+ }
+
+ /**
+ * 判断:指定 ApiKey 是否具有指定 Scope 列表 (OR 模式,具备其一即可),返回 true 或 false
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public static boolean hasApiKeyScopeOr(String apiKey, String... scopes) {
+ return SaManager.getSaApiKeyTemplate().hasApiKeyScopeOr(apiKey, scopes);
+ }
+
+ /**
+ * 校验:指定 ApiKey 是否具有指定 Scope 列表 (OR 模式,具备其一即可),如果不具备则抛出异常
+ * @param apiKey ApiKey
+ * @param scopes 需要校验的权限列表
+ */
+ public static void checkApiKeyScopeOr(String apiKey, String... scopes) {
+ SaManager.getSaApiKeyTemplate().checkApiKeyScopeOr(apiKey, scopes);
+ }
+
+ /**
+ * 判断:指定 ApiKey 是否属于指定 LoginId,返回 true 或 false
+ * @param apiKey /
+ * @param loginId /
+ */
+ public static boolean isApiKeyLoginId(String apiKey, Object loginId) {
+ return SaManager.getSaApiKeyTemplate().isApiKeyLoginId(apiKey, loginId);
+ }
+
+ /**
+ * 校验:指定 ApiKey 是否属于指定 LoginId,如果不是则抛出异常
+ *
+ * @param apiKey /
+ * @param loginId /
+ */
+ public static void checkApiKeyLoginId(String apiKey, Object loginId) {
+ SaManager.getSaApiKeyTemplate().checkApiKeyLoginId(apiKey, loginId);
+ }
+
+
+ // ------------------- 请求查询
+
+ /**
+ * 数据读取:从请求对象中读取 ApiKey,获取不到返回 null
+ */
+ public static String readApiKeyValue(SaRequest request) {
+ return SaManager.getSaApiKeyTemplate().readApiKeyValue(request);
+ }
+
+ /**
+ * 数据读取:从请求对象中读取 ApiKey,并查询到 ApiKeyModel 信息
+ */
+ public static ApiKeyModel currentApiKey() {
+ return SaManager.getSaApiKeyTemplate().currentApiKey();
+ }
+
+
+ // ------------------- 索引操作
+
+ /**
+ * 调整指定 SaSession 的 TTL 值,以保证最小化内存占用
+ * @param loginId /
+ * @param session 可填写 null,代表使用 loginId 现场查询
+ */
+ public static void adjustIndex(Object loginId, SaSession session) {
+ SaManager.getSaApiKeyTemplate().adjustIndex(loginId, session);
+ }
+
+ /**
+ * 获取指定 loginId 的 ApiKey 列表记录
+ * @param loginId /
+ * @return /
+ */
+ public static List getApiKeyList(Object loginId) {
+ return SaManager.getSaApiKeyTemplate().getApiKeyList(loginId);
+ }
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoader.java b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoader.java
new file mode 100644
index 00000000..7a877f00
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoader.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.apikey.loader;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.apikey.model.ApiKeyModel;
+
+/**
+ * ApiKey 数据加载器
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public interface SaApiKeyDataLoader {
+
+ /**
+ * 获取:框架是否保存索引信息
+ *
+ * @return /
+ */
+ default Boolean getIsRecordIndex() {
+ return SaManager.getConfig().getApiKey().getIsRecordIndex();
+ }
+
+ /**
+ * 根据 apiKey 从数据库获取 ApiKeyModel 信息 (实现此方法无需为数据做缓存处理,框架内部已包含缓存逻辑)
+ *
+ * @param apiKey /
+ * @return ApiKeyModel
+ */
+ default ApiKeyModel getApiKeyModelFromDatabase(String apiKey) {
+ return null;
+ }
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoaderDefaultImpl.java b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoaderDefaultImpl.java
new file mode 100644
index 00000000..72131f9f
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/loader/SaApiKeyDataLoaderDefaultImpl.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.apikey.loader;
+
+/**
+ * ApiKey 数据加载器 默认实现类
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class SaApiKeyDataLoaderDefaultImpl implements SaApiKeyDataLoader {
+
+ // be empty of
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/apikey/model/ApiKeyModel.java b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/model/ApiKeyModel.java
new file mode 100644
index 00000000..b86ac8ff
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/apikey/model/ApiKeyModel.java
@@ -0,0 +1,380 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.apikey.model;
+
+import cn.dev33.satoken.dao.SaTokenDao;
+import cn.dev33.satoken.error.SaErrorCode;
+import cn.dev33.satoken.exception.ApiKeyException;
+import cn.dev33.satoken.util.SaFoxUtil;
+
+import java.io.Serializable;
+import java.util.*;
+
+/**
+ * Model: API Key
+ *
+ * @author click33
+ * @since 1.41.0
+ */
+public class ApiKeyModel implements Serializable {
+
+ private static final long serialVersionUID = -6541180061782004705L;
+
+ /**
+ * 名称
+ */
+ private String title;
+
+ /**
+ * 介绍
+ */
+ private String intro;
+
+ /**
+ * ApiKey 值
+ */
+ private String apiKey;
+
+ /**
+ * 账号 id
+ */
+ private Object loginId;
+
+ /**
+ * ApiKey 创建时间,13位时间戳
+ */
+ private long createTime;
+
+ /**
+ * ApiKey 到期时间,13位时间戳 (-1=永不过期)
+ */
+ private long expiresTime;
+
+ /**
+ * 是否有效 (true=生效, false=禁用)
+ */
+ private Boolean isValid = true;
+
+ /**
+ * 授权范围
+ */
+ private List scopes = new ArrayList<>();
+
+ /**
+ * 扩展数据
+ */
+ private Map extraData;
+
+ /**
+ * 构造函数
+ */
+ public ApiKeyModel() {
+ this.createTime = System.currentTimeMillis();
+ }
+
+
+ // method
+
+ /**
+ * 添加 Scope
+ * @param scope /
+ * @return /
+ */
+ public ApiKeyModel addScope(String ...scope) {
+ if (this.scopes == null) {
+ this.scopes = new ArrayList<>();
+ }
+ this.scopes.addAll(Arrays.asList(scope));
+ return this;
+ }
+
+ /**
+ * 添加 扩展数据
+ * @param key /
+ * @param value /
+ * @return /
+ */
+ public ApiKeyModel addExtra(String key, Object value) {
+ if (this.extraData == null) {
+ this.extraData = new LinkedHashMap<>();
+ }
+ this.extraData.put(key, value);
+ return this;
+ }
+
+ /**
+ * 查询扩展数据
+ */
+ public Object getExtra(String key) {
+ if (this.extraData == null) {
+ return null;
+ }
+ return this.extraData.get(key);
+ }
+
+ /**
+ * 删除扩展数据
+ */
+ public Object removeExtra(String key) {
+ if (this.extraData == null) {
+ return null;
+ }
+ return this.extraData.remove(key);
+ }
+
+ /**
+ * 数据自检,判断是否可以保存入库
+ */
+ public void checkByCanSaved() {
+ if (SaFoxUtil.isEmpty(this.apiKey)) {
+ throw new ApiKeyException("ApiKey 值不可为空").setApiKey(apiKey).setCode(SaErrorCode.CODE_12304);
+ }
+ if (this.loginId == null) {
+ throw new ApiKeyException("无效 ApiKey: " + apiKey).setApiKey(apiKey).setCode(SaErrorCode.CODE_12304);
+ }
+ if (this.createTime == 0) {
+ throw new ApiKeyException("请指定 createTime 创建时间").setApiKey(apiKey).setCode(SaErrorCode.CODE_12304);
+ }
+ if (this.expiresTime == 0) {
+ throw new ApiKeyException("请指定 expiresTime 过期时间").setApiKey(apiKey).setCode(SaErrorCode.CODE_12304);
+ }
+ if (this.isValid == null) {
+ throw new ApiKeyException("请指定 isValid 是否生效").setApiKey(apiKey).setCode(SaErrorCode.CODE_12304);
+ }
+ }
+
+ /**
+ * 获取:此 ApiKey 的剩余有效期(秒), -1=永不过期
+ * @return /
+ */
+ public long expiresIn() {
+ if (expiresTime == SaTokenDao.NEVER_EXPIRE) {
+ return SaTokenDao.NEVER_EXPIRE;
+ }
+ long s = (expiresTime - System.currentTimeMillis()) / 1000;
+ return s < 1 ? -2 : s;
+ }
+
+ /**
+ * 判断:此 ApiKey 是否已超时
+ * @return /
+ */
+ public boolean timeExpired() {
+ if (expiresTime == SaTokenDao.NEVER_EXPIRE) {
+ return false;
+ }
+ return System.currentTimeMillis() > expiresTime;
+ }
+
+
+ // get and set
+
+ /**
+ * 获取 名称
+ *
+ * @return title 名称
+ */
+ public String getTitle() {
+ return this.title;
+ }
+
+ /**
+ * 设置 名称
+ *
+ * @param title 名称
+ * @return 对象自身
+ */
+ public ApiKeyModel setTitle(String title) {
+ this.title = title;
+ return this;
+ }
+
+ /**
+ * 获取 介绍
+ *
+ * @return intro 介绍
+ */
+ public String getIntro() {
+ return this.intro;
+ }
+
+ /**
+ * 设置 介绍
+ *
+ * @param intro 介绍
+ * @return 对象自身
+ */
+ public ApiKeyModel setIntro(String intro) {
+ this.intro = intro;
+ return this;
+ }
+
+ /**
+ * 获取 ApiKey 值
+ *
+ * @return apiKey ApiKey 值
+ */
+ public String getApiKey() {
+ return this.apiKey;
+ }
+
+ /**
+ * 设置 ApiKey 值
+ *
+ * @param apiKey ApiKey 值
+ * @return 对象自身
+ */
+ public ApiKeyModel setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
+ /**
+ * 获取 账号 id
+ *
+ * @return loginId 账号 id
+ */
+ public Object getLoginId() {
+ return this.loginId;
+ }
+
+ /**
+ * 设置 账号 id
+ *
+ * @param loginId 账号 id
+ * @return 对象自身
+ */
+ public ApiKeyModel setLoginId(Object loginId) {
+ this.loginId = loginId;
+ return this;
+ }
+
+ /**
+ * 获取 ApiKey 创建时间,13位时间戳
+ *
+ * @return createTime ApiKey 创建时间,13位时间戳
+ */
+ public long getCreateTime() {
+ return this.createTime;
+ }
+
+ /**
+ * 设置 ApiKey 创建时间,13位时间戳
+ *
+ * @param createTime ApiKey 创建时间,13位时间戳
+ * @return 对象自身
+ */
+ public ApiKeyModel setCreateTime(long createTime) {
+ this.createTime = createTime;
+ return this;
+ }
+
+ /**
+ * 获取 ApiKey 到期时间,13位时间戳 (-1=永不过期)
+ *
+ * @return expiresTime ApiKey 到期时间,13位时间戳 (-1=永不过期)
+ */
+ public long getExpiresTime() {
+ return this.expiresTime;
+ }
+
+ /**
+ * 设置 ApiKey 到期时间,13位时间戳 (-1=永不过期)
+ *
+ * @param expiresTime ApiKey 到期时间,13位时间戳 (-1=永不过期)
+ * @return 对象自身
+ */
+ public ApiKeyModel setExpiresTime(long expiresTime) {
+ this.expiresTime = expiresTime;
+ return this;
+ }
+
+ /**
+ * 获取 是否有效 (true=生效 false=禁用)
+ *
+ * @return /
+ */
+ public Boolean getIsValid() {
+ return this.isValid;
+ }
+
+ /**
+ * 设置 是否有效 (true=生效 false=禁用)
+ *
+ * @param isValid /
+ * @return 对象自身
+ */
+ public ApiKeyModel setIsValid(Boolean isValid) {
+ this.isValid = isValid;
+ return this;
+ }
+
+ /**
+ * 获取 授权范围
+ *
+ * @return scopes 授权范围
+ */
+ public List getScopes() {
+ return this.scopes;
+ }
+
+ /**
+ * 设置 授权范围
+ *
+ * @param scopes 授权范围
+ * @return 对象自身
+ */
+ public ApiKeyModel setScopes(List scopes) {
+ this.scopes = scopes;
+ return this;
+ }
+
+ /**
+ * 获取 扩展数据
+ *
+ * @return extraData 扩展数据
+ */
+ public Map getExtraData() {
+ return this.extraData;
+ }
+
+ /**
+ * 设置 扩展数据
+ *
+ * @param extraData 扩展数据
+ * @return 对象自身
+ */
+ public ApiKeyModel setExtraData(Map extraData) {
+ this.extraData = extraData;
+ return this;
+ }
+
+
+ @Override
+ public String toString() {
+ return "ApiKeyModel{" +
+ "title='" + title +
+ ", intro='" + intro +
+ ", apiKey='" + apiKey +
+ ", loginId=" + loginId +
+ ", createTime=" + createTime +
+ ", expiresTime=" + expiresTime +
+ ", isValid=" + isValid +
+ ", scopes=" + scopes +
+ ", extraData=" + extraData +
+ '}';
+ }
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/config/SaApiKeyConfig.java b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaApiKeyConfig.java
new file mode 100644
index 00000000..452a05e5
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaApiKeyConfig.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.config;
+
+/**
+ * Sa-Token API Key 相关配置
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class SaApiKeyConfig {
+
+ /**
+ * API Key 前缀
+ */
+ private String prefix = "AK-";
+
+ /**
+ * API Key 有效期,-1=永久有效,默认30天 (修改此配置项不会影响到已创建的 API Key)
+ */
+ private long timeout = 2592000;
+
+ /**
+ * 框架是否记录索引信息
+ */
+ private Boolean isRecordIndex = true;
+
+ /**
+ * 获取 API Key 前缀
+ *
+ * @return /
+ */
+ public String getPrefix() {
+ return this.prefix;
+ }
+
+ /**
+ * 设置 API Key 前缀
+ *
+ * @param prefix /
+ * @return 对象自身
+ */
+ public SaApiKeyConfig setPrefix(String prefix) {
+ this.prefix = prefix;
+ return this;
+ }
+
+ /**
+ * 获取 API Key 有效期,-1=永久有效,默认30天 (修改此配置项不会影响到已创建的 API Key)
+ *
+ * @return /
+ */
+ public long getTimeout() {
+ return this.timeout;
+ }
+
+ /**
+ * 设置 API Key 有效期,-1=永久有效,默认30天 (修改此配置项不会影响到已创建的 API Key)
+ *
+ * @param timeout /
+ * @return 对象自身
+ */
+ public SaApiKeyConfig setTimeout(long timeout) {
+ this.timeout = timeout;
+ return this;
+ }
+
+ /**
+ * 获取 框架是否保存索引信息
+ *
+ * @return /
+ */
+ public Boolean getIsRecordIndex() {
+ return this.isRecordIndex;
+ }
+
+ /**
+ * 设置 框架是否保存索引信息
+ *
+ * @param isRecordIndex /
+ * @return 对象自身
+ */
+ public SaApiKeyConfig setIsRecordIndex(Boolean isRecordIndex) {
+ this.isRecordIndex = isRecordIndex;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "SaApiKeyConfig{" +
+ "prefix='" + prefix + '\'' +
+ ", timeout=" + timeout +
+ ", isRecordIndex=" + isRecordIndex +
+ '}';
+ }
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java
index 96af30b4..0a6ff227 100644
--- a/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java
@@ -233,6 +233,10 @@ public class SaTokenConfig implements Serializable {
*/
public Map signMany = new LinkedHashMap<>();
+ /**
+ * API Key 相关配置
+ */
+ public SaApiKeyConfig apiKey = new SaApiKeyConfig();
/**
* @return token 名称 (同时也是: cookie 名称、提交 token 时参数的名称、存储 token 时的 key 前缀)
@@ -898,6 +902,26 @@ public class SaTokenConfig implements Serializable {
return this;
}
+ /**
+ * API Key 相关配置
+ *
+ * @return /
+ */
+ public SaApiKeyConfig getApiKey() {
+ return this.apiKey;
+ }
+
+ /**
+ * 设置 API Key 相关配置
+ *
+ * @param apiKey /
+ * @return /
+ */
+ public SaTokenConfig setApiKey(SaApiKeyConfig apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
@Override
public String toString() {
@@ -941,6 +965,7 @@ public class SaTokenConfig implements Serializable {
+ ", cookie=" + cookie
+ ", sign=" + sign
+ ", signMany=" + signMany
+ + ", apiKey=" + apiKey
+ "]";
}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/error/SaErrorCode.java b/sa-token-core/src/main/java/cn/dev33/satoken/error/SaErrorCode.java
index dca784e8..a69af4b8 100644
--- a/sa-token-core/src/main/java/cn/dev33/satoken/error/SaErrorCode.java
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/error/SaErrorCode.java
@@ -204,4 +204,27 @@ public interface SaErrorCode {
/** 未找到对应 appid 的 SaSignConfig */
int CODE_12211 = 12211;
+ // ------------
+
+ /** 无效 API Key */
+ int CODE_12301 = 12301;
+
+ /** API Key 已过期 */
+ int CODE_12302 = 12302;
+
+ /** API Key 已被禁用 */
+ int CODE_12303 = 12303;
+
+ /** API Key 字段自检未通过 */
+ int CODE_12304 = 12304;
+
+ /** 未开启索引记录功能却调用了相关 API */
+ int CODE_12305 = 12305;
+
+ /** API Key 不具有指定 Scope */
+ int CODE_12311 = 12311;
+
+ /** API Key 不属于指定用户 */
+ int CODE_12312 = 12312;
+
}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyException.java b/sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyException.java
new file mode 100644
index 00000000..e069eccf
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyException.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.exception;
+
+/**
+ * 一个异常:代表 ApiKey 相关错误
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class ApiKeyException extends SaTokenException {
+
+ /**
+ * 序列化版本号
+ */
+ private static final long serialVersionUID = 6806129545290130114L;
+
+ /**
+ * 一个异常:代表 ApiKey 相关错误
+ * @param cause 根异常原因
+ */
+ public ApiKeyException(Throwable cause) {
+ super(cause);
+ }
+
+ /**
+ * 一个异常:代表 ApiKey 相关错误
+ * @param message 异常描述
+ */
+ public ApiKeyException(String message) {
+ super(message);
+ }
+
+ /**
+ * 具体引起异常的 ApiKey 值
+ */
+ public String apiKey;
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public ApiKeyException setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
+ /**
+ * 如果 flag==true,则抛出 message 异常
+ * @param flag 标记
+ * @param message 异常信息
+ * @param code 异常细分码
+ */
+ public static void throwBy(boolean flag, String message, int code) {
+ if(flag) {
+ throw new ApiKeyException(message).setCode(code);
+ }
+ }
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyScopeException.java b/sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyScopeException.java
new file mode 100644
index 00000000..fa176116
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/exception/ApiKeyScopeException.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.exception;
+
+/**
+ * 一个异常:代表 ApiKey Scope 相关错误
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class ApiKeyScopeException extends ApiKeyException {
+
+ /**
+ * 序列化版本号
+ */
+ private static final long serialVersionUID = 6806129545290130114L;
+
+ /**
+ * 一个异常:代表 ApiKey Scope 相关错误
+ * @param cause 根异常原因
+ */
+ public ApiKeyScopeException(Throwable cause) {
+ super(cause);
+ }
+
+ /**
+ * 一个异常:代表 ApiKey Scope 相关错误
+ * @param message 异常描述
+ */
+ public ApiKeyScopeException(String message) {
+ super(message);
+ }
+
+ /**
+ * 具体引起异常的 ApiKey 值
+ */
+ public String apiKey;
+
+ /**
+ * 具体引起异常的 scope 值
+ */
+ public String scope;
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public ApiKeyScopeException setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
+ public String getScope() {
+ return scope;
+ }
+
+ public ApiKeyScopeException setScope(String scope) {
+ this.scope = scope;
+ return this;
+ }
+
+ /**
+ * 如果 flag==true,则抛出 message 异常
+ * @param flag 标记
+ * @param message 异常信息
+ * @param code 异常细分码
+ */
+ public static void throwBy(boolean flag, String message, int code) {
+ if(flag) {
+ throw new ApiKeyScopeException(message).setCode(code);
+ }
+ }
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/secure/SaBase32Util.java b/sa-token-core/src/main/java/cn/dev33/satoken/secure/SaBase32Util.java
index fa85eadd..def16028 100644
--- a/sa-token-core/src/main/java/cn/dev33/satoken/secure/SaBase32Util.java
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/secure/SaBase32Util.java
@@ -21,7 +21,7 @@ import java.nio.charset.StandardCharsets;
* Sa-Token Base32 工具类
*
* @author click33
- * @since 1.41.0
+ * @since 1.42.0
*/
public class SaBase32Util {
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpTemplate.java b/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpTemplate.java
index c3c0c286..848db916 100644
--- a/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpTemplate.java
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpTemplate.java
@@ -30,7 +30,7 @@ import java.time.Instant;
* TOTP 算法类,支持 生成/验证 动态一次性密码
*
* @author click33
- * @since 1.41.0
+ * @since 1.42.0
*/
public class SaTotpTemplate {
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpUtil.java b/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpUtil.java
index 4bb9587f..915d86a2 100644
--- a/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpUtil.java
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/secure/totp/SaTotpUtil.java
@@ -21,7 +21,7 @@ import cn.dev33.satoken.SaManager;
* TOTP 工具类,支持 生成/验证 动态一次性密码
*
* @author click33
- * @since 1.41.0
+ * @since 1.42.0
*/
public class SaTotpUtil {
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/session/SaSessionRawUtil.java b/sa-token-core/src/main/java/cn/dev33/satoken/session/SaSessionRawUtil.java
new file mode 100644
index 00000000..f323e5e8
--- /dev/null
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/session/SaSessionRawUtil.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2020-2099 sa-token.cc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cn.dev33.satoken.session;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.strategy.SaStrategy;
+
+/**
+ * SaSession 读写工具类
+ *
+ * @author click33
+ * @since 1.42.0
+ */
+public class SaSessionRawUtil {
+
+ private SaSessionRawUtil() {
+ }
+
+ /**
+ * 拼接Key: 在存储 SaSession 时应该使用的 key
+ *
+ * @param type 类型
+ * @param valueId 唯一标识
+ * @return sessionId
+ */
+ public static String splicingSessionKey(String type, Object valueId) {
+ return SaManager.getConfig().getTokenName() + ":raw-session:" + type + ":" + valueId;
+ }
+
+ /**
+ * 判断:指定 SaSession 是否存在
+ *
+ * @param type /
+ * @param valueId /
+ * @return 是否存在
+ */
+ public static boolean isExists(String type, Object valueId) {
+ return SaManager.getSaTokenDao().getSession(splicingSessionKey(type, valueId)) != null;
+ }
+
+ /**
+ * 获取指定 SaSession 对象, 如果此 SaSession 尚未在 Cache 创建,isCreate 参数代表是否则新建并返回
+ *
+ * @param type /
+ * @param valueId /
+ * @param isCreate 如果此 SaSession 尚未在 DB 创建,是否新建并返回
+ * @return SaSession 对象
+ */
+ public static SaSession getSessionById(String type, Object valueId, boolean isCreate) {
+ String sessionId = splicingSessionKey(type, valueId);
+ SaSession session = SaManager.getSaTokenDao().getSession(sessionId);
+ if (session == null && isCreate) {
+ session = SaStrategy.instance.createSession.apply(sessionId);
+ session.setType(type);
+ // TODO 过期时间
+ SaManager.getSaTokenDao().setSession(session, SaManager.getConfig().getTimeout());
+ }
+ return session;
+ }
+
+ /**
+ * 获取指定 SaSession, 如果此 SaSession 尚未在 DB 创建,则新建并返回
+ *
+ * @param type /
+ * @param valueId /
+ * @return SaSession 对象
+ */
+ public static SaSession getSessionById(String type, Object valueId) {
+ return getSessionById(type, valueId, true);
+ }
+
+ /**
+ * 删除指定 SaSession
+ *
+ * @param type /
+ * @param valueId /
+ */
+ public static void deleteSessionById(String type, Object valueId) {
+ SaManager.getSaTokenDao().deleteSession(splicingSessionKey(type, valueId));
+ }
+
+}
diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/strategy/SaAnnotationStrategy.java b/sa-token-core/src/main/java/cn/dev33/satoken/strategy/SaAnnotationStrategy.java
index cd554800..a53baa33 100644
--- a/sa-token-core/src/main/java/cn/dev33/satoken/strategy/SaAnnotationStrategy.java
+++ b/sa-token-core/src/main/java/cn/dev33/satoken/strategy/SaAnnotationStrategy.java
@@ -66,6 +66,7 @@ public final class SaAnnotationStrategy {
annotationHandlerMap.put(SaCheckHttpDigest.class, new SaCheckHttpDigestHandler());
annotationHandlerMap.put(SaCheckOr.class, new SaCheckOrHandler());
annotationHandlerMap.put(SaCheckSign.class, new SaCheckSignHandler());
+ annotationHandlerMap.put(SaCheckApiKey.class, new SaCheckApiKeyHandler());
}
/**
diff --git a/sa-token-demo/pom.xml b/sa-token-demo/pom.xml
index a9bed331..f4f4612e 100644
--- a/sa-token-demo/pom.xml
+++ b/sa-token-demo/pom.xml
@@ -11,6 +11,7 @@
sa-token-demo-alone-redis
sa-token-demo-alone-redis-cluster
+ sa-token-demo-apikey
sa-token-demo-beetl
sa-token-demo-bom-import
sa-token-demo-caffeine
diff --git a/sa-token-demo/sa-token-demo-apikey/pom.xml b/sa-token-demo/sa-token-demo-apikey/pom.xml
new file mode 100644
index 00000000..64728f49
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/pom.xml
@@ -0,0 +1,67 @@
+
+ 4.0.0
+ cn.dev33
+ sa-token-demo-apikey
+ 0.0.1-SNAPSHOT
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 2.5.14
+
+
+
+
+
+ 1.41.0
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ cn.dev33
+ sa-token-spring-boot-starter
+ ${sa-token.version}
+
+
+
+
+ cn.dev33
+ sa-token-redis-template
+ ${sa-token.version}
+
+
+
+
+ org.apache.commons
+ commons-pool2
+
+
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ provided
+
+
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+
+
+
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/SaTokenApiKeyApplication.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/SaTokenApiKeyApplication.java
new file mode 100644
index 00000000..b5858879
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/SaTokenApiKeyApplication.java
@@ -0,0 +1,16 @@
+package com.pj;
+
+import cn.dev33.satoken.SaManager;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SaTokenApiKeyApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SaTokenApiKeyApplication.class, args);
+ System.out.println("\n启动成功,Sa-Token 配置如下:" + SaManager.getConfig());
+ System.out.println("\n测试访问:http://localhost:8081/index.html");
+ }
+
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyDataLoaderImpl.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyDataLoaderImpl.java
new file mode 100644
index 00000000..c36cabaf
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyDataLoaderImpl.java
@@ -0,0 +1,31 @@
+package com.pj.mock;
+
+import cn.dev33.satoken.apikey.loader.SaApiKeyDataLoader;
+import cn.dev33.satoken.apikey.model.ApiKeyModel;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * API Key 数据加载器实现类 (从数据库查询)
+ *
+ * @author click33
+ * @since 2025/4/4
+ */
+//@Component // 打开此注解后,springboot 会自动注入此组件,打开 Sa-Token API Key 模块的数据库模式
+public class SaApiKeyDataLoaderImpl implements SaApiKeyDataLoader {
+
+ @Autowired
+ SaApiKeyMockMapper apiKeyMockMapper;
+
+ // 指定框架不再维护 API Key 索引信息,而是由我们手动从数据库维护
+ @Override
+ public Boolean getIsRecordIndex() {
+ return false;
+ }
+
+ // 根据 apiKey 从数据库获取 ApiKeyModel 信息 (实现此方法无需为数据做缓存处理,框架内部已包含缓存逻辑)
+ @Override
+ public ApiKeyModel getApiKeyModelFromDatabase(String apiKey) {
+ return apiKeyMockMapper.getApiKeyModel(apiKey);
+ }
+
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyMockMapper.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyMockMapper.java
new file mode 100644
index 00000000..65205dc6
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/mock/SaApiKeyMockMapper.java
@@ -0,0 +1,42 @@
+package com.pj.mock;
+
+import cn.dev33.satoken.apikey.model.ApiKeyModel;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 模拟数据库操作类
+ *
+ * @author click33
+ * @since 2025/4/4
+ */
+@Service
+public class SaApiKeyMockMapper {
+
+ // 添加模拟测试数据
+ public static final Map map = new HashMap<>();
+ static {
+ ApiKeyModel ak1 = new ApiKeyModel();
+ ak1.setLoginId(10001); // 设置绑定的用户 id
+ ak1.setApiKey("AK-NAO6u57zbOWCmLaiVQuVW2tyt3rHpZrXkaQp"); // 设置 API Key 值
+ ak1.setTitle("test"); // 设置名称
+ ak1.setExpiresTime(System.currentTimeMillis() + 2592000); // 设置失效时间,13位时间戳,-1=永不失效
+ map.put(ak1.getApiKey(), ak1);
+
+ ApiKeyModel ak2 = new ApiKeyModel();
+ ak2.setLoginId(10001); // 设置绑定的用户 id
+ ak2.setApiKey("AK-NxcO63u57zbOWCmLaiVQuVWXssRwAxFcAxcFF"); // 设置 API Key 值
+ ak2.setTitle("commit2"); // 设置名称
+ ak1.addScope("commit", "pull"); // 设置权限范围
+ ak2.setExpiresTime(System.currentTimeMillis() + 2592000); // 设置失效时间,13位时间戳,-1=永不失效
+ map.put(ak2.getApiKey(), ak2);
+ }
+
+ // 返回指定 API Key 对应的 ApiKeyModel
+ public ApiKeyModel getApiKeyModel(String apiKey) {
+ return map.get(apiKey);
+ }
+
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/GlobalException.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/GlobalException.java
new file mode 100644
index 00000000..c38ebed3
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/GlobalException.java
@@ -0,0 +1,24 @@
+package com.pj.satoken;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import cn.dev33.satoken.util.SaResult;
+
+/**
+ * 全局异常处理
+ */
+@RestControllerAdvice
+public class GlobalException {
+
+ // 全局异常拦截(拦截项目中的所有异常)
+ @ExceptionHandler
+ public SaResult handlerException(Exception e, HttpServletRequest request, HttpServletResponse response) throws Exception {
+ e.printStackTrace();
+ return SaResult.error(e.getMessage());
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/SaTokenConfigure.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/SaTokenConfigure.java
new file mode 100644
index 00000000..38756c7a
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/satoken/SaTokenConfigure.java
@@ -0,0 +1,76 @@
+package com.pj.satoken;
+
+import cn.dev33.satoken.context.SaHolder;
+import cn.dev33.satoken.filter.SaServletFilter;
+import cn.dev33.satoken.interceptor.SaInterceptor;
+import cn.dev33.satoken.router.SaHttpMethod;
+import cn.dev33.satoken.router.SaRouter;
+import cn.dev33.satoken.util.SaResult;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+
+/**
+ * [Sa-Token 权限认证] 配置类
+ * @author click33
+ *
+ */
+@Configuration
+public class SaTokenConfigure implements WebMvcConfigurer {
+
+ /**
+ * 注册 Sa-Token 拦截器打开注解鉴权功能
+ */
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
+ }
+
+ /**
+ * 注册 [Sa-Token 全局过滤器]
+ */
+ @Bean
+ public SaServletFilter getSaServletFilter() {
+ return new SaServletFilter()
+
+ // 指定 [拦截路由] 与 [放行路由]
+ .addInclude("/**")// .addExclude("/favicon.ico")
+
+ // 认证函数: 每次请求执行
+ .setAuth(obj -> {
+ // 输出 API 请求日志,方便调试代码
+ // SaManager.getLog().debug("----- 请求path={} 提交token={}", SaHolder.getRequest().getRequestPath(), StpUtil.getTokenValue());
+
+ })
+
+ // 异常处理函数:每次认证函数发生异常时执行此函数
+ .setError(e -> {
+ System.out.println("---------- sa全局异常 ");
+ e.printStackTrace();
+ return SaResult.error(e.getMessage());
+ })
+
+ // 前置函数:在每次认证函数之前执行
+ .setBeforeAuth(obj -> {
+ // ---------- 设置一些安全响应头 ----------
+ SaHolder.getResponse()
+ // 允许指定域访问跨域资源
+ .setHeader("Access-Control-Allow-Origin", "*")
+ // 允许所有请求方式
+ .setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE")
+ // 有效时间
+ .setHeader("Access-Control-Max-Age", "3600")
+ // 允许的header参数
+ .setHeader("Access-Control-Allow-Headers", "*");
+
+ // 如果是预检请求,则立即返回到前端
+ SaRouter.match(SaHttpMethod.OPTIONS)
+ .free(r -> System.out.println("--------OPTIONS预检请求,不做处理"))
+ .back();
+ })
+ ;
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyController.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyController.java
new file mode 100644
index 00000000..c9eae5fb
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyController.java
@@ -0,0 +1,65 @@
+package com.pj.test;
+
+import cn.dev33.satoken.apikey.SaApiKeyUtil;
+import cn.dev33.satoken.apikey.model.ApiKeyModel;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.util.SaResult;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * API Key 相关接口
+ *
+ * @author click33
+ */
+@RestController
+public class ApiKeyController {
+
+ // 返回当前登录用户拥有的 ApiKey 列表
+ @RequestMapping("/myApiKeyList")
+ public SaResult myApiKeyList() {
+ List apiKeyList = SaApiKeyUtil.getApiKeyList(StpUtil.getLoginId());
+ return SaResult.data(apiKeyList);
+ }
+
+ // 创建一个新的 ApiKey,并返回
+ @RequestMapping("/createApiKey")
+ public SaResult createApiKey() {
+ ApiKeyModel akModel = SaApiKeyUtil.createApiKeyModel(StpUtil.getLoginId()).setTitle("test");
+ SaApiKeyUtil.saveApiKey(akModel);
+ return SaResult.data(akModel);
+ }
+
+ // 修改 ApiKey
+ @RequestMapping("/updateApiKey")
+ public SaResult updateApiKey(ApiKeyModel akModel) {
+ // 先验证一下是否为本人的 ApiKey
+ SaApiKeyUtil.checkApiKeyLoginId(akModel.getApiKey(), StpUtil.getLoginId());
+ // 修改
+ ApiKeyModel akModel2 = SaApiKeyUtil.getApiKey(akModel.getApiKey());
+ akModel2.setTitle(akModel.getTitle());
+ akModel2.setExpiresTime(akModel.getExpiresTime());
+ akModel2.setIsValid(akModel.getIsValid());
+ akModel2.setScopes(akModel.getScopes());
+ SaApiKeyUtil.saveApiKey(akModel2);
+ return SaResult.ok();
+ }
+
+ // 删除 ApiKey
+ @RequestMapping("/deleteApiKey")
+ public SaResult deleteApiKey(String apiKey) {
+ SaApiKeyUtil.checkApiKeyLoginId(apiKey, StpUtil.getLoginId());
+ SaApiKeyUtil.deleteApiKey(apiKey);
+ return SaResult.ok();
+ }
+
+ // 删除当前用户所有 ApiKey
+ @RequestMapping("/deleteMyAllApiKey")
+ public SaResult deleteMyAllApiKey() {
+ SaApiKeyUtil.deleteApiKeyByLoginId(StpUtil.getLoginId());
+ return SaResult.ok();
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyResourcesController.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyResourcesController.java
new file mode 100644
index 00000000..8083f128
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/ApiKeyResourcesController.java
@@ -0,0 +1,55 @@
+package com.pj.test;
+
+import cn.dev33.satoken.annotation.SaCheckApiKey;
+import cn.dev33.satoken.annotation.SaMode;
+import cn.dev33.satoken.apikey.SaApiKeyUtil;
+import cn.dev33.satoken.apikey.model.ApiKeyModel;
+import cn.dev33.satoken.util.SaResult;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * API Key 资源 相关接口
+ *
+ * @author click33
+ */
+@RestController
+public class ApiKeyResourcesController {
+
+ // 必须携带有效的 ApiKey 才能访问
+ @SaCheckApiKey
+ @RequestMapping("/akRes1")
+ public SaResult akRes1() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+ // 必须携带有效的 ApiKey ,且具有 userinfo 权限
+ @SaCheckApiKey(scope = "userinfo")
+ @RequestMapping("/akRes2")
+ public SaResult akRes2() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+ // 必须携带有效的 ApiKey ,且同时具有 userinfo、chat 权限
+ @SaCheckApiKey(scope = {"userinfo", "chat"})
+ @RequestMapping("/akRes3")
+ public SaResult akRes3() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+ // 必须携带有效的 ApiKey ,且具有 userinfo、chat 其中之一权限
+ @SaCheckApiKey(scope = {"userinfo", "chat"}, mode = SaMode.OR)
+ @RequestMapping("/akRes4")
+ public SaResult akRes4() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/LoginController.java b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/LoginController.java
new file mode 100644
index 00000000..7ec224a5
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/java/com/pj/test/LoginController.java
@@ -0,0 +1,37 @@
+package com.pj.test;
+
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.util.SaResult;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 登录 Controller
+ *
+ * @author click33
+ */
+@RestController
+public class LoginController {
+
+ // 登录
+ @RequestMapping("login")
+ public SaResult login(@RequestParam(defaultValue="10001") String id) {
+ StpUtil.login(id);
+ return SaResult.ok().set("satoken", StpUtil.getTokenValue());
+ }
+
+ // 查询当前登录人
+ @RequestMapping("getLoginId")
+ public SaResult getLoginId() {
+ return SaResult.data(StpUtil.getLoginId());
+ }
+
+ // 注销
+ @RequestMapping("logout")
+ public SaResult logout() {
+ StpUtil.logout();
+ return SaResult.ok();
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/resources/application.yml b/sa-token-demo/sa-token-demo-apikey/src/main/resources/application.yml
new file mode 100644
index 00000000..41808724
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/resources/application.yml
@@ -0,0 +1,43 @@
+# 端口
+server:
+ port: 8081
+
+
+############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
+sa-token:
+ # token 名称 (同时也是 cookie 名称)
+ token-name: satoken
+ # 开启日志信息
+ is-log: true
+ # API Key 相关配置
+ api-key:
+ # API Key 前缀
+ prefix: AK-
+ # API Key 有效期,-1=永久有效,默认30天 (修改此配置项不会影响到已创建的 API Key)
+ timeout: 2592000
+ # 框架是否记录索引信息
+ is-record-index: true
+
+spring:
+ # redis配置
+ redis:
+ # Redis数据库索引(默认为0)
+ database: 0
+ # Redis服务器地址
+ host: 127.0.0.1
+ # Redis服务器连接端口
+ port: 6379
+ # Redis服务器连接密码(默认为空)
+ password:
+ # 连接超时时间
+ timeout: 10s
+ lettuce:
+ pool:
+ # 连接池最大连接数
+ max-active: 200
+ # 连接池最大阻塞等待时间(使用负值表示没有限制)
+ max-wait: -1ms
+ # 连接池中的最大空闲连接
+ max-idle: 10
+ # 连接池中的最小空闲连接
+ min-idle: 0
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/resources/static/common.js b/sa-token-demo/sa-token-demo-apikey/src/main/resources/static/common.js
new file mode 100644
index 00000000..a0690cb0
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/resources/static/common.js
@@ -0,0 +1,137 @@
+// 服务器接口主机地址
+var baseUrl = "http://localhost:8081";
+
+// 封装一下Ajax
+function ajax(path, data, successFn, errorFn) {
+ console.log(baseUrl + path);
+ fetch(baseUrl + path, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'satoken': localStorage.getItem('satoken')
+ },
+ body: serializeToQueryString(data),
+ })
+ .then(response => response.json())
+ .then(res => {
+ console.log('返回数据:', res);
+ if(res.code == 200) {
+ successFn(res);
+ } else {
+ if(errorFn) {
+ errorFn(res);
+ } else {
+ showMsg('错误:' + res.msg);
+ }
+ }
+ })
+ .catch(error => {
+ console.error('请求失败:', error);
+ return alert("异常:" + JSON.stringify(error));
+ });
+}
+
+
+// ------------ 工具方法 ---------------
+
+// 从url中查询到指定名称的参数值
+function getParam(name, defaultValue) {
+ var query = window.location.search.substring(1);
+ var vars = query.split("&");
+ for (var i = 0; i < vars.length; i++) {
+ var pair = vars[i].split("=");
+ if (pair[0] == name) {
+ return pair[1];
+ }
+ }
+ return (defaultValue == undefined ? null : defaultValue);
+}
+
+// 将 json 对象序列化为kv字符串,形如:name=Joh&age=30&active=true
+function serializeToQueryString(obj) {
+ return Object.entries(obj)
+ .filter(([_, value]) => value != null) // 过滤 null 和 undefined
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
+ .join('&');
+}
+
+// 随机生成字符串
+function randomString(len) {
+ len = len || 32;
+ var $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890';
+ var maxPos = $chars.length;
+ var str = '';
+ for (i = 0; i < len; i++) {
+ str += $chars.charAt(Math.floor(Math.random() * maxPos));
+ }
+ return str;
+}
+
+// 带动画的弹出提示
+function showMsg(message) {
+ const alertBox = document.createElement('div');
+
+ // 初始样式(包含隐藏状态)
+ Object.assign(alertBox.style, {
+ position: 'fixed',
+ left: '50%',
+ top: '50%',
+ transform: 'translate(-50%, -50%) scale(0.8) translateY(-30px)', // 初始缩放+位移
+ opacity: '0',
+ background: 'rgba(0, 0, 0, 0.85)',
+ color: 'white',
+ padding: '16px 32px',
+ borderRadius: '8px',
+ transition: 'all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55)', // 弹性动画曲线
+ pointerEvents: 'none',
+ whiteSpace: 'nowrap',
+ fontSize: '16px',
+ boxShadow: '0 4px 12px rgba(0,0,0,0.25)' // 添加投影增强立体感
+ });
+ alertBox.textContent = message;
+
+ document.body.appendChild(alertBox);
+
+ // 强制重绘确保动画触发
+ void alertBox.offsetHeight;
+
+ // 应用入场动画
+ Object.assign(alertBox.style, {
+ opacity: '1',
+ transform: 'translate(-50%, -50%) scale(1) translateY(-20px)'
+ });
+
+ // 自动消失逻辑
+ setTimeout(() => {
+ Object.assign(alertBox.style, {
+ opacity: '0',
+ transform: 'translate(-50%, -50%) scale(0.9) translateY(-20px)'
+ });
+
+ alertBox.addEventListener('transitionend', () => {
+ alertBox.remove();
+ }, {
+ once: true
+ });
+ }, 3000);
+}
+
+// 将日期格式化 yyyy-MM-dd HH:mm:ss
+function formatDateTime(date) {
+ date = new Date(date);
+ // 补零函数
+ const pad = (n, len) => n.toString().padStart(len, '0');
+
+ // 分解时间组件
+ const year = date.getFullYear();
+ const month = pad(date.getMonth() + 1, 2); // 0-11 → 1-12
+ const day = pad(date.getDate(), 2);
+ const hours = pad(date.getHours(), 2); // 24小时制
+ const minutes = pad(date.getMinutes(), 2);
+ const seconds = pad(date.getSeconds(), 2);
+ const milliseconds = pad(date.getMilliseconds(), 3);
+
+ // 拼接格式
+ // return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-apikey/src/main/resources/static/index.html b/sa-token-demo/sa-token-demo-apikey/src/main/resources/static/index.html
new file mode 100644
index 00000000..313f7e97
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-apikey/src/main/resources/static/index.html
@@ -0,0 +1,197 @@
+
+
+
+
+ Title
+
+
+
+
+
Sa-Token - API Key 测试页
+
登录
+
当前登录人:
+
输入账号 id 登录:
+
+
+
+
+
API Key 列表
+
+
+
+ | 名称 |
+ API Key |
+ 权限(多个用逗号隔开) |
+ 过期时间 |
+ 是否生效 |
+ 操作 |
+
+
+
+
+
+
+
调用 API
+
+ 使用的 API Key:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sa-token-doc/_sidebar.md b/sa-token-doc/_sidebar.md
index 86fe1923..347414f2 100644
--- a/sa-token-doc/_sidebar.md
+++ b/sa-token-doc/_sidebar.md
@@ -95,6 +95,7 @@
- [和 Dubbo 集成](/plugin/dubbo-extend)
- [和 gRPC 集成](/plugin/grpc-extend)
- [API 接口参数签名](/plugin/api-sign)
+ - [API Key 接口调用秘钥](/plugin/api-key)
- [Sa-Token 插件开发指南](/fun/plugin-dev)
- [自定义 SaTokenContext 指南](/fun/sa-token-context)
diff --git a/sa-token-doc/plugin/api-key.md b/sa-token-doc/plugin/api-key.md
new file mode 100644
index 00000000..37e6f0e3
--- /dev/null
+++ b/sa-token-doc/plugin/api-key.md
@@ -0,0 +1,227 @@
+# API Key 接口调用秘钥
+
+API Key(应用程序编程接口密钥) 是一种用于身份验证和授权的字符串代码,通常由服务提供商生成并分配给开发者或用户。它的主要作用是标识调用 API(应用程序编程接口)的请求来源,确保请求的合法性,并控制访问权限。
+
+以上是官话,简单理解:API Key 是一种接口调用密钥,类似于会话 token ,但比会话 token 具有更灵活的权限控制。
+
+
+### 1、需求场景
+
+为了帮助大家更好的理解 API Key 的应用场景,我们假设具有以下业务场景:
+
+> [!NOTE| label:业务场景]
+> 你们公司开发了一款论坛网站,非常火爆。
+>
+> 某日,你发现一位用户的头像可以随着日期而变化,Ta 的头像总是显示当前最新日期。
+>
+> 这并未引起你的警觉,因为你是一个程序员,在你看来,写一个任务脚本,每天定时调用 API 更新自己的头像是一件非常简单的事情。
+>
+> 一个月后,越来越多的账号“具有了此功能”,仿佛发生了人传人,Ta 们的头像都可以随着日期而变化,而且颜色各不相同,DIY 的不亦乐乎。
+>
+> 这引起了你的怀疑,如此大批账号的自动化更新行为,显然不是 “某个程序员利用定时脚本更新账号信息” 可以解释的。
+>
+> 一番调查之后,你发现了事情的真相,没有灰产公司捣乱,这批账号也不是机器账号,只是有一个公司为你们的网站开发了一款插件。
+>
+> 这款插件的作用是:用户把自己的 账号+密码 保存在插件中,插件便可以定时更新该账号的头像、昵称、资料等信息。
+>
+> 你觉得插件很有意思,但是插件“要求用户提交账号密码”的行为,让你感到很不爽。
+>
+> 总有一些用户为了得到“些许便利”,而出卖自己的账号密码给插件。
+>
+> 随着时间推移,越来越多的第三方公司或个人为你的网站开发插件:有的可以自动更新账号资料、有的可以自动发帖,有的检测到新粉丝就发送消息通知...
+>
+> 最终,不守规矩的插件出现了:一款插件在提供功能的同时,大量收集用户密码等隐私信息,作为不法用途。
+>
+> 为了遏制这种现象,你们公司升级了系统,增加了 IP 校验等风控判断,阻断了这些插件的 API 调用。
+>
+> 似乎……解决了问题?用户再也不会把账号密码交给第三方插件了。
+>
+> 但是插件的需求总是存在的呀,有些用户确实很需要这些插件的能力来提高网站使用体验。
+>
+> 俗话说的好,堵不如疏,既然用户有需求,第三方公司愿意免费打工开发插件,我们何不设计一套授权架构,
+> 既不需要让用户把账号密码交给第三方插件,又能让插件得到一些权限来调用特定 API 为用户服务。
+>
+> API Key 就是为了完成这种“可控式部分授权” 而设计的一种身份凭证。
+
+
+为了让第三方插件为用户工作,用户必定是要为插件提供一个“凭证”信息的,然后插件利用“凭证”信息,代替用户调用特定 API 完成一些功能。
+
+不同的凭证信息将会带来不同的后果:
+
+
+| 提供的凭证 | 后果 |
+| :-------- | :-------- |
+| 账号密码 | 插件可以得到账号所有权限,安全风险极高 |
+| 会话 token | 插件可以调用几乎所有 API,安全风险极高,且容易受到用户退出登录导致 token 失效的影响 |
+| API Key | 在可控的范围内进行部分授权,且可以方便的随时取消授权,只要设计得当,不会造成安全问题 |
+
+API Key 具有以下特点:
+- 1、格式类似于会话 token,是一个随机字符串。
+- 2、每个 API Key 都会和具体的用户 id 发生绑定,后端可以查询到此 API Key 的授权人是谁。
+- 3、一个用户可以创建多个 API Key,用作不同的插件中。
+- 4、每个 API Key 都可以赋予不同的 scope 权限,以做到最小化授权。
+- 5、API Key 可以设置有效期,并且随时删除回收,做到灵活控制。
+
+
+
+
+
+### 2、创建 API Key
+
+理解了应用场景后,让我们看看 Sa-Token 为 API Key 提供了哪些方法
+*(此插件是内嵌到 sa-token-core 核心包中的模块,大家无需再次引入其它依赖,插件直接可用)*:
+
+
+``` java
+// 为指定用户创建一个新的 API Key
+ApiKeyModel akModel = SaApiKeyUtil.createApiKeyModel(10001).setTitle("test");
+System.out.println("API Key 值:" + akModel.getApiKey());
+
+// 保存 API Key
+SaApiKeyUtil.saveApiKey(akModel);
+
+// 删除 API Key
+SaApiKeyUtil.deleteApiKey(apiKey);
+```
+
+一个 ApiKeyModel 可设置以下属性:
+``` java
+ApiKeyModel akModel = new ApiKeyModel();
+akModel.setLoginId(10001); // 设置绑定的用户 id
+akModel.setApiKey("AK-NAO6u57zbOWCmLaiVQuVW2tyt3rHpZrXkaQp"); // 设置 API Key 值
+akModel.setTitle("commit"); // 设置名称
+akModel.setIntro("提交代码专用"); // 设置描述
+akModel.addScope("commit", "pull"); // 设置权限范围
+akModel.setExpiresTime(System.currentTimeMillis() + 2592000); // 设置失效时间,13位时间戳,-1=永不失效
+akModel.setIsValid(true); // 设置是否有效
+akModel.addExtra("name", "张三"); // 设置扩展信息
+// 保存
+SaApiKeyUtil.saveApiKey(akModel);
+```
+
+查询:
+
+``` java
+// 获取 API Key 详细信息
+ApiKeyModel akModel = SaApiKeyUtil.getApiKey("AK-NAO6u57zbOWCmLaiVQuVW2tyt3rHpZrXkaQp");
+
+// 直接获取 ApiKey 所代表的 loginId
+Object loginId = SaApiKeyUtil.getLoginIdByApiKey("AK-NAO6u57zbOWCmLaiVQuVW2tyt3rHpZrXkaQp");
+
+// 获取指定 loginId 的 ApiKey 列表记录
+List apiKeyList = SaApiKeyUtil.getApiKeyList(10001);
+```
+
+
+### 3、校验 API Key
+
+``` java
+// 校验指定 API Key 是否有效,无效会抛出异常 ApiKeyException
+SaApiKeyUtil.checkApiKey("AK-XxxXxxXxx");
+
+// 校验指定 API Key 是否具有指定 Scope 权限,不具有会抛出异常 ApiKeyScopeException
+SaApiKeyUtil.checkApiKeyScope("AK-XxxXxxXxx", "userinfo");
+
+// 校验指定 API Key 是否具有指定 Scope 权限,返回 true 或 false
+SaApiKeyUtil.hasApiKeyScope("AK-XxxXxxXxx", "userinfo");
+
+// 校验指定 API Key 是否属于指定账号 id
+SaApiKeyUtil.checkApiKeyLoginId("AK-XxxXxxXxx", 10001);
+```
+
+注解鉴权示例:
+``` java
+/**
+ * API Key 资源 相关接口
+ */
+@RestController
+public class ApiKeyResourcesController {
+
+ // 必须携带有效的 ApiKey 才能访问
+ @SaCheckApiKey
+ @RequestMapping("/akRes1")
+ public SaResult akRes1() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+ // 必须携带有效的 ApiKey ,且具有 userinfo 权限
+ @SaCheckApiKey(scope = "userinfo")
+ @RequestMapping("/akRes2")
+ public SaResult akRes2() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+ // 必须携带有效的 ApiKey ,且同时具有 userinfo、chat 权限
+ @SaCheckApiKey(scope = {"userinfo", "chat"})
+ @RequestMapping("/akRes3")
+ public SaResult akRes3() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+ // 必须携带有效的 ApiKey ,且具有 userinfo、chat 其中之一权限
+ @SaCheckApiKey(scope = {"userinfo", "chat"}, mode = SaMode.OR)
+ @RequestMapping("/akRes4")
+ public SaResult akRes4() {
+ ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
+ System.out.println("当前 ApiKey: " + akModel);
+ return SaResult.ok("调用成功");
+ }
+
+}
+```
+
+
+### 4、打开数据库模式
+
+框架默认将所有 API Key 信息保存在缓存中,这可以称之为“缓存模式”,这种模式下,重启缓存库后,数据将丢失。
+
+如果你想改为“数据库模式”,可以通过 `implements SaApiKeyDataLoader` 实现从数据库加载的逻辑。
+
+``` java
+/**
+ * API Key 数据加载器实现类 (从数据库查询)
+ */
+@Component
+public class SaApiKeyDataLoaderImpl implements SaApiKeyDataLoader {
+
+ @Autowired
+ SaApiKeyMapper apiKeyMapper;
+
+ // 指定框架不再维护 API Key 索引信息,而是由我们手动从数据库维护
+ @Override
+ public Boolean getIsRecordIndex() {
+ return false;
+ }
+
+ // 根据 apiKey 从数据库获取 ApiKeyModel 信息 (实现此方法无需为数据做缓存处理,框架内部已包含缓存逻辑)
+ @Override
+ public ApiKeyModel getApiKeyModelFromDatabase(String apiKey) {
+ return apiKeyMapper.getApiKeyModel(apiKey);
+ }
+
+}
+```
+
+参考上述代码实现后,框架内部逻辑将会做出一些改变,请注意以下事项:
+
+- 1、调用 `SaApiKeyUtil.getApiKey("ApiKey")` 时,会先从缓存中查询,查询不到时调用 `getApiKeyModelFromDatabase` 从数据库加载。
+- 2、框架不再维护 API Key 索引数据,这意味着无法再调用 `SaApiKeyUtil.getApiKeyList(10001)` 来获取一个用户的所有的 API Key 数据,请自行从数据库查询。
+- 3、调用 `SaApiKeyUtil.saveApiKey(akModel)` 保存时,只会把 API Key 数据保存到缓存中,请自行补充额外代码向数据库保存数据。
+- 4、调用 `SaApiKeyUtil.deleteApiKey("ApiKey")` 时,只会删除这个 API Key 在缓存中的数据,不会删除数据库的数据,请自行补充相关代码保证数据双删。
+- 5、其它诸如查询 `SaApiKeyUtil.getApiKey("ApiKey")` 或校验 `SaApiKeyUtil.checkApiKeyScope("ApiKey", "userinfo")` 等方法,依旧可以正常调用。
+
+
+
+
+
+
+
+
+
+
diff --git a/sa-token-doc/start/download.md b/sa-token-doc/start/download.md
index 394c2419..677fdea2 100644
--- a/sa-token-doc/start/download.md
+++ b/sa-token-doc/start/download.md
@@ -214,6 +214,7 @@ Maven依赖一直无法加载成功?[参考解决方案](https://sa-token.cc/d
├── sa-token-demo // [示例] Sa-Token 示例合集
├── sa-token-demo-alone-redis // [示例] Sa-Token 集成 alone-redis 模块
├── sa-token-demo-alone-redis-cluster // [示例] Sa-Token 集成 alone-redis 模块、集群模式
+ ├── sa-token-demo-apikey // [示例] Sa-Token API Key 模块示例
├── sa-token-demo-beetl // [示例] Sa-Token 集成 beetl 示例
├── sa-token-demo-bom-import // [示例] Sa-Token bom 包导入示例
├── sa-token-demo-case // [示例] Sa-Token 各模块示例
diff --git a/sa-token-doc/use/config.md b/sa-token-doc/use/config.md
index e4c20195..64a01142 100644
--- a/sa-token-doc/use/config.md
+++ b/sa-token-doc/use/config.md
@@ -112,7 +112,9 @@ public class SaTokenConfigure {
---
### 2、核心包所有可配置项
-**你不必立刻掌握整个表格,只需要在用到某个功能时再详细查阅它即可**
+#### 2.1、核心模块配置
+
+你不必立刻掌握整个表格,只需要在用到某个功能时再详细查阅它即可
| 参数名称 | 类型 | 默认值 | 说明 |
| :-------- | :-------- | :-------- | :-------- |
@@ -154,7 +156,7 @@ public class SaTokenConfigure {
| cookie | Object | new SaCookieConfig() | Cookie 配置对象 |
| sign | Object | new SaSignConfig() | API 签名配置对象 |
-Cookie相关配置:
+#### 2.2、Cookie相关配置:
| 参数名称 | 类型 | 默认值 | 说明 |
| :-------- | :-------- | :-------- | :-------- |
@@ -166,7 +168,7 @@ Cookie相关配置:
| extraAttrs | String | new LinkedHashMap() | 额外扩展属性 |
-Cookie 配置示例:
+Cookie 配置示例:
@@ -209,7 +211,8 @@ sa-token.cookie.extraAttrs.Partitioned=""
```
-Sign 参数签名相关配置:
+
+#### 2.3、Sign 参数签名相关配置
| 参数名称 | 类型 | 默认值 | 说明 |
| :-------- | :-------- | :-------- | :-------- |
@@ -238,6 +241,45 @@ sa-token.sign.secret-key=kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
+#### 2.4、API Key 相关配置
+
+| 参数名称 | 类型 | 默认值 | 说明 |
+| :-------- | :-------- | :-------- | :-------- |
+| prefix | String | AK- | API Key 前缀 |
+| timeout | long | 2592000 | API Key 有效期,-1=永久有效,默认30天 (修改此配置项不会影响到已创建的 API Key) |
+| isRecordIndex | String | true | 框架是否记录索引信息 |
+
+示例:
+
+
+
+``` yaml
+# Sa-Token 配置
+sa-token:
+ # API Key 相关配置
+ api-key:
+ # API Key 前缀
+ prefix: AK-
+ # API Key 有效期,-1=永久有效,默认30天 (修改此配置项不会影响到已创建的 API Key)
+ timeout: 2592000
+ # 框架是否记录索引信息
+ is-record-index: true
+```
+
+``` properties
+# API Key 前缀
+sa-token.pi-key.prefix=AK-
+# API Key 有效期,-1=永久有效,默认30天 (修改此配置项不会影响到已创建的 API Key)
+sa-token.pi-key.timeout=2592000
+# 框架是否记录索引信息
+sa-token.pi-key.is-record-index=true
+```
+
+
+
+
+
+
### 3、单点登录相关配置
diff --git a/sa-token-starter/sa-token-solon-plugin/src/main/java/cn/dev33/satoken/solon/SaBeanInject.java b/sa-token-starter/sa-token-solon-plugin/src/main/java/cn/dev33/satoken/solon/SaBeanInject.java
index ee32af3b..af69492d 100644
--- a/sa-token-starter/sa-token-solon-plugin/src/main/java/cn/dev33/satoken/solon/SaBeanInject.java
+++ b/sa-token-starter/sa-token-solon-plugin/src/main/java/cn/dev33/satoken/solon/SaBeanInject.java
@@ -17,6 +17,8 @@ package cn.dev33.satoken.solon;
import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.annotation.handler.SaAnnotationHandlerInterface;
+import cn.dev33.satoken.apikey.SaApiKeyTemplate;
+import cn.dev33.satoken.apikey.loader.SaApiKeyDataLoader;
import cn.dev33.satoken.config.SaTokenConfig;
import cn.dev33.satoken.context.SaTokenContext;
import cn.dev33.satoken.context.second.SaTokenSecondContextCreator;
@@ -229,6 +231,28 @@ public class SaBeanInject {
SaManager.setSaSignTemplate(saSignTemplate);
}
+ /**
+ * 注入自定义的 ApiKey 模块 Bean
+ *
+ * @param apiKeyTemplate /
+ */
+ @Condition(onBean = SaApiKeyTemplate.class)
+ @Bean
+ public void setSaApiKeyTemplate(SaApiKeyTemplate apiKeyTemplate) {
+ SaManager.setSaApiKeyTemplate(apiKeyTemplate);
+ }
+
+ /**
+ * 注入自定义的 ApiKey 数据加载器 Bean
+ *
+ * @param apiKeyDataLoader /
+ */
+ @Condition(onBean = SaApiKeyDataLoader.class)
+ @Bean
+ public void setSaApiKeyDataLoader(SaApiKeyDataLoader apiKeyDataLoader) {
+ SaManager.setSaApiKeyDataLoader(apiKeyDataLoader);
+ }
+
/**
* 注入自定义的 TOTP 算法 Bean
*
diff --git a/sa-token-starter/sa-token-spring-boot-autoconfig/src/main/java/cn/dev33/satoken/spring/SaBeanInject.java b/sa-token-starter/sa-token-spring-boot-autoconfig/src/main/java/cn/dev33/satoken/spring/SaBeanInject.java
index f9cab052..07b85df1 100644
--- a/sa-token-starter/sa-token-spring-boot-autoconfig/src/main/java/cn/dev33/satoken/spring/SaBeanInject.java
+++ b/sa-token-starter/sa-token-spring-boot-autoconfig/src/main/java/cn/dev33/satoken/spring/SaBeanInject.java
@@ -17,6 +17,8 @@ package cn.dev33.satoken.spring;
import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.annotation.handler.SaAnnotationHandlerInterface;
+import cn.dev33.satoken.apikey.SaApiKeyTemplate;
+import cn.dev33.satoken.apikey.loader.SaApiKeyDataLoader;
import cn.dev33.satoken.config.SaTokenConfig;
import cn.dev33.satoken.context.SaTokenContext;
import cn.dev33.satoken.context.second.SaTokenSecondContextCreator;
@@ -215,6 +217,26 @@ public class SaBeanInject {
SaManager.setSaSignTemplate(saSignTemplate);
}
+ /**
+ * 注入自定义的 ApiKey 模块 Bean
+ *
+ * @param apiKeyTemplate /
+ */
+ @Autowired(required = false)
+ public void setSaApiKeyTemplate(SaApiKeyTemplate apiKeyTemplate) {
+ SaManager.setSaApiKeyTemplate(apiKeyTemplate);
+ }
+
+ /**
+ * 注入自定义的 ApiKey 数据加载器 Bean
+ *
+ * @param apiKeyDataLoader /
+ */
+ @Autowired(required = false)
+ public void setSaApiKeyDataLoader(SaApiKeyDataLoader apiKeyDataLoader) {
+ SaManager.setSaApiKeyDataLoader(apiKeyDataLoader);
+ }
+
/**
* 注入自定义的 TOTP 算法 Bean
*