diff --git a/flutter_monisuo/build/web/flutter_bootstrap.js b/flutter_monisuo/build/web/flutter_bootstrap.js index a2f3579..88770e4 100644 --- a/flutter_monisuo/build/web/flutter_bootstrap.js +++ b/flutter_monisuo/build/web/flutter_bootstrap.js @@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"e4b8dca3f1b4ede4c30371002441c88c12187e _flutter.loader.load({ serviceWorkerSettings: { - serviceWorkerVersion: "112731293" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */ + serviceWorkerVersion: "3241221564" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */ } }); diff --git a/flutter_monisuo/build/web/main.dart.js b/flutter_monisuo/build/web/main.dart.js index 92bb789..7ca50a7 100644 --- a/flutter_monisuo/build/web/main.dart.js +++ b/flutter_monisuo/build/web/main.dart.js @@ -29185,9 +29185,11 @@ _.c=c _.$ti=d}, b47(a){return B.b.hY(B.a2v,new A.aS1(a))}, aS1:function aS1(a){this.a=a}, -DU:function DU(a){this.a=$ -this.b=a -this.c=null}, +DU:function DU(a){var _=this +_.a=$ +_.b=a +_.c=null +_.d=!1}, aji:function aji(){}, re:function re(a,b,c,d){var _=this _.d=a @@ -102824,11 +102826,13 @@ A.cR().$1("ResponseData: "+A.l(r?q:s.a)) A.cR().$1("====================") if((r?q:s.c)===401)return new A.cg(!1,"\u767b\u9304\u5df2\u904e\u671f\uff0c\u8acb\u91cd\u65b0\u767b\u9304",q,b.i("cg<0>")) return new A.cg(!1,this.ajp(a),q,b.i("cg<0>"))}, -Eu(){var s,r,q +Eu(){var s,r,q,p=this +if(p.d)return +p.d=!0 A.yj() -s=this.c +s=p.c if(s!=null)s.$0() -r=$.a5.aq$.x.h(0,this.b) +r=$.a5.aq$.x.h(0,p.b) if(r!=null){s=A.c_(r,!1) q=s.xc("/login",null,t.X) q.toString @@ -102905,7 +102909,8 @@ g=t.z e=m.d s=j!=null?15:17 break -case 15:d=a.b +case 15:e.d=!1 +d=a.b d===$&&A.a() d.m(0,"Authorization","Bearer "+j) e=e.a diff --git a/flutter_monisuo/lib/core/constants/api_endpoints.dart b/flutter_monisuo/lib/core/constants/api_endpoints.dart index 61ea41f..023ceb8 100644 --- a/flutter_monisuo/lib/core/constants/api_endpoints.dart +++ b/flutter_monisuo/lib/core/constants/api_endpoints.dart @@ -33,7 +33,7 @@ class ApiEndpoints { /// 退出登錄 static const String logout = '/api/user/logout'; - /// 刷新 Token(後端尚未實現,預留接口) + /// 刷新 Token static const String tokenRefresh = '/api/auth/refresh'; // ==================== 行情模塊 ==================== diff --git a/flutter_monisuo/lib/core/network/dio_client.dart b/flutter_monisuo/lib/core/network/dio_client.dart index f0d337b..ffb5a38 100644 --- a/flutter_monisuo/lib/core/network/dio_client.dart +++ b/flutter_monisuo/lib/core/network/dio_client.dart @@ -35,6 +35,9 @@ class DioClient { /// 未授權回調(token 徹底過期時觸發 AuthProvider.forceLogout) VoidCallback? onForceLogout; + /// 防止重複 forceLogout + bool hasForceLoggedOut = false; + DioClient({this.navigatorKey}) { _dio = _createDio(); _setupInterceptors(); @@ -133,8 +136,10 @@ class DioClient { return ApiResponse.fail(message); } - /// 徹底失效時清除數據並跳轉登錄頁 + /// 徹底失效時清除數據並跳轉登錄頁(防重複調用) void forceLogout() { + if (hasForceLoggedOut) return; + hasForceLoggedOut = true; LocalStorage.clearUserData(); onForceLogout?.call(); final context = navigatorKey?.currentContext; @@ -253,6 +258,8 @@ class _TokenRefreshInterceptor extends QueuedInterceptor { try { final newToken = await _refreshToken(); if (newToken != null) { + // 刷新成功,重置 forceLogout 標記(用戶重新激活) + _client.hasForceLoggedOut = false; // 刷新成功,更新 header 並重試原始請求 requestOptions.headers['Authorization'] = 'Bearer $newToken'; final retryResponse = await _client._dio.fetch(requestOptions); diff --git a/sql/init.sql b/sql/init.sql index 89909c1..fcd6d26 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -27,6 +27,7 @@ CREATE TABLE `sys_user` ( `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间', `last_login_ip` varchar(50) DEFAULT NULL COMMENT '最后登录IP', `token` varchar(500) DEFAULT NULL COMMENT '当前Token', + `refresh_token` varchar(512) DEFAULT NULL COMMENT '刷新Token', `referral_code` varchar(8) DEFAULT NULL COMMENT '推广码', `referred_by` bigint(20) DEFAULT NULL COMMENT '推广人用户ID', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', @@ -53,6 +54,7 @@ CREATE TABLE `sys_admin` ( `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间', `last_login_ip` varchar(50) DEFAULT NULL COMMENT '最后登录IP', `token` varchar(500) DEFAULT NULL COMMENT '当前Token', + `refresh_token` varchar(512) DEFAULT NULL COMMENT '刷新Token', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), diff --git a/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java b/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java index 2635972..ed55a5e 100644 --- a/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java +++ b/src/main/java/com/it/rattan/monisuo/filter/TokenFilter.java @@ -20,23 +20,27 @@ public class TokenFilter implements Filter { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - /** 不需要验证的路径 */ - private static final String[] EXCLUDE_PATHS = { + /** 不需要验证的路径(精确匹配) */ + private static final String[] EXACT_PATHS = { "/api/user/register", "/api/user/login", "/api/auth/refresh", "/api/wallet/default", "/api/wallet/networks", - "/api/kline/", - "/ws/", "/admin/login", - "/uploads/", "/swagger-resources", "/v2/api-docs", - "/webjars/", "/swagger-ui.html" }; + /** 不需要验证的路径前缀 */ + private static final String[] PREFIX_PATHS = { + "/api/kline/", + "/ws/", + "/uploads/", + "/webjars/" + }; + @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { @@ -45,9 +49,17 @@ public class TokenFilter implements Filter { String uri = httpRequest.getRequestURI(); - // 检查是否排除路径 - for (String excludePath : EXCLUDE_PATHS) { - if (uri.contains(excludePath)) { + // 检查是否排除路径(精确匹配) + for (String path : EXACT_PATHS) { + if (uri.equals(path)) { + chain.doFilter(request, response); + return; + } + } + + // 检查是否排除路径前缀 + for (String prefix : PREFIX_PATHS) { + if (uri.startsWith(prefix)) { chain.doFilter(request, response); return; } diff --git a/src/main/java/com/it/rattan/monisuo/service/AuthService.java b/src/main/java/com/it/rattan/monisuo/service/AuthService.java index 3e6cb6c..09ca5ae 100644 --- a/src/main/java/com/it/rattan/monisuo/service/AuthService.java +++ b/src/main/java/com/it/rattan/monisuo/service/AuthService.java @@ -7,6 +7,7 @@ import com.it.rattan.monisuo.mapper.UserMapper; import com.it.rattan.monisuo.util.JwtUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; import java.util.Map; @@ -30,6 +31,7 @@ public class AuthService { * 5. 更新数据库中的 refreshToken * 6. 返回新 token 对 */ + @Transactional(rollbackFor = Exception.class) public Map refreshToken(String refreshToken) { // 1. 验证 refreshToken if (!JwtUtil.isValid(refreshToken)) { @@ -56,8 +58,14 @@ public class AuthService { throw new RuntimeException("账号已被禁用"); } - // 验证 refreshToken 与数据库中存储的一致 - if (!refreshToken.equals(user.getRefreshToken())) { + // 验证 refreshToken 与数据库中存储的一致(防重放攻击) + String storedRefreshToken = user.getRefreshToken(); + if (storedRefreshToken == null || !refreshToken.equals(storedRefreshToken)) { + // refreshToken 不匹配说明可能被窃取,清除该用户所有 token + userMapper.update(null, new LambdaUpdateWrapper() + .eq(User::getId, user.getId()) + .set(User::getToken, null) + .set(User::getRefreshToken, null)); throw new RuntimeException("refreshToken不匹配,请重新登录"); }