doge 3 päivää sitten
vanhempi
sitoutus
f8b4b05aa0

+ 57 - 4
app/Http/Middleware/ApiCrypto.php

@@ -5,8 +5,11 @@ declare(strict_types=1);
 namespace App\Http\Middleware;
 namespace App\Http\Middleware;
 
 
 use App\Exceptions\ApiCryptoException;
 use App\Exceptions\ApiCryptoException;
+use App\Models\User;
+use App\Models\UserSession;
 use App\Services\ApiCryptoService;
 use App\Services\ApiCryptoService;
 use Closure;
 use Closure;
+use Illuminate\Database\QueryException;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use JsonException;
 use JsonException;
 use RuntimeException;
 use RuntimeException;
@@ -29,6 +32,12 @@ class ApiCrypto
             return $next($request);
             return $next($request);
         }
         }
 
 
+        $isEncrypted = $request->headers->get(self::HEADER) === '1';
+        $plaintextUser = $this->isPlaintextUser($request);
+        if ($plaintextUser && !$isEncrypted) {
+            return $this->markPlaintextResponse($next($request));
+        }
+
         try {
         try {
             $crypto = new ApiCryptoService(
             $crypto = new ApiCryptoService(
                 (string) config('api_crypto.key'),
                 (string) config('api_crypto.key'),
@@ -36,10 +45,12 @@ class ApiCrypto
                 (int) config('api_crypto.max_payload_bytes', 2097152),
                 (int) config('api_crypto.max_payload_bytes', 2097152),
             );
             );
         } catch (RuntimeException) {
         } catch (RuntimeException) {
-            return $this->error(5001, 'API 加密配置错误,请联系服务端管理员', 500)->prepare($request);
+            $response = $this->error(5001, 'API 加密配置错误,请联系服务端管理员', 500)->prepare($request);
+
+            return $plaintextUser ? $this->markPlaintextResponse($response) : $response;
         }
         }
 
 
-        if ($request->headers->get(self::HEADER) !== '1') {
+        if (!$isEncrypted) {
             if (config('api_crypto.allow_plaintext')) {
             if (config('api_crypto.allow_plaintext')) {
                 return $next($request);
                 return $next($request);
             }
             }
@@ -59,10 +70,52 @@ class ApiCrypto
             $parameters = $crypto->decrypt($payload, ApiCryptoService::DIRECTION_REQUEST);
             $parameters = $crypto->decrypt($payload, ApiCryptoService::DIRECTION_REQUEST);
             $this->replaceParameters($request, $parameters, $queryMethod);
             $this->replaceParameters($request, $parameters, $queryMethod);
         } catch (ApiCryptoException | JsonException $exception) {
         } catch (ApiCryptoException | JsonException $exception) {
-            return $this->encryptResponse($this->error(4002, $exception->getMessage(), 400), $crypto, $request);
+            $response = $this->error(4002, $exception->getMessage(), 400);
+
+            return $plaintextUser
+                ? $this->markPlaintextResponse($response->prepare($request))
+                : $this->encryptResponse($response, $crypto, $request);
+        }
+
+        $response = $next($request);
+
+        return $plaintextUser
+            ? $this->markPlaintextResponse($response)
+            : $this->encryptResponse($response, $crypto, $request);
+    }
+
+    private function isPlaintextUser(Request $request): bool
+    {
+        $userIds = config('api_crypto.plaintext_user_ids', []);
+        $token = $request->header('Authorization');
+        if ($userIds === [] || !is_string($token) || $token === '') {
+            return false;
         }
         }
 
 
-        return $this->encryptResponse($next($request), $crypto, $request);
+        try {
+            $session = UserSession::findActiveToken($token, now()->getTimestamp());
+            if (!$session || !hash_equals((string) $session->token, $token)
+                || !in_array((string) $session->user_id, $userIds, true)) {
+                return false;
+            }
+
+            $user = User::where('user_id', $session->user_id)->first();
+        } catch (QueryException) {
+            return false;
+        }
+
+        return $user
+            && (string) $user->user_id === (string) $session->user_id
+            && empty($user->getAttributes()['delete_time'])
+            && (int) ($user->getAttributes()['is_banned'] ?? 0) !== 1;
+    }
+
+    private function markPlaintextResponse(Response $response): Response
+    {
+        $response->headers->set(self::HEADER, '0');
+        $response->headers->set('Cache-Control', 'no-store');
+
+        return $response;
     }
     }
 
 
     private function replaceParameters(Request $request, array $parameters, bool $queryMethod): void
     private function replaceParameters(Request $request, array $parameters, bool $queryMethod): void

+ 2 - 4
app/Http/Middleware/CheckToken.php

@@ -33,12 +33,10 @@ class CheckToken
 
 
         // 3. 查询 user_session:token 存在 + 未过期
         // 3. 查询 user_session:token 存在 + 未过期
         $time = now()->getTimestamp();
         $time = now()->getTimestamp();
-        $session = UserSession::where('token', $token)
-            ->where('expire_time', '>', $time)
-            ->first();
+        $session = UserSession::findActiveToken($token, $time);
 
 
         // 4. 会话不存在或已过期
         // 4. 会话不存在或已过期
-        if (!$session || UserSession::validUntil($session->getAttributes()) <= $time) {
+        if (!$session) {
             return response()->json([
             return response()->json([
                 'code' => 401,
                 'code' => 401,
                 'msg' => lang('token无效或已过期')
                 'msg' => lang('token无效或已过期')

+ 7 - 0
app/Models/UserSession.php

@@ -52,6 +52,13 @@ class UserSession extends BaseModel
         return $lastActivity > 0 ? min((int)($session['expire_time'] ?? 0), $lastActivity + self::IDLE_SECONDS) : 0;
         return $lastActivity > 0 ? min((int)($session['expire_time'] ?? 0), $lastActivity + self::IDLE_SECONDS) : 0;
     }
     }
 
 
+    public static function findActiveToken(string $token, int $time): ?self
+    {
+        $session = static::where('token', $token)->where('expire_time', '>', $time)->first();
+
+        return $session && static::validUntil($session->getAttributes()) > $time ? $session : null;
+    }
+
     /** Renew only the same still-active token; never move the activity clock backwards. */
     /** Renew only the same still-active token; never move the activity clock backwards. */
     public static function renewActiveToken(string $token, int $time, ?int $sessionId = null): ?self
     public static function renewActiveToken(string $token, int $time, ?int $sessionId = null): ?self
     {
     {

+ 1 - 0
config/api_crypto.php

@@ -4,6 +4,7 @@ return [
     'enabled' => filter_var(env('API_CRYPTO_ENABLED', false), FILTER_VALIDATE_BOOL),
     'enabled' => filter_var(env('API_CRYPTO_ENABLED', false), FILTER_VALIDATE_BOOL),
     'key' => (string) env('API_CRYPTO_KEY', ''),
     'key' => (string) env('API_CRYPTO_KEY', ''),
     'allow_plaintext' => filter_var(env('API_CRYPTO_ALLOW_PLAINTEXT', false), FILTER_VALIDATE_BOOL),
     'allow_plaintext' => filter_var(env('API_CRYPTO_ALLOW_PLAINTEXT', false), FILTER_VALIDATE_BOOL),
+    'plaintext_user_ids' => preg_split('/[\s,]+/', (string) env('API_CRYPTO_PLAINTEXT_USER_IDS', ''), -1, PREG_SPLIT_NO_EMPTY) ?: [],
     'timestamp_tolerance' => (int) env('API_CRYPTO_TIMESTAMP_TOLERANCE', 300),
     'timestamp_tolerance' => (int) env('API_CRYPTO_TIMESTAMP_TOLERANCE', 300),
     'max_payload_bytes' => (int) env('API_CRYPTO_MAX_PAYLOAD_BYTES', 2097152),
     'max_payload_bytes' => (int) env('API_CRYPTO_MAX_PAYLOAD_BYTES', 2097152),
 ];
 ];

+ 11 - 0
docs/前端接口/前台API请求响应加密接入说明.md

@@ -46,3 +46,14 @@ GET/HEAD 把 `payload` 放在查询参数中,POST 放在 JSON 请求体中。
 | `4002` | 400 | `payload` 缺失、格式错误、过期或认证失败 |
 | `4002` | 400 | `payload` 缺失、格式错误、过期或认证失败 |
 | `5001` | 500 | 服务端加密密钥无效,返回普通 JSON |
 | `5001` | 500 | 服务端加密密钥无效,返回普通 JSON |
 | `5002` | 500 | 服务端响应加密失败,返回普通 JSON |
 | `5002` | 500 | 服务端响应加密失败,返回普通 JSON |
+
+## 白名单账号测试
+
+白名单账号携带当前有效的 `Authorization` Token,即可在上述接口直接发送原业务参数。
+
+```http
+GET /api/wallet/withdrawChannel?lang=zh
+Authorization: <白名单账号的登录令牌>
+```
+
+响应直接读取原业务 JSON。继续发送加密请求也可以,服务端仍解密请求,响应返回明文。收到 `X-Api-Encrypted: 0` 时跳过响应解密。

+ 2 - 0
example.env

@@ -9,6 +9,8 @@ APP_URL=
 API_CRYPTO_ENABLED=false
 API_CRYPTO_ENABLED=false
 API_CRYPTO_KEY=
 API_CRYPTO_KEY=
 API_CRYPTO_ALLOW_PLAINTEXT=false
 API_CRYPTO_ALLOW_PLAINTEXT=false
+# 测试账号的业务 user_id,多个 ID 用英文逗号分隔。
+API_CRYPTO_PLAINTEXT_USER_IDS=
 API_CRYPTO_TIMESTAMP_TOLERANCE=300
 API_CRYPTO_TIMESTAMP_TOLERANCE=300
 API_CRYPTO_MAX_PAYLOAD_BYTES=2097152
 API_CRYPTO_MAX_PAYLOAD_BYTES=2097152
 
 

+ 65 - 0
tests/Integration/ApiCryptoTest.php

@@ -48,6 +48,7 @@ class ApiCryptoTest extends TestCase
             'api_crypto.enabled' => true,
             'api_crypto.enabled' => true,
             'api_crypto.key' => $key,
             'api_crypto.key' => $key,
             'api_crypto.allow_plaintext' => false,
             'api_crypto.allow_plaintext' => false,
+            'api_crypto.plaintext_user_ids' => [],
             'api_crypto.timestamp_tolerance' => 300,
             'api_crypto.timestamp_tolerance' => 300,
             'api_crypto.max_payload_bytes' => 2097152,
             'api_crypto.max_payload_bytes' => 2097152,
             'cache.default' => 'array',
             'cache.default' => 'array',
@@ -335,6 +336,69 @@ class ApiCryptoTest extends TestCase
         self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent());
         self::assertSame('{"code":0}', $this->send('/api/wallet/probe')->getContent());
     }
     }
 
 
+    public function test_allowlisted_user_can_send_plaintext_and_receives_plaintext_for_encrypted_requests(): void
+    {
+        $this->createWalletFixture();
+        DB::table('users')->update(['user_id' => '9000000001', 'real_name' => 'Fixture Name']);
+        DB::table('user_session')->update(['user_id' => '9000000001']);
+        config(['api_crypto.plaintext_user_ids' => ['99', '9000000001']]);
+        $headers = ['HTTP_AUTHORIZATION' => self::TOKEN];
+
+        $response = $this->send('/api/wallet/withdrawChannel?lang=en', 'GET', $headers);
+        self::assertSame(200, $response->getStatusCode());
+        self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
+        self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+        self::assertStringContainsString('X-Api-Encrypted', $response->headers->get('Access-Control-Expose-Headers'));
+        $body = json_decode($response->getContent(), true);
+        self::assertSame('Bank card', array_column($body['data'], 'label', 'value')['DF001']);
+
+        Schema::create('pc_issues', function (Blueprint $table) {
+            $table->id();
+            $table->integer('status');
+            $table->string('issue_no');
+        });
+        $response = $this->send('/api/newPc/yuanTou', 'GET', $headers);
+        self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
+        self::assertSame(0, json_decode($response->getContent(), true)['code']);
+
+        $response = $this->sendEncrypted('/api/wallet/addBank', 'POST', [
+            'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345',
+        ], true);
+        self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
+        self::assertSame(0, json_decode($response->getContent(), true)['code']);
+        self::assertSame('12345', DB::table('banks')->sole()->card_no);
+
+        $response = $this->send('/api/wallet/addBank', 'POST', $headers + ['HTTP_X_API_ENCRYPTED' => '1']);
+        self::assertSame(400, $response->getStatusCode());
+        self::assertSame('0', $response->headers->get('X-Api-Encrypted'));
+        self::assertSame(4002, json_decode($response->getContent(), true)['code']);
+    }
+
+    public function test_allowlist_requires_the_matching_users_active_token(): void
+    {
+        $this->createWalletFixture();
+        DB::table('users')->update(['user_id' => '99']);
+        DB::table('user_session')->update(['user_id' => '99']);
+        $url = '/api/wallet/withdrawChannel?user_id=42';
+        $headers = ['HTTP_AUTHORIZATION' => self::TOKEN];
+        config(['api_crypto.plaintext_user_ids' => ['42']]);
+
+        // 42 is the member_id and the submitted parameter, but the token belongs to user_id 99.
+        self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']);
+        self::assertSame(0, $this->unwrap($this->sendEncrypted($url, 'GET', (object) [], true))['code']);
+
+        config(['api_crypto.plaintext_user_ids' => ['99']]);
+        self::assertSame(4001, $this->unwrap($this->send($url))['code']);
+        self::assertSame(4001, $this->unwrap($this->send($url, 'GET', ['HTTP_AUTHORIZATION' => 'invalid-token']))['code']);
+
+        DB::table('users')->update(['is_banned' => 1]);
+        self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']);
+
+        DB::table('users')->update(['is_banned' => 0]);
+        DB::table('user_session')->update(['create_time' => time() - 86401, 'update_time' => 0]);
+        self::assertSame(4001, $this->unwrap($this->send($url, 'GET', $headers))['code']);
+    }
+
     public function test_withdrawal_account_and_address_apis_ignore_and_hide_aliases(): void
     public function test_withdrawal_account_and_address_apis_ignore_and_hide_aliases(): void
     {
     {
         $this->createWalletFixture();
         $this->createWalletFixture();
@@ -539,6 +603,7 @@ class ApiCryptoTest extends TestCase
             $table->id();
             $table->id();
             $table->string('user_id');
             $table->string('user_id');
             $table->string('member_id');
             $table->string('member_id');
+            $table->integer('is_banned')->default(0);
             $table->string('account')->default('fixture-login');
             $table->string('account')->default('fixture-login');
             $table->string('first_name')->nullable();
             $table->string('first_name')->nullable();
             $table->string('admin_note')->nullable();
             $table->string('admin_note')->nullable();