feat:日志敏感信息过滤
This commit is contained in:
1 parent
82800373ca
commit
ede0f9bc4c
10 files changed
+517
-18
No files matched your search
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart(旧版兼容,无配置)" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define=FILE_ACCESS_MODE=legacy" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
||||
Vendored
+23
-5
@@ -6,21 +6,39 @@
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "pj_supermarket_cloud",
|
||||
"name": "pj_supermarket_cloud(新版签名)",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
"type": "dart",
|
||||
"toolArgs": [
|
||||
"--dart-define=FILE_ACCESS_MODE=signed",
|
||||
"--dart-define-from-file=.file-access/config.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pj_supermarket_cloud(旧版兼容,无配置)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"toolArgs": [
|
||||
"--dart-define=FILE_ACCESS_MODE=legacy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pj_supermarket_cloud (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
"flutterMode": "profile",
|
||||
"toolArgs": [
|
||||
"--dart-define-from-file=.file-access/config.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pj_supermarket_cloud (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
"flutterMode": "release",
|
||||
"toolArgs": [
|
||||
"--dart-define-from-file=.file-access/config.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,55 @@
|
||||
flutter pub run build_runner build --delete-conflicting-outputs~
|
||||
```
|
||||
|
||||
## 本地运行
|
||||
|
||||
项目使用 FVM Flutter 3.27.4。在 IDE 中选择启动模式即可,无需每次填写参数:
|
||||
|
||||
| 模式 | VS Code 启动项 | Android Studio 启动项 | 文件请求 |
|
||||
| --- | --- | --- | --- |
|
||||
| 新版签名 | `pj_supermarket_cloud(新版签名)` | `main.dart`,Additional run args 配置下述签名参数 | 读取本地配置,图片和上传携带签名 |
|
||||
| 旧版兼容测试 | `pj_supermarket_cloud(旧版兼容,无配置)` | `main.dart(旧版兼容,无配置)` | 不读取配置文件,图片和上传不携带应用签名 |
|
||||
|
||||
直接运行以下命令也可启动。Debug 未传任何文件签名配置时,自动走旧版兼容测试模式:
|
||||
|
||||
```bash
|
||||
fvm flutter run
|
||||
```
|
||||
|
||||
旧版模式会在控制台打印 `[file-access] 旧版兼容测试模式`,不发起文件校时和签名续期。
|
||||
即使同时提供签名配置,显式 `FILE_ACCESS_MODE=legacy` 也会保持旧版请求行为。
|
||||
该模式仅用于 Debug;Profile/Release 仍需有效签名配置。配置不完整或密钥错误会报错。
|
||||
|
||||
新版签名模式读取 `.file-access/config.json`。该文件已被 Git 忽略,所有环境共用现有的一套配置。
|
||||
换电脑或在 CI 构建时需要同步这份配置;准备方式见
|
||||
[文件访问接入说明](../pj_service_cloud/docs/file-access/README.md)。
|
||||
|
||||
```bash
|
||||
fvm flutter run --dart-define-from-file=.file-access/config.json
|
||||
```
|
||||
|
||||
VS Code 的新版 Debug、Profile、Release 启动配置已包含签名参数。
|
||||
Android Studio 在 `main.dart` 的 Run/Debug Configurations 中,将 Additional run args 设置为
|
||||
`--dart-define-from-file=.file-access/config.json`。
|
||||
这些参数在编译时生效,修改后需停止调试并重新运行。
|
||||
|
||||
## 旧版兼容验证
|
||||
|
||||
1. 选择“旧版兼容,无配置”,完全停止后重新启动,确认控制台显示旧版模式。
|
||||
2. 使用未缓存的新图片,检查实际请求只有原始文件参数,没有 `appId/kid/ts/sign`。
|
||||
主屏、副屏、人脸图片下载均使用同一编译模式;已有图片缓存不代表服务器仍允许读取。
|
||||
3. 在已部署文件校验的后端测试:`mode: enforce` 且 `legacy-dart.enabled: true` 时,
|
||||
符合 `Dart/<版本> (dart:io)` UA 的裸图片请求可走兼容;关闭兼容后应返回 403。
|
||||
后端 `observe` 模式仍会放行,不能用于确认拒绝行为。
|
||||
4. 旧 Dart 兼容只放行图片;PDF、裸 APK、上传不会因 Dart UA 放行。
|
||||
服务端签发的 `package` 下载链接继续可用。切回“新版签名”后,验证图片和上传请求带签名。
|
||||
|
||||
此模式模拟旧版请求,旧 APK 在实际设备上的兼容性仍需用原安装包验证。
|
||||
|
||||
## flutter打包
|
||||
|
||||
```
|
||||
flutter build apk --build-name 2.2.0 --build-number 220
|
||||
fvm flutter build apk --dart-define-from-file=.file-access/config.json --build-name 2.2.0 --build-number 220
|
||||
--build-name为版本号(展示)
|
||||
--build-number为版本号(内部比较)
|
||||
```
|
||||
|
||||
@@ -143,7 +143,7 @@ abstract class AppNetworkTool {
|
||||
final response = await downloadClient.send(request).timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode == 200) return await response.stream.toBytes().timeout(const Duration(seconds: 30));
|
||||
await response.stream.drain<void>();
|
||||
if (response.statusCode != 403 || attempt > 0 || !FileAccess.client.isProtected(url)) return null;
|
||||
if (response.statusCode != 403 || attempt > 0 || !FileAccess.signingEnabled || !FileAccess.client.isProtected(url)) return null;
|
||||
await FileAccess.refreshClock(force: true);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../values/constants.dart';
|
||||
@@ -13,6 +15,45 @@ const _filePaths = {
|
||||
'/api/admin/sys-file/oss/file'
|
||||
};
|
||||
|
||||
enum FileAccessMode { signed, legacy }
|
||||
|
||||
/// Missing Debug credentials can simulate old clients. Invalid credentials and
|
||||
/// release/profile builds still require a valid signing configuration.
|
||||
FileAccessMode resolveFileAccessMode({
|
||||
required String mode,
|
||||
required bool debugBuild,
|
||||
required bool hasConfiguration,
|
||||
required String appId,
|
||||
required String kid,
|
||||
required String key,
|
||||
}) {
|
||||
if (!{'auto', 'signed', 'legacy'}.contains(mode)) {
|
||||
throw StateError('Invalid FILE_ACCESS_MODE: $mode');
|
||||
}
|
||||
if (mode == 'legacy') {
|
||||
if (!debugBuild) {
|
||||
throw StateError('Legacy file access testing requires a Debug build');
|
||||
}
|
||||
return FileAccessMode.legacy;
|
||||
}
|
||||
if (mode == 'auto' && debugBuild && !hasConfiguration) {
|
||||
return FileAccessMode.legacy;
|
||||
}
|
||||
var validKey = false;
|
||||
try {
|
||||
validKey = base64Decode(key).length >= 32;
|
||||
} on FormatException {
|
||||
// A broken configuration must not silently produce unsigned requests.
|
||||
}
|
||||
if (appId != 'flutter' ||
|
||||
!RegExp(r'^[a-zA-Z0-9_-]{1,32}$').hasMatch(kid) ||
|
||||
!validKey) {
|
||||
throw StateError(
|
||||
'Build with --dart-define-from-file=.file-access/config.json');
|
||||
}
|
||||
return FileAccessMode.signed;
|
||||
}
|
||||
|
||||
String fileAccessCanonical(String file, String app, String kid, int ts) => [
|
||||
'v1',
|
||||
'oss-file-read',
|
||||
@@ -32,6 +73,24 @@ String fileAccessSignature(
|
||||
.toString();
|
||||
}
|
||||
|
||||
String fileUploadCanonical(
|
||||
String target, String app, String kid, int ts, String nonce) {
|
||||
if (!{'upload', 'inner-upload', 'qn-upload'}.contains(target)) {
|
||||
throw ArgumentError('Invalid file upload target');
|
||||
}
|
||||
return ['v1', 'oss-file-upload', 'POST', target, app, kid, '$ts', nonce]
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
String fileUploadSignature(
|
||||
String target, String app, String kid, int ts, String nonce, String key) {
|
||||
final bytes = base64Decode(key);
|
||||
if (bytes.length < 32) throw StateError('Invalid file upload key');
|
||||
return Hmac(sha256, bytes)
|
||||
.convert(utf8.encode(fileUploadCanonical(target, app, kid, ts, nonce)))
|
||||
.toString();
|
||||
}
|
||||
|
||||
/// Pure URL/signature logic. No GetX, database, device or main-engine dependency.
|
||||
class FileAccessClient {
|
||||
FileAccessClient(
|
||||
@@ -39,14 +98,17 @@ class FileAccessClient {
|
||||
required this.appId,
|
||||
required this.kid,
|
||||
required this.key,
|
||||
this.signingEnabled = true,
|
||||
this.allowedOrigins = const [],
|
||||
DateTime Function()? now})
|
||||
: now = now ?? DateTime.now;
|
||||
final String baseUrl, appId, kid, key;
|
||||
final bool signingEnabled;
|
||||
final List<String> allowedOrigins;
|
||||
final DateTime Function() now;
|
||||
int offsetMs = 0, ttlSeconds = 1800;
|
||||
final _cache = <String, ({int ts, String url})>{};
|
||||
final _uploadRandom = Random.secure();
|
||||
|
||||
String resolve(String source) {
|
||||
final value = source.trim();
|
||||
@@ -102,6 +164,66 @@ class FileAccessClient {
|
||||
}
|
||||
|
||||
bool isProtected(String source) => _owned(source) != null;
|
||||
|
||||
String? uploadTarget(String source) {
|
||||
var uri = Uri.tryParse(resolve(source));
|
||||
if (uri != null && !uri.hasScheme) uri = Uri.parse(baseUrl).resolveUri(uri);
|
||||
if (uri == null ||
|
||||
!{'http', 'https'}.contains(uri.scheme) ||
|
||||
uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty) {
|
||||
return null;
|
||||
}
|
||||
final origins = [
|
||||
Uri.parse(baseUrl).origin,
|
||||
...allowedOrigins
|
||||
.where((e) => e.isNotEmpty)
|
||||
.map((e) => Uri.parse(e).origin)
|
||||
];
|
||||
if (!origins.contains(uri.origin)) return null;
|
||||
final prefix = Uri.parse(baseUrl).path.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uploadPath = uri.path;
|
||||
for (final entry in {
|
||||
'upload': 'upload',
|
||||
'inner-upload': 'inner-upload',
|
||||
'qn-upload': 'qn/upload'
|
||||
}.entries) {
|
||||
if ([
|
||||
'/sys-file/',
|
||||
'/admin/sys-file/',
|
||||
'/api/sys-file/',
|
||||
'/api/admin/sys-file/',
|
||||
'$prefix/admin/sys-file/',
|
||||
'$prefix/sys-file/'
|
||||
].any((start) => uploadPath == '$start${entry.value}')) {
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, String> uploadHeaders(String source, {String? nonce}) {
|
||||
if (!signingEnabled) return {};
|
||||
final target = uploadTarget(source);
|
||||
if (target == null) return {};
|
||||
final issued = (now().millisecondsSinceEpoch + offsetMs) ~/ 1000;
|
||||
final requestNonce = nonce ??
|
||||
List.generate(
|
||||
16,
|
||||
(_) => _uploadRandom
|
||||
.nextInt(256)
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')).join();
|
||||
return {
|
||||
'X-File-App-Id': appId,
|
||||
'X-File-Key-Id': kid,
|
||||
'X-File-Timestamp': '$issued',
|
||||
'X-File-Nonce': requestNonce,
|
||||
'X-File-Signature':
|
||||
fileUploadSignature(target, appId, kid, issued, requestNonce, key),
|
||||
};
|
||||
}
|
||||
|
||||
String original(String source) {
|
||||
final uri = _owned(source);
|
||||
if (uri == null) return resolve(source);
|
||||
@@ -116,6 +238,7 @@ class FileAccessClient {
|
||||
}
|
||||
|
||||
String url(String source, {bool force = false}) {
|
||||
if (!signingEnabled) return original(source);
|
||||
final uri = _owned(source);
|
||||
if (uri == null) return resolve(source);
|
||||
final names = uri.queryParametersAll['fileName'];
|
||||
@@ -156,15 +279,33 @@ class FileAccessClient {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
bool synchronizeInfo(Map data, int roundTripMs) {
|
||||
// The Java project's Long serializer returns numeric JSON strings.
|
||||
final serverTime = int.tryParse('${data['serverTime']}');
|
||||
final ttl = int.tryParse('${data['ttlSeconds']}');
|
||||
if (serverTime == null || serverTime <= 0) return false;
|
||||
synchronize(serverTime, ttl ?? ttlSeconds, roundTripMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
void clear() => _cache.clear();
|
||||
}
|
||||
|
||||
abstract class FileAccess {
|
||||
static final mode = resolveFileAccessMode(
|
||||
mode: Constants.fileAccessMode,
|
||||
debugBuild: kDebugMode,
|
||||
hasConfiguration: Constants.hasFileAccessConfiguration,
|
||||
appId: Constants.fileAccessAppId,
|
||||
kid: Constants.fileAccessKeyId,
|
||||
key: Constants.fileAccessKey);
|
||||
static bool get signingEnabled => mode == FileAccessMode.signed;
|
||||
static final client = FileAccessClient(
|
||||
baseUrl: Constants.ossUrl,
|
||||
appId: Constants.fileAccessAppId,
|
||||
kid: Constants.fileAccessKeyId,
|
||||
key: Constants.fileAccessKey,
|
||||
signingEnabled: signingEnabled,
|
||||
allowedOrigins: Constants.fileAccessAllowedOrigins.split(','));
|
||||
static final revision = ValueNotifier<int>(0);
|
||||
static Timer? _timer;
|
||||
@@ -177,11 +318,10 @@ abstract class FileAccess {
|
||||
|
||||
static void initialize() {
|
||||
if (_initialized) return;
|
||||
if (Constants.fileAccessAppId != 'flutter' ||
|
||||
!RegExp(r'^[a-zA-Z0-9_-]{1,32}$').hasMatch(Constants.fileAccessKeyId) ||
|
||||
base64Decode(Constants.fileAccessKey).length < 32) {
|
||||
throw StateError(
|
||||
'Build with --dart-define-from-file=.file-access/config.json');
|
||||
if (!signingEnabled) {
|
||||
_initialized = true;
|
||||
debugPrint('[file-access] 旧版兼容测试模式:图片和上传不携带应用签名');
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
WidgetsBinding.instance.addObserver(_lifecycle);
|
||||
@@ -195,6 +335,7 @@ abstract class FileAccess {
|
||||
}
|
||||
|
||||
static void resume() {
|
||||
if (!signingEnabled) return;
|
||||
_timer ??=
|
||||
Timer.periodic(const Duration(minutes: 1), (_) => revision.value++);
|
||||
unawaited(refreshClock());
|
||||
@@ -206,6 +347,7 @@ abstract class FileAccess {
|
||||
}
|
||||
|
||||
static Future<void> refreshClock({bool force = false}) {
|
||||
if (!signingEnabled) return Future.value();
|
||||
if (_pending != null) return _pending!;
|
||||
if (!force &&
|
||||
_lastSync != null &&
|
||||
@@ -227,11 +369,9 @@ abstract class FileAccess {
|
||||
.timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode != 200) return;
|
||||
final data = jsonDecode(response.body)['data'];
|
||||
if (data is Map && data['serverTime'] is num) {
|
||||
client.synchronize(
|
||||
(data['serverTime'] as num).toInt(),
|
||||
(data['ttlSeconds'] as num?)?.toInt() ?? 1800,
|
||||
DateTime.now().millisecondsSinceEpoch - started);
|
||||
if (data is Map &&
|
||||
client.synchronizeInfo(
|
||||
data, DateTime.now().millisecondsSinceEpoch - started)) {
|
||||
_lastSync = DateTime.now();
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -242,7 +382,7 @@ abstract class FileAccess {
|
||||
}
|
||||
|
||||
static Future<bool> retryImage(String source) async {
|
||||
if (!client.isProtected(source)) return false;
|
||||
if (!signingEnabled || !client.isProtected(source)) return false;
|
||||
final raw = client.original(source),
|
||||
previous = _retries[client.original(source)];
|
||||
if (previous != null &&
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:get/get.dart' hide FormData;
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart';
|
||||
|
||||
/// 无需向操作员重复提示的服务端业务消息。
|
||||
const ignoreMsg = ["账号未登录"];
|
||||
@@ -71,6 +72,8 @@ class HttpManage {
|
||||
'imagehash',
|
||||
'localfilepath',
|
||||
'evidencefilepath',
|
||||
'xfilesignature',
|
||||
'xfilenonce',
|
||||
};
|
||||
|
||||
/// 进程内唯一的 HTTP 管理器实例,确保拦截器和连接池不被重复创建。
|
||||
@@ -99,6 +102,9 @@ class HttpManage {
|
||||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (RequestOptions options, handler) async {
|
||||
if (options.method.toUpperCase() == 'POST') {
|
||||
options.headers.addAll(FileAccess.client.uploadHeaders(options.uri.toString()));
|
||||
}
|
||||
options.extra[_requestStartAtKey] = DateTime.now();
|
||||
_startPaymentTiming(options);
|
||||
options.headers["CLIENT-TOC"] = "Y";
|
||||
|
||||
@@ -9,6 +9,13 @@ enum LoginVersion {
|
||||
}
|
||||
|
||||
class Constants {
|
||||
/// Debug 未配置签名时自动模拟旧版;signed 强制签名,legacy 显式测试旧版。
|
||||
static const fileAccessMode = String.fromEnvironment('FILE_ACCESS_MODE', defaultValue: 'auto');
|
||||
static const hasFileAccessConfiguration =
|
||||
bool.hasEnvironment('FILE_ACCESS_APP_ID') ||
|
||||
bool.hasEnvironment('FILE_ACCESS_KEY_ID') ||
|
||||
bool.hasEnvironment('FILE_ACCESS_KEY') ||
|
||||
bool.hasEnvironment('FILE_ACCESS_ALLOWED_ORIGINS');
|
||||
static const fileAccessAppId = String.fromEnvironment('FILE_ACCESS_APP_ID', defaultValue: 'flutter');
|
||||
static const fileAccessKeyId = String.fromEnvironment('FILE_ACCESS_KEY_ID', defaultValue: 'v1');
|
||||
static const fileAccessKey = String.fromEnvironment('FILE_ACCESS_KEY');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"target": "upload",
|
||||
"appId": "admin",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "00112233445566778899aabbccddeeff",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\nupload\nadmin\nv1\n1793232000\n00112233445566778899aabbccddeeff",
|
||||
"sign": "75fde91ab548250139f260680aeb2e51f831d29d8e4fb3cfb28898528fd3a2a8"
|
||||
},
|
||||
{
|
||||
"target": "qn-upload",
|
||||
"appId": "h5",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "ffeeddccbbaa99887766554433221100",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\nqn-upload\nh5\nv1\n1793232000\nffeeddccbbaa99887766554433221100",
|
||||
"sign": "01cef848210b1bc31f5fe7679428b36d8e0f15b9f5517472a023d4d2c008086c"
|
||||
},
|
||||
{
|
||||
"target": "inner-upload",
|
||||
"appId": "service",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "0123456789abcdef0123456789abcdef",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\ninner-upload\nservice\nv1\n1793232000\n0123456789abcdef0123456789abcdef",
|
||||
"sign": "0d49bb992e5138cdb7a3f2bd7c83dfd60aedeae2b205898c4a6211c794f35aae"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/app/app_network_tool.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart';
|
||||
import 'package:pj_supermarket_cloud/common/values/constants.dart';
|
||||
|
||||
void main() {
|
||||
final testKey = base64Encode(List.generate(32, (index) => index));
|
||||
|
||||
FileAccessMode select({
|
||||
String mode = 'auto',
|
||||
bool debug = true,
|
||||
bool configured = false,
|
||||
String appId = 'flutter',
|
||||
String kid = 'v1',
|
||||
String key = '',
|
||||
}) =>
|
||||
resolveFileAccessMode(
|
||||
mode: mode,
|
||||
debugBuild: debug,
|
||||
hasConfiguration: configured,
|
||||
appId: appId,
|
||||
kid: kid,
|
||||
key: key,
|
||||
);
|
||||
|
||||
test('an ordinary Debug launch without configuration simulates old clients',
|
||||
() {
|
||||
expect(select(), FileAccessMode.legacy);
|
||||
});
|
||||
|
||||
test('valid configuration selects signing in Debug and production builds',
|
||||
() {
|
||||
for (final debug in [true, false]) {
|
||||
for (final mode in ['auto', 'signed']) {
|
||||
expect(
|
||||
select(mode: mode, debug: debug, configured: true, key: testKey),
|
||||
FileAccessMode.signed,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('partial or invalid configuration never silently disables signing', () {
|
||||
for (final key in [
|
||||
'',
|
||||
'not-base64!',
|
||||
base64Encode([1, 2, 3])
|
||||
]) {
|
||||
expect(() => select(configured: true, key: key), throwsStateError);
|
||||
}
|
||||
expect(
|
||||
() => select(configured: true, appId: 'admin', key: testKey),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(
|
||||
() => select(configured: true, kid: 'invalid key id', key: testKey),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test('production and explicit signed launches reject missing credentials',
|
||||
() {
|
||||
expect(() => select(debug: false), throwsStateError);
|
||||
expect(() => select(mode: 'signed'), throwsStateError);
|
||||
});
|
||||
|
||||
test(
|
||||
'explicit legacy testing stays unsigned even when credentials are present',
|
||||
() {
|
||||
expect(
|
||||
select(mode: 'legacy', configured: true, key: testKey),
|
||||
FileAccessMode.legacy,
|
||||
);
|
||||
expect(() => select(mode: 'legacy', debug: false), throwsStateError);
|
||||
expect(() => select(mode: 'typo'), throwsStateError);
|
||||
});
|
||||
|
||||
test(
|
||||
'legacy image requests omit all signature fields and preserve file names',
|
||||
() {
|
||||
final client = FileAccessClient(
|
||||
baseUrl: 'https://files.example/api',
|
||||
appId: 'flutter',
|
||||
kid: 'v1',
|
||||
key: '',
|
||||
signingEnabled: false,
|
||||
);
|
||||
const source = '/admin/sys-file/oss/file?appId=flutter&kid=v1&ts=1&sign=old'
|
||||
'&width=20&fileName=literal%252Fname.png';
|
||||
final url = Uri.parse(client.url(source));
|
||||
expect(url.path, '/api/admin/sys-file/oss/file');
|
||||
expect(
|
||||
url.queryParameters, {'width': '20', 'fileName': 'literal%2Fname.png'});
|
||||
expect(client.url(source, force: true), url.toString());
|
||||
expect(client.url('photo.jpg'),
|
||||
'https://files.example/api/admin/sys-file/oss/file?fileName=photo.jpg');
|
||||
const external = 'https://other.example/image.png?sign=third-party';
|
||||
expect(client.url(external), external);
|
||||
expect(client.url('assets/logo.png'), 'assets/logo.png');
|
||||
});
|
||||
|
||||
test('legacy uploads carry no app signature headers and need no key', () {
|
||||
final client = FileAccessClient(
|
||||
baseUrl: 'https://files.example/api',
|
||||
appId: 'flutter',
|
||||
kid: 'v1',
|
||||
key: '',
|
||||
signingEnabled: false,
|
||||
);
|
||||
for (final target in ['upload', 'inner-upload', 'qn/upload']) {
|
||||
expect(client.uploadHeaders('/admin/sys-file/$target'), isEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('the compiled launch configuration controls image and upload signing',
|
||||
() {
|
||||
final expectedSigning = Constants.fileAccessMode != 'legacy' &&
|
||||
Constants.hasFileAccessConfiguration;
|
||||
expect(FileAccess.signingEnabled, expectedSigning);
|
||||
final uri = Uri.parse(FileAccess.client.url('photo.jpg'));
|
||||
expect(uri.queryParameters.containsKey('sign'), expectedSigning);
|
||||
expect(
|
||||
FileAccess.client.uploadHeaders('/admin/sys-file/upload').isNotEmpty,
|
||||
expectedSigning,
|
||||
);
|
||||
// Old clients can still consume package links signed by the server.
|
||||
const package = 'https://files.example/admin/sys-file/oss/file'
|
||||
'?appId=package&kid=v1&ts=1&sign=server&fileName=app.apk';
|
||||
expect(FileAccess.packageUrl(package), package);
|
||||
});
|
||||
|
||||
test(
|
||||
'legacy image downloads send bare requests and do not retry a rejected signature',
|
||||
() async {
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
return http.Response('', 403);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
expect(await AppNetworkTool.downloadImage('photo.jpg', client: client),
|
||||
isNull);
|
||||
expect(requests, hasLength(1));
|
||||
expect(requests.single.url.queryParameters, {'fileName': 'photo.jpg'});
|
||||
}, skip: FileAccess.signingEnabled);
|
||||
|
||||
testWidgets(
|
||||
'unsigned startup avoids platform initialization and renewal timers',
|
||||
(tester) async {
|
||||
FileAccess.initialize();
|
||||
FileAccess.initialize();
|
||||
final before = FileAccess.revision.value;
|
||||
FileAccess.resume();
|
||||
await FileAccess.refreshClock(force: true);
|
||||
expect(await FileAccess.retryImage('photo.jpg'), isFalse);
|
||||
await tester.pump(const Duration(minutes: 2));
|
||||
expect(FileAccess.revision.value, before);
|
||||
FileAccess.pause();
|
||||
}, skip: FileAccess.signingEnabled);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart';
|
||||
|
||||
void main() {
|
||||
final vectors =
|
||||
jsonDecode(File('test-vectors/file-upload.json').readAsStringSync())
|
||||
as List;
|
||||
for (final v in vectors) {
|
||||
test('shared upload signature: ${v['target']}', () {
|
||||
expect(
|
||||
fileUploadCanonical(
|
||||
v['target'], v['appId'], v['kid'], v['ts'], v['nonce']),
|
||||
v['canonical']);
|
||||
expect(
|
||||
fileUploadSignature(
|
||||
v['target'], v['appId'], v['kid'], v['ts'], v['nonce'], v['key']),
|
||||
v['sign']);
|
||||
});
|
||||
}
|
||||
test(
|
||||
'upload headers are fresh, target-bound and restricted to owned endpoints',
|
||||
() {
|
||||
final client = FileAccessClient(
|
||||
baseUrl: 'https://files.example/api',
|
||||
appId: 'flutter',
|
||||
kid: 'v1',
|
||||
key: vectors[0]['key'],
|
||||
now: () => DateTime.fromMillisecondsSinceEpoch(1793232000000),
|
||||
allowedOrigins: ['https://legacy.example']);
|
||||
for (final entry in {
|
||||
'/admin/sys-file/upload': 'upload',
|
||||
'/sys-file/upload': 'upload',
|
||||
'/api/admin/sys-file/qn/upload': 'qn-upload',
|
||||
'https://legacy.example/admin/sys-file/inner-upload': 'inner-upload'
|
||||
}.entries) {
|
||||
final headers =
|
||||
client.uploadHeaders(entry.key, nonce: vectors[0]['nonce']);
|
||||
expect(
|
||||
headers['X-File-Signature'],
|
||||
fileUploadSignature(entry.value, 'flutter', 'v1', 1793232000,
|
||||
vectors[0]['nonce'], vectors[0]['key']));
|
||||
}
|
||||
final first = client.uploadHeaders('/admin/sys-file/upload'),
|
||||
second = client.uploadHeaders('/admin/sys-file/upload');
|
||||
expect(first['X-File-Nonce'], isNot(second['X-File-Nonce']));
|
||||
expect(first['X-File-Signature'], isNot(second['X-File-Signature']));
|
||||
for (final source in [
|
||||
'https://other.example/admin/sys-file/upload',
|
||||
'https:///admin/sys-file/upload',
|
||||
'/admin/user/import',
|
||||
'/admin/sys-file/oss/file?fileName=x.jpg',
|
||||
'file:///local.png'
|
||||
]) {
|
||||
expect(client.uploadHeaders(source), isEmpty);
|
||||
}
|
||||
});
|
||||
test(
|
||||
'the Java numeric-string clock response corrects upload and image signatures',
|
||||
() {
|
||||
final client = FileAccessClient(
|
||||
baseUrl: 'https://files.example/api',
|
||||
appId: 'flutter',
|
||||
kid: 'v1',
|
||||
key: vectors[0]['key'],
|
||||
now: () => DateTime.fromMillisecondsSinceEpoch(1793318400000));
|
||||
expect(
|
||||
client.synchronizeInfo(
|
||||
{'serverTime': '1793232000000', 'ttlSeconds': '1800'}, 0),
|
||||
isTrue);
|
||||
expect(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],
|
||||
'1793232000');
|
||||
expect(
|
||||
Uri.parse(client.url('photo.jpg')).queryParameters['ts'], '1793232000');
|
||||
expect(client.synchronizeInfo({'serverTime': 'not-a-time'}, 0), isFalse);
|
||||
expect(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],
|
||||
'1793232000');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user