feat: 新增 API Key 模块

This commit is contained in:
click33 2025-04-04 23:36:59 +08:00
parent 8cff63b0fc
commit 601d8b1373
37 changed files with 2868 additions and 7 deletions

View File

@ -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
*/

View File

@ -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
*
* <p> 可标注在方法类上效果等同于标注在此类的所有方法上
*
* @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;
}

View File

@ -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<SaCheckApiKey> {
@Override
public Class<SaCheckApiKey> 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);
}
}
}

View File

@ -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<String> 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<String> 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<String> 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<String> apiKeyList = session.get(API_KEY_LIST, ArrayList::new);
ArrayList<String> apiKeyNewList = new ArrayList<>();
ArrayList<ApiKeyModel> 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<ApiKeyModel> getApiKeyList(Object loginId) {
// 先判断是否开启索引
if(! SaManager.getSaApiKeyDataLoader().getIsRecordIndex()) {
SaManager.getLog().warn("当前 API Key 模块未开启索引记录功能,无法执行 getApiKeyList 操作");
return new ArrayList<>();
}
// 先查 RawSession
List<ApiKeyModel> apiKeyModelList = new ArrayList<>();
SaSession session = SaSessionRawUtil.getSessionById(SESSION_TYPE, loginId, false);
if(session == null) {
return apiKeyModelList;
}
// RawSession 遍历查询
ArrayList<String> 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
/**
* 拼接keyApiKey 持久化
* @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);
// }
// }
}

View File

@ -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<ApiKeyModel> getApiKeyList(Object loginId) {
return SaManager.getSaApiKeyTemplate().getApiKeyList(loginId);
}
}

View File

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

View File

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

View File

@ -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<String> scopes = new ArrayList<>();
/**
* 扩展数据
*/
private Map<String, Object> 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<String> getScopes() {
return this.scopes;
}
/**
* 设置 授权范围
*
* @param scopes 授权范围
* @return 对象自身
*/
public ApiKeyModel setScopes(List<String> scopes) {
this.scopes = scopes;
return this;
}
/**
* 获取 扩展数据
*
* @return extraData 扩展数据
*/
public Map<String, Object> getExtraData() {
return this.extraData;
}
/**
* 设置 扩展数据
*
* @param extraData 扩展数据
* @return 对象自身
*/
public ApiKeyModel setExtraData(Map<String, Object> 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 +
'}';
}
}

View File

@ -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 +
'}';
}
}

View File

@ -233,6 +233,10 @@ public class SaTokenConfig implements Serializable {
*/
public Map<String, SaSignConfig> 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
+ "]";
}

View File

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

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ import java.time.Instant;
* TOTP 算法类支持 生成/验证 动态一次性密码
*
* @author click33
* @since 1.41.0
* @since 1.42.0
*/
public class SaTotpTemplate {

View File

@ -21,7 +21,7 @@ import cn.dev33.satoken.SaManager;
* TOTP 工具类支持 生成/验证 动态一次性密码
*
* @author click33
* @since 1.41.0
* @since 1.42.0
*/
public class SaTotpUtil {

View File

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

View File

@ -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());
}
/**

View File

@ -11,6 +11,7 @@
<modules>
<module>sa-token-demo-alone-redis</module>
<module>sa-token-demo-alone-redis-cluster</module>
<module>sa-token-demo-apikey</module>
<module>sa-token-demo-beetl</module>
<module>sa-token-demo-bom-import</module>
<module>sa-token-demo-caffeine</module>

View File

@ -0,0 +1,67 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-demo-apikey</artifactId>
<version>0.0.1-SNAPSHOT</version>
<!-- SpringBoot -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.14</version>
<relativePath/>
</parent>
<!-- 定义 Sa-Token 版本号 -->
<properties>
<sa-token.version>1.41.0</sa-token.version>
</properties>
<dependencies>
<!-- springboot依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Sa-Token 权限认证, 在线文档https://sa-token.cc/ -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
<!-- Sa-Token 整合 RedisTemplate -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-template</artifactId>
<version>${sa-token.version}</version>
</dependency>
<!-- 提供Redis连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- 热刷新 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>provided</scope>
</dependency>
<!-- @ConfigurationProperties -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

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

View File

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

View File

@ -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<String, ApiKeyModel> 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);
}
}

View File

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

View File

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

View File

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

View File

@ -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 且同时具有 userinfochat 权限
@SaCheckApiKey(scope = {"userinfo", "chat"})
@RequestMapping("/akRes3")
public SaResult akRes3() {
ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
System.out.println("当前 ApiKey: " + akModel);
return SaResult.ok("调用成功");
}
// 必须携带有效的 ApiKey 且具有 userinfochat 其中之一权限
@SaCheckApiKey(scope = {"userinfo", "chat"}, mode = SaMode.OR)
@RequestMapping("/akRes4")
public SaResult akRes4() {
ApiKeyModel akModel = SaApiKeyUtil.currentApiKey();
System.out.println("当前 ApiKey: " + akModel);
return SaResult.ok("调用成功");
}
}

View File

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

View File

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

View File

@ -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}`;
}

View File

@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
body{background-color: #EFF6FF;}
td{padding: 5px 10px;}
button{cursor: pointer;}
table{margin-top: 10px; width: 100%;}
[name=title]{width: 100px;}
.change-tr{background-color: #F5E5F5 ;}
.remark{margin-left: 10px; color: #999;}
</style>
</head>
<body>
<div style="width: 1200px; margin: auto;">
<h1>Sa-Token - API Key 测试页</h1>
<h2>登录</h2>
<div>当前登录人:<b class="curr-uid" style="color: green"></b></div>
<span>输入账号 id 登录:</span>
<input name="loginId" />
<button onclick="doLogin()">登录</button>
<button onclick="doLogout()()">注销</button>
<h2>API Key 列表</h2>
<button onclick="createApiKey()">+ 创建 API Key</button>
<table cellspacing="0" border="1">
<tr>
<th>名称</th>
<th style="width: 435px;">API Key</th>
<th>权限(多个用逗号隔开)</th>
<th style="width: 190px;">过期时间</th>
<th style="width: 80px;">是否生效</th>
<th style="width: 170px;">操作</th>
</tr>
<tbody class="ak-tbody">
<!-- <tr class="ak-xxxx">
<td><input name="title" value="xx" /></td>
<td>AK-EG9BKM4bel7OqRoixNvSQ1a6DYusNfEXDjPr</td>
<td><input name="scopes" value="aaa" /></td>
<td><input name="expiresTime" value="2020-02-02 01:50:20" type="datetime-local" /></td>
<td>
<label><input name="isValid" checked type="checkbox" />生效</label>
</td>
<td>
<button onclick="updateApiKey('xxx')">修改</button>
<button onclick="useApiKey('xxx')">使用</button>
<button onclick="deleteApiKey('xxx')">删除</button>
</td>
</tr> -->
</tbody>
</table>
<h2>调用 API</h2>
<div style="line-height: 30px;">
<span>使用的 API Key</span>
<input name="api-key" style="width: 600px;"/> <br>
<button onclick="callAPI('/akRes1')">调用接口 1 </button> <span class="remark">需要正确的 API Key</span> <br>
<button onclick="callAPI('/akRes2')">调用接口 2 </button> <span class="remark">需要具备 Scope: userinfo</span> <br>
<button onclick="callAPI('/akRes3')">调用接口 3 </button> <span class="remark">需要具备 Scope: userinfo,chat (需要全部具备)</span> <br>
<button onclick="callAPI('/akRes4')">调用接口 4 </button> <span class="remark">需要具备 Scope: userinfo,chat (具备其一即可)</span> <br>
</div>
<div style="height: 200px;"></div>
</div>
<script src="common.js"></script>
<script>
// 登录
function doLogin() {
var loginId = document.querySelector("[name=loginId]").value;
if (loginId === "") {
return alert("请输入账号 id");
}
ajax("/login", {id: loginId}, function (res) {
localStorage.setItem("satoken", res.satoken);
showMsg('登录成功');
setTimeout(function(){
location.reload();
}, 1000);
})
}
// 查询当前登录人
function getLoginInfo() {
ajax("/getLoginId", {}, function (res) {
document.querySelector(".curr-uid").innerHTML = res.data;
document.querySelector('[name=loginId]').value = res.data;
myApiKeyList();
}, function(){
document.querySelector(".curr-uid").innerHTML = '未登录';
document.querySelector('[name=loginId]').value = '10001';
})
}
getLoginInfo();
// 注销登录
function doLogout() {
ajax("/logout", {}, function (res) {
showMsg('注销成功');
setTimeout(function(){
location.reload();
}, 1000)
})
}
</script>
<script>
// 渲染一个 API Key 对象到表格
function renderApiKey(ak) {
const trDom = `
<tr class="ak-${ak.apiKey}">
<td><input name="title" value="${ak.title}" oninput="changeTr('${ak.apiKey}')"/></td>
<td>${ak.apiKey}</td>
<td><input name="scopes" value="${ak.scopes.join(',')}" oninput="changeTr('${ak.apiKey}')" /></td>
<td><input name="expiresTime" value="${formatDateTime(ak.expiresTime)}" type="datetime-local" oninput="changeTr('${ak.apiKey}')"/></td>
<td>
<label><input name="isValid" ${ak.isValid ? 'checked' : ''} type="checkbox" oninput="changeTr('${ak.apiKey}')"/>生效</label>
</td>
<td>
<button onclick="updateApiKey('${ak.apiKey}')">修改</button>
<button onclick="useApiKey('${ak.apiKey}')">使用</button>
<button onclick="deleteApiKey('${ak.apiKey}')">删除</button>
</td>
</tr>
`;
document.querySelector('.ak-tbody').innerHTML = document.querySelector('.ak-tbody').innerHTML + trDom;
}
// 查询当前所有 API Key
function myApiKeyList() {
ajax("/myApiKeyList", {}, function (res) {
res.data.forEach(function(item){
renderApiKey(item);
})
})
}
// 创建 ApiKey
function createApiKey() {
if(document.querySelector(".curr-uid").innerHTML === '未登录') {
return alert('请先登录');
}
ajax("/createApiKey", {}, function (res) {
renderApiKey(res.data);
showMsg('创建成功');
})
}
// 使用
function useApiKey(apiKey){
document.querySelector('[name=api-key]').value = apiKey;
showMsg('已填充至输入框,请调用接口');
}
// 修改
function updateApiKey(apiKey) {
const tr = document.querySelector(".ak-" + apiKey);
const data = {
apiKey: apiKey,
title: tr.querySelector('[name=title]').value,
scopes: tr.querySelector('[name=scopes]').value,
expiresTime: new Date(tr.querySelector('[name=expiresTime]').value).getTime(),
isValid: tr.querySelector('[name=isValid]').checked,
}
ajax("/updateApiKey", data, function (res) {
showMsg('修改成功');
tr.classList.remove('change-tr');
})
}
// 删除
function deleteApiKey(apiKey) {
ajax("/deleteApiKey", {apiKey: apiKey}, function (res) {
showMsg('删除成功');
const tr = document.querySelector(".ak-" + apiKey);
tr.remove();
})
}
// 指定行的输入框变动
function changeTr(apiKey) {
const tr = document.querySelector(".ak-" + apiKey);
tr.classList.add('change-tr');
}
</script>
<script>
// 调用指定接口
function callAPI(apiPath) {
const apiKey = document.querySelector('[name=api-key]').value;
if(!apiKey) {
return showMsg('请先填写 API Key')
}
ajax(apiPath, {apikey: apiKey}, function (res) {
showMsg(res.msg);
})
}
</script>
</body>
</html>

View File

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

View File

@ -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<ApiKeyModel> 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")` 等方法,依旧可以正常调用。

View File

@ -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 各模块示例

View File

@ -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 配置示例:
<!---------------------------- tabs:start ---------------------------->
<!------------- tab:yaml 风格 ------------->
@ -209,7 +211,8 @@ sa-token.cookie.extraAttrs.Partitioned=""
```
<!---------------------------- tabs:end ---------------------------->
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 | 框架是否记录索引信息 |
示例:
<!---------------------------- tabs:start ---------------------------->
<!------------- tab:yaml 风格 ------------->
``` 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
```
<!------------- tab:properties 风格 ------------->
``` 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
```
<!---------------------------- tabs:end ---------------------------->
### 3、单点登录相关配置

View File

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

View File

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