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: +
+ 需要正确的 API Key
+ 需要具备 Scope: userinfo
+ 需要具备 Scope: userinfo,chat (需要全部具备)
+ 需要具备 Scope: userinfo,chat (具备其一即可)
+
+ +
+ +
+ + + + + + 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 *