diff --git a/mvn clean.bat b/mvn clean.bat
index 57e7e58e..c15702f3 100644
--- a/mvn clean.bat
+++ b/mvn clean.bat
@@ -11,6 +11,7 @@ cd sa-token-demo-alone-redis-cluster & call mvn clean & cd ..
cd sa-token-demo-beetl & call mvn clean & cd ..
cd sa-token-demo-bom-import & call mvn clean & cd ..
cd sa-token-demo-case & call mvn clean & cd ..
+cd sa-token-demo-device-lock & call mvn clean & cd ..
cd sa-token-demo-grpc & call mvn clean & cd ..
cd sa-token-demo-hutool-timed-cache & call mvn clean & cd ..
cd sa-token-demo-jwt & call mvn clean & cd ..
diff --git a/sa-token-demo/pom.xml b/sa-token-demo/pom.xml
index fde0caf9..d088deb0 100644
--- a/sa-token-demo/pom.xml
+++ b/sa-token-demo/pom.xml
@@ -14,6 +14,7 @@
sa-token-demo-beetl
sa-token-demo-bom-import
sa-token-demo-case
+ sa-token-demo-device-lock
sa-token-demo-dubbo/sa-token-demo-dubbo-provider
sa-token-demo-dubbo/sa-token-demo-dubbo-consumer
sa-token-demo-dubbo/sa-token-demo-dubbo3-provider
diff --git a/sa-token-demo/sa-token-demo-device-lock-h5/common.js b/sa-token-demo/sa-token-demo-device-lock-h5/common.js
new file mode 100644
index 00000000..72bbbae4
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock-h5/common.js
@@ -0,0 +1,69 @@
+// 服务器接口主机地址
+var baseUrl = "http://localhost:8081";
+
+// 封装一下Ajax
+function ajax(path, data, successFn) {
+ 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);
+ successFn(res);
+ })
+ .catch(error => {
+ console.error('提交失败:', error);
+ return alert("异常:" + JSON.stringify(error));
+ });
+}
+
+// 获取本地的 设备id
+function getLocalDeviceId() {
+ let localDeviceId = localStorage.getItem('local-device-id');
+ if(!localDeviceId) {
+ localDeviceId = randomString(60);
+ localStorage.setItem('local-device-id', localDeviceId);
+ }
+ return localDeviceId;
+}
+
+
+
+// ------------ 工具方法 ---------------
+
+// 从url中查询到指定名称的参数值
+function getParam(name, defaultValue){
+ var query = window.location.search.substring(1);
+ var vars = query.split("&");
+ for (var i=0;i 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;
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-device-lock-h5/device-lock-auth.html b/sa-token-demo/sa-token-demo-device-lock-h5/device-lock-auth.html
new file mode 100644
index 00000000..e9e559a5
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock-h5/device-lock-auth.html
@@ -0,0 +1,69 @@
+
+
+
+
+ 设备锁测试-认证页
+
+
+
+
+
设备锁测试-认证页
+
您正在一台新设备上登录此账号,需要进行身份验证
+
您绑定的手机号为:
+
+ 验证码:
+
+
+
+
+
+
+
+
+
diff --git a/sa-token-demo/sa-token-demo-device-lock-h5/index.html b/sa-token-demo/sa-token-demo-device-lock-h5/index.html
new file mode 100644
index 00000000..f9c109e8
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock-h5/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+ 设备锁测试-首页
+
+
+ 设备锁测试-首页
+ 当前是否登录:
+
+ 登录
+ 注销
+
+
+
+
+
+
diff --git a/sa-token-demo/sa-token-demo-device-lock-h5/login.html b/sa-token-demo/sa-token-demo-device-lock-h5/login.html
new file mode 100644
index 00000000..35095894
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock-h5/login.html
@@ -0,0 +1,47 @@
+
+
+
+
+ 设备锁测试-登录页
+
+
+
+
+
设备锁测试-登录页
+
用户:
+
密码:
+
+
+
+
+
+
+
diff --git a/sa-token-demo/sa-token-demo-device-lock/pom.xml b/sa-token-demo/sa-token-demo-device-lock/pom.xml
new file mode 100644
index 00000000..33352b66
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/pom.xml
@@ -0,0 +1,55 @@
+
+ 4.0.0
+ cn.dev33
+ sa-token-demo-device-lock
+ 0.0.1-SNAPSHOT
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 2.5.14
+
+
+
+
+
+
+
+ 1.40.0
+ com.pj.SaTokenDeviceLockApplication
+
+
+
+
+
+
+ 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
+
+
+
+
+
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/SaTokenDeviceLockApplication.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/SaTokenDeviceLockApplication.java
new file mode 100644
index 00000000..b2ee68e6
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/SaTokenDeviceLockApplication.java
@@ -0,0 +1,21 @@
+package com.pj;
+
+import cn.dev33.satoken.SaManager;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+
+/**
+ * Sa-Token 测试
+ * @author click33
+ *
+ */
+@SpringBootApplication
+public class SaTokenDeviceLockApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SaTokenDeviceLockApplication.class, args);
+ System.out.println("\n启动成功:Sa-Token配置如下:" + SaManager.getConfig());
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/current/GlobalException.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/current/GlobalException.java
new file mode 100644
index 00000000..ddc2c3e1
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/current/GlobalException.java
@@ -0,0 +1,22 @@
+package com.pj.current;
+
+import cn.dev33.satoken.util.SaResult;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 全局异常处理
+ */
+@RestControllerAdvice
+public class GlobalException {
+
+ @ExceptionHandler
+ public SaResult handlerException(Exception e, HttpServletRequest request, HttpServletResponse response) {
+ e.printStackTrace();
+ return SaResult.error(e.getMessage());
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/current/NotFoundHandle.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/current/NotFoundHandle.java
new file mode 100644
index 00000000..0c5fa56b
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/current/NotFoundHandle.java
@@ -0,0 +1,27 @@
+package com.pj.current;
+
+import java.io.IOException;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.boot.web.servlet.error.ErrorController;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import cn.dev33.satoken.util.SaResult;
+
+/**
+ * 处理 404
+ * @author click33
+ */
+@RestController
+public class NotFoundHandle implements ErrorController {
+
+ @RequestMapping("/error")
+ public Object error(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ response.setStatus(200);
+ return SaResult.get(404, "not found", null);
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/satoken/SaTokenConfigure.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/satoken/SaTokenConfigure.java
new file mode 100644
index 00000000..dd0ab680
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/satoken/SaTokenConfigure.java
@@ -0,0 +1,87 @@
+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) {
+ // 注册 Sa-Token 拦截器打开注解鉴权功能
+ 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()
+ // 服务器名称
+ .setServer("sa-server")
+ // 是否可以在iframe显示视图: DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以
+ .setHeader("X-Frame-Options", "SAMEORIGIN")
+ // 是否启用浏览器默认XSS防护: 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时,停止渲染页面
+ .setHeader("X-XSS-Protection", "1; mode=block")
+ // 禁用浏览器内容嗅探
+ .setHeader("X-Content-Type-Options", "nosniff")
+
+ // ---------- 设置跨域响应头 ----------
+ // 允许指定域访问跨域资源
+ .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-device-lock/src/main/java/com/pj/test/LoginController.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/test/LoginController.java
new file mode 100644
index 00000000..db9d53e2
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/test/LoginController.java
@@ -0,0 +1,90 @@
+package com.pj.test;
+
+import cn.dev33.satoken.stp.SaLoginParameter;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.util.SaResult;
+import com.pj.util.DeviceLockCheckUtil;
+import com.pj.util.PhoneCodeUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 登录测试
+ *
+ * @author click33
+ */
+@RestController
+@RequestMapping("/acc/")
+public class LoginController {
+
+ @Autowired
+ SysUserMockDao userMockDao;
+
+ // 账号密码登录
+ @RequestMapping("doLogin")
+ public SaResult doLogin(String name, String pwd, String deviceId) {
+ // 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
+ if("zhang".equals(name) && "123456".equals(pwd)) {
+ long userId = userMockDao.getUserIdByName(name);
+
+ // 登录前,检测设备锁
+ if ( ! StpUtil.isTrustDeviceId(userId, deviceId)) {
+ DeviceLockCheckUtil.setDeviceIdToUserId(deviceId, 10001);
+ // 与前端约定好,返回421表示此设备需要验证
+ return SaResult.get(421, "新设备登录,需要验证设备", deviceId);
+ }
+
+ // 登录
+ return login(userId, deviceId);
+ }
+ return SaResult.error("登录失败");
+ }
+
+ // 查询登录状态
+ @RequestMapping("isLogin")
+ public SaResult isLogin() {
+ return SaResult.data(StpUtil.isLogin());
+ }
+
+ // 注销登录
+ @RequestMapping("logout")
+ public SaResult logout() {
+ StpUtil.logout();
+ return SaResult.ok();
+ }
+
+ // 返回设备id绑定的 userId 的手机号,脱敏形式
+ @RequestMapping("getPhone")
+ public SaResult getPhone(String deviceId) {
+ long userId = DeviceLockCheckUtil.getUserIdByDeviceId(deviceId);
+ String phone = userMockDao.getPhoneByUserId(userId);
+ return SaResult.data(phone.substring(0, 3) + "****" + phone.substring(7));
+ }
+
+ // 发送验证码
+ @RequestMapping("sendCode")
+ public SaResult sendCode(String deviceId) {
+ long userId = DeviceLockCheckUtil.getUserIdByDeviceId(deviceId);
+ String phone = userMockDao.getPhoneByUserId(userId);
+ PhoneCodeUtil.sendCode(phone);
+ return SaResult.ok();
+ }
+
+ // 验证验证码
+ @RequestMapping("checkCode")
+ public SaResult checkCode(String deviceId, String code) {
+ long userId = DeviceLockCheckUtil.getUserIdByDeviceId(deviceId);
+ String phone = userMockDao.getPhoneByUserId(userId);
+ PhoneCodeUtil.checkCode(phone, code);
+ // 校验通过,开始登录
+ return login(userId, deviceId);
+ }
+
+ // 指定账号登录
+ private SaResult login(long userId, String deviceId) {
+ StpUtil.login(userId, new SaLoginParameter().setDeviceId(deviceId));
+ return SaResult.ok("登录成功").set("token", StpUtil.getTokenValue());
+ }
+
+}
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/test/SysUserMockDao.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/test/SysUserMockDao.java
new file mode 100644
index 00000000..7d2cbe8b
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/test/SysUserMockDao.java
@@ -0,0 +1,24 @@
+package com.pj.test;
+
+import org.springframework.stereotype.Service;
+
+/**
+ * 模拟数据库操作类
+ *
+ * @author click33
+ * @since 2025/3/5
+ */
+@Service
+public class SysUserMockDao {
+
+ // 返回指定 userId 绑定的手机号
+ public String getPhoneByUserId(long userId) {
+ return "13112341234";
+ }
+
+ // 返回指定用户名对应的 userId
+ public long getUserIdByName(String name) {
+ return 10001;
+ }
+
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/util/DeviceLockCheckUtil.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/util/DeviceLockCheckUtil.java
new file mode 100644
index 00000000..b4f67bd7
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/util/DeviceLockCheckUtil.java
@@ -0,0 +1,42 @@
+package com.pj.util;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.util.SaFoxUtil;
+
+/**
+ * 设备锁操作工具类
+ * @author click33
+ * @since 2025/3/5
+ */
+public class DeviceLockCheckUtil {
+
+ /**
+ * 保存设备id与用户id的映射关系
+ * @param deviceId /
+ * @param userId /
+ */
+ public static void setDeviceIdToUserId(String deviceId, long userId) {
+ if(SaFoxUtil.isEmpty(deviceId) || SaFoxUtil.isEmpty(userId)) {
+ throw new RuntimeException("设备id或用户id不能为空");
+ }
+ SaManager.getSaTokenDao().set(saveKeyPrefix() + deviceId, String.valueOf(userId), 1200);
+ }
+
+ /**
+ * 返回设备id绑定的用户id
+ * @param deviceId /
+ */
+ public static long getUserIdByDeviceId(String deviceId) {
+ String userIdStr = SaManager.getSaTokenDao().get(saveKeyPrefix() + deviceId);
+ if(userIdStr == null) {
+ throw new RuntimeException("此设备id目前未绑定任何用户");
+ }
+ return Long.parseLong(userIdStr);
+ }
+
+ // 返回数据保存时使用的前缀
+ public static Object saveKeyPrefix() {
+ return SaManager.getConfig().getTokenName() + ":device-to-userid:";
+ }
+
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/util/PhoneCodeUtil.java b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/util/PhoneCodeUtil.java
new file mode 100644
index 00000000..7327b08d
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/java/com/pj/util/PhoneCodeUtil.java
@@ -0,0 +1,31 @@
+package com.pj.util;//package com.pj.oauth2.custom;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.util.SaFoxUtil;
+
+/**
+ * 手机验证码工具类 (仅做逻辑模拟,不做真实发送)
+ *
+ * @author click33
+ * @since 2024/8/23
+ */
+public class PhoneCodeUtil {
+
+ // 指定手机号发送验证码
+ public static void sendCode(String phone) {
+ String code = SaFoxUtil.getRandomNumber(100000, 999999) + "";
+ SaManager.getSaTokenDao().set("phone_code:" + phone, code, 60 * 5);
+ System.out.println("手机号:" + phone + ",验证码:" + code + ",已发送成功");
+ }
+
+ // 校验验证码是否正确,不正确则抛出异常
+ public static void checkCode(String phone, String code) {
+ String oldCode = SaManager.getSaTokenDao().get("phone_code:" + phone);
+ if( ! code.equals(oldCode) ) {
+ throw new RuntimeException("验证码错误");
+ }
+ // 验证通过后,立即删除验证码
+ SaManager.getSaTokenDao().delete("phone_code:" + phone);
+ }
+
+}
\ No newline at end of file
diff --git a/sa-token-demo/sa-token-demo-device-lock/src/main/resources/application.yml b/sa-token-demo/sa-token-demo-device-lock/src/main/resources/application.yml
new file mode 100644
index 00000000..84e4a4fa
--- /dev/null
+++ b/sa-token-demo/sa-token-demo-device-lock/src/main/resources/application.yml
@@ -0,0 +1,49 @@
+# 端口
+server:
+ port: 8081
+
+############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
+sa-token:
+ # token 名称 (同时也是 cookie 名称)
+ token-name: satoken
+ # token 有效期(单位:秒) 默认30天,-1 代表永久有效
+ timeout: 2592000
+ # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
+ active-timeout: -1
+ # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
+ is-concurrent: true
+ # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
+ is-share: false
+ # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
+ token-style: uuid
+ # 是否输出操作日志
+ is-log: 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
+
+
+
+
+
\ No newline at end of file