This commit is contained in:
hashbro
2026-08-09 02:08:42 +08:00
parent 74a67c3900
commit 5c7600744e
24 changed files with 1510 additions and 70 deletions
+3 -6
View File
@@ -46,14 +46,11 @@ ADMIN_PASSWORD=admin123
# Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0.
CORUNA_SESSION_KEY=
# Channel link display + builder deployment_domains (when channel.domains is empty).
# Channel domains (link display + builder deployment_domains when channel.domains is empty).
# Overridable by 系统设置 → 投放域名.
CORUNA_LAB_CHANNEL_DOMAINS=
# Builder reporting_domains (always from env; not overridden by channel.domains).
CORUNA_LAB_AMIDN_DOMAINS=
# Legacy aliases (optional):
# CORUNA_DEPLOYMENT_DOMAINS= / CORUNA_CHANNEL_DOMAINS= → CORUNA_LAB_CHANNEL_DOMAINS
# CORUNA_REPORTING_DOMAINS= → CORUNA_LAB_AMIDN_DOMAINS
# API / reporting domains for the builder (always from env; not overridden by channel.domains).
CORUNA_REPORTING_DOMAINS=
# Sibling project coruna-lab-web build API (Bearer authentication)
CORUNA_BUILD_SERVICE_URL=http://127.0.0.1:8081
CORUNA_BUILD_SERVICE_TOKEN=
+7 -4
View File
@@ -36,7 +36,9 @@ php artisan serve --host=0.0.0.0 --port=8000
Admin:
- Shell: `http://127.0.0.1:8000/admin`
- Login: `http://127.0.0.1:8000/admin/login` — `admin` / `admin123`(见 `.env` `ADMIN_*`)
- Login: `http://127.0.0.1:8000/admin/login` — AJAX JSON 登录(参考 qrpay):用户名 / 密码 / 可选谷歌验证码
- 默认账号:`admin` / `admin123`(见 `.env` `ADMIN_*`)
- 登录后「安全 → 谷歌验证」可绑定 Google Authenticator
## 新建渠道
@@ -48,11 +50,11 @@ CORUNA_BUILD_SERVICE_TOKEN=replace-me
CORUNA_BUILD_SERVICE_CONNECT_TIMEOUT=5
CORUNA_BUILD_SERVICE_TIMEOUT=600
CORUNA_LAB_CHANNEL_DOMAINS=www.dep1.example,www.dep2.example
CORUNA_LAB_AMIDN_DOMAINS=www.rep1.example,www.rep2.example
CORUNA_REPORTING_DOMAINS=www.rep1.example,www.rep2.example
CORUNA_STATIC_SITE_BASE_URL=https://static.example
```
创建渠道时可填写 `domains`(可选);留空则展示链接与构建 `deployment_domains` 使用 `CORUNA_LAB_CHANNEL_DOMAINS`。构建时的 `reporting_domains` 始终使用 `CORUNA_LAB_AMIDN_DOMAINS`。
创建渠道时可填写 `domains`(可选);留空则展示链接与构建 `deployment_domains` 使用 `CORUNA_LAB_CHANNEL_DOMAINS`。构建时的 `reporting_domains` 始终使用 `CORUNA_REPORTING_DOMAINS`。
构建:`POST /v1/channels/{id}/build`
删除:`DELETE /v1/channels/{id}`
@@ -64,7 +66,8 @@ https://<host>/channel/<channel-id>/web/support.html
https://<host>/channel/<channel-id>/sync/daily.html
```
构建服务部署见 [`../coruna-lab-web/docs/BUILD_API.md`](../coruna-lab-web/docs/BUILD_API.md)。
构建服务部署见 [`../coruna-lab-web/docs/BUILD_API.md`](../coruna-lab-web/docs/BUILD_API.md)。
宝塔同机部署(含 Telegram webhook)见 [`docs/BAOTA_DEPLOY.md`](docs/BAOTA_DEPLOY.md)。
## C2 / Admin 说明
@@ -22,7 +22,7 @@ class AdminUserController extends Controller
$q->where('username', 'like', '%'.$username.'%');
}
$sortable = ['id', 'username', 'is_super', 'created_at', 'updated_at'];
$sortable = ['id', 'username', 'is_super', 'status', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -40,6 +40,9 @@ class AdminUserController extends Controller
'id' => $a->id,
'username' => $a->username,
'is_super' => (int) $a->is_super,
'status' => (int) $a->status,
'google_auth_open' => (int) $a->google_auth_open,
'last_ip' => $a->last_ip,
'created_at' => optional($a->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($a->updated_at)->format('Y-m-d H:i:s'),
'is_self' => $a->id === $selfId,
@@ -60,12 +63,14 @@ class AdminUserController extends Controller
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('admins', 'username')],
'password' => ['required', 'string', 'min:6', 'max:128'],
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
$admin = Admin::query()->create([
'username' => $data['username'],
'password' => $data['password'],
'is_super' => (int) ($data['is_super'] ?? 0),
'status' => (int) ($data['status'] ?? 1),
]);
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $admin->id]]);
@@ -76,6 +81,7 @@ class AdminUserController extends Controller
$data = $request->validate([
'password' => ['nullable', 'string', 'min:6', 'max:128'],
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if (array_key_exists('is_super', $data) && $data['is_super'] !== null) {
@@ -86,6 +92,13 @@ class AdminUserController extends Controller
$adminUser->is_super = $newSuper;
}
if (array_key_exists('status', $data) && $data['status'] !== null) {
if ((int) $adminUser->id === (int) auth('admin')->id() && (int) $data['status'] === 0) {
return response()->json(['code' => 1, 'msg' => '不能禁用当前登录账号'], 422);
}
$adminUser->status = (int) $data['status'];
}
if (! empty($data['password'])) {
$adminUser->password = $data['password'];
}
+84 -14
View File
@@ -3,11 +3,21 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Admin;
use App\Services\AdminGoogle2fa;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
private const MAX_ATTEMPTS = 5;
private const DECAY_SECONDS = 60;
public function showLogin()
{
if (Auth::guard('admin')->check()) {
@@ -22,23 +32,78 @@ class AuthController extends Controller
return view('admin.shell');
}
public function login(Request $request)
public function login(Request $request, AdminGoogle2fa $google2fa): JsonResponse
{
$credentials = $request->validate([
'username' => 'required|string',
'password' => 'required|string',
]);
if (Auth::guard('admin')->attempt(
['username' => $credentials['username'], 'password' => $credentials['password']],
$request->boolean('remember')
)) {
$request->session()->regenerate();
return redirect()->intended(route('admin.home'));
try {
$credentials = $request->validate([
'username' => 'required|string|min:2|max:64',
'password' => 'required|string|min:6|max:128',
'GACode' => 'nullable|string|max:16',
], [
'username.required' => '请输入用户名',
'password.required' => '请输入密码',
]);
} catch (ValidationException $e) {
return response()->json([
'code' => 1,
'msg' => collect($e->errors())->flatten()->implode('<br>'),
]);
}
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
$throttleKey = $this->throttleKey($request);
if (RateLimiter::tooManyAttempts($throttleKey, self::MAX_ATTEMPTS)) {
$seconds = RateLimiter::availableIn($throttleKey);
return response()->json([
'code' => 1,
'msg' => '登陆失败次数过多,请'.$seconds.'秒后再重试',
]);
}
if (! Auth::guard('admin')->attempt(
['username' => $credentials['username'], 'password' => $credentials['password']],
false
)) {
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
return response()->json(['code' => 1, 'msg' => '用户名或密码错误']);
}
/** @var Admin $user */
$user = Auth::guard('admin')->user();
if ((int) $user->status !== 1) {
Auth::guard('admin')->logout();
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
return response()->json(['code' => 1, 'msg' => '用户已被禁用']);
}
if ((int) $user->google_auth_open === 1) {
$code = (string) ($credentials['GACode'] ?? '');
if ($code === '') {
Auth::guard('admin')->logout();
return response()->json(['code' => 1, 'msg' => '请输入谷歌验证码!']);
}
if (! $google2fa->verify((string) $user->google_secret, $code)) {
Auth::guard('admin')->logout();
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确!']);
}
}
RateLimiter::clear($throttleKey);
$request->session()->regenerate();
$user->forceFill(['last_ip' => $request->ip()])->save();
return response()->json([
'code' => 0,
'msg' => '登录成功',
'data' => route('admin.home'),
]);
}
public function logout(Request $request)
@@ -49,4 +114,9 @@ class AuthController extends Controller
return redirect()->route('admin.login');
}
private function throttleKey(Request $request): string
{
return Str::transliterate(Str::lower((string) $request->input('username')).'|'.$request->ip());
}
}
@@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Admin;
use App\Services\AdminGoogle2fa;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class Google2faController extends Controller
{
public function index()
{
/** @var Admin $admin */
$admin = auth('admin')->user();
return view('admin.security.google2fa', [
'enabled' => (int) $admin->google_auth_open === 1,
'bound' => filled($admin->google_secret),
]);
}
public function prepare(Request $request, AdminGoogle2fa $google2fa): JsonResponse
{
/** @var Admin $admin */
$admin = auth('admin')->user();
$data = $request->validate([
'password' => ['required', 'string'],
], [
'password.required' => '登陆密码不能为空',
]);
if (! Hash::check($data['password'], $admin->password)) {
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
}
if ((int) $admin->google_auth_open === 1 || filled($admin->google_secret)) {
return response()->json(['code' => 201, 'msg' => '您已绑定谷歌验证,可直接开启或关闭']);
}
$secret = $google2fa->generateSecret();
$request->session()->put('admin_google2fa_pending_secret', $secret);
$otpAuthUrl = $google2fa->otpAuthUrl($admin, $secret);
return response()->json([
'code' => 0,
'msg' => 'ok',
'secret' => $secret,
'qr_svg' => $google2fa->qrSvg($otpAuthUrl),
]);
}
public function bind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
{
/** @var Admin $admin */
$admin = auth('admin')->user();
$data = $request->validate([
'GAKey' => ['required', 'string', 'max:16'],
'GASecret' => ['required', 'string', 'max:64'],
], [
'GAKey.required' => '请输入谷歌验证码',
'GASecret.required' => '参数不完整',
]);
$pending = (string) $request->session()->get('admin_google2fa_pending_secret', '');
if ($pending === '' || ! hash_equals($pending, $data['GASecret'])) {
return response()->json(['code' => 1, 'msg' => '绑定已过期,请重新获取二维码']);
}
if (! $google2fa->verify($data['GASecret'], $data['GAKey'])) {
return response()->json(['code' => 1, 'msg' => '绑定失败,验证码不正确']);
}
$admin->forceFill([
'google_auth_open' => 1,
'google_secret' => $data['GASecret'],
])->save();
$request->session()->forget('admin_google2fa_pending_secret');
return response()->json(['code' => 0, 'msg' => '绑定成功,下次登录将需要输入谷歌验证码']);
}
public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse
{
/** @var Admin $admin */
$admin = auth('admin')->user();
$data = $request->validate([
'password' => ['required', 'string'],
'open' => ['required', 'integer', 'in:0,1'],
'GACode' => ['nullable', 'string', 'max:16'],
]);
if (! Hash::check($data['password'], $admin->password)) {
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
}
if (! filled($admin->google_secret)) {
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
}
$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' => '谷歌验证码不正确']);
}
}
$admin->forceFill(['google_auth_open' => $open])->save();
return response()->json([
'code' => 0,
'msg' => $open === 1 ? '已开启谷歌验证' : '已关闭谷歌验证',
]);
}
public function unbind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
{
/** @var Admin $admin */
$admin = auth('admin')->user();
$data = $request->validate([
'password' => ['required', 'string'],
'GACode' => ['required', 'string', 'max:16'],
]);
if (! Hash::check($data['password'], $admin->password)) {
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
}
if (! filled($admin->google_secret)) {
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
}
if (! $google2fa->verify((string) $admin->google_secret, $data['GACode'])) {
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
}
$admin->forceFill([
'google_auth_open' => 0,
'google_secret' => null,
])->save();
$request->session()->forget('admin_google2fa_pending_secret');
return response()->json(['code' => 0, 'msg' => '已解除谷歌验证绑定']);
}
}
+17 -2
View File
@@ -6,15 +6,25 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
class Admin extends Authenticatable
{
protected $fillable = ['username', 'password', 'is_super'];
protected $fillable = [
'username',
'password',
'is_super',
'status',
'google_auth_open',
'google_secret',
'last_ip',
];
protected $hidden = ['password', 'remember_token'];
protected $hidden = ['password', 'remember_token', 'google_secret'];
protected function casts(): array
{
return [
'password' => 'hashed',
'is_super' => 'integer',
'status' => 'integer',
'google_auth_open' => 'integer',
];
}
@@ -22,4 +32,9 @@ class Admin extends Authenticatable
{
return (int) $this->is_super === 1;
}
public function isEnabled(): bool
{
return (int) $this->status === 1;
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ class Channel extends Model
}
/**
* Domains for support-link display and builder deployment_domains.
* Domains for support-link display and builder channel hosts.
* Channel row wins; otherwise CORUNA_LAB_CHANNEL_DOMAINS.
*
* @return list<string>
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Services;
use App\Models\Admin;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use PragmaRX\Google2FA\Google2FA;
class AdminGoogle2fa
{
private readonly Google2FA $google2fa;
public function __construct(?Google2FA $google2fa = null)
{
$this->google2fa = $google2fa ?? new Google2FA;
}
public function generateSecret(): string
{
return $this->google2fa->generateSecretKey();
}
public function verify(string $secret, string $code): bool
{
$code = trim($code);
if ($secret === '' || $code === '') {
return false;
}
return (bool) $this->google2fa->verifyKey($secret, $code);
}
public function otpAuthUrl(Admin $admin, string $secret): string
{
$issuer = (string) config('app.name', 'Coruna Lab');
return $this->google2fa->getQRCodeUrl($issuer, $admin->username.'@admin', $secret);
}
public function qrSvg(string $otpAuthUrl, int $size = 200): string
{
$writer = new Writer(new ImageRenderer(
new RendererStyle($size),
new SvgImageBackEnd
));
return $writer->writeString($otpAuthUrl);
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ class ChannelProjectService
throw new RuntimeException('投放域名未配置(CORUNA_LAB_CHANNEL_DOMAINS 或渠道 domains)');
}
if ($reportingDomains === []) {
throw new RuntimeException('上报域名未配置(CORUNA_LAB_AMIDN_DOMAINS)');
throw new RuntimeException('上报域名未配置(CORUNA_REPORTING_DOMAINS)');
}
$response = $this->request('post', $channelId, [
+1 -1
View File
@@ -83,7 +83,7 @@ class SettingsService
if (array_key_exists('channels.domains', $map) && $map['channels.domains'] !== null) {
$domains = self::parseDomains($map['channels.domains']);
// Settings override channel/deployment hosts only; reporting stays CORUNA_LAB_AMIDN_DOMAINS.
// Settings override channel hosts only; reporting stays CORUNA_REPORTING_DOMAINS.
config([
'coruna.channel_domains' => $domains,
'coruna.deployment_domains' => $domains,
+2
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"bacon/bacon-qr-code": "^3.1",
"furqansiddiqui/bip39-mnemonic-php": "^0.1.7",
"guzzlehttp/guzzle": "^7.15",
"kornrunner/keccak": "^1.1",
@@ -14,6 +15,7 @@
"laravel/tinker": "^2.10.1",
"nutgram/laravel": "^1.7",
"nutgram/nutgram": "^4.49",
"pragmarx/google2fa": "^9.0",
"simplito/elliptic-php": "^1.0"
},
"require-dev": {
Generated
+227 -1
View File
@@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3a1612bc4d46f2bcff8904370145b15f",
"content-hash": "2a3e9276f4cbc867f173d43e2742ffd3",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
"time": "2026-04-05T21:06:35+00:00"
},
{
"name": "brick/math",
"version": "0.14.8",
@@ -135,6 +190,56 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -2856,6 +2961,75 @@
],
"time": "2026-07-24T22:11:53+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -2931,6 +3105,58 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
{
"name": "pragmarx/google2fa",
"version": "v9.0.0",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
},
"time": "2025-09-19T22:51:08+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
+7 -13
View File
@@ -4,24 +4,18 @@ $parseDomains = static fn (string $value): array => array_values(array_filter(ar
'trim',
preg_split('/[\s,;]+/', $value) ?: []
)));
// Channel / deployment hosts (link display fallback + builder deployment_domains).
$channelDomains = $parseDomains((string) env(
'CORUNA_LAB_CHANNEL_DOMAINS',
env('CORUNA_DEPLOYMENT_DOMAINS', env('CORUNA_CHANNEL_DOMAINS', ''))
));
// Reporting hosts passed to lab-web as reporting_domains.
$adminDomains = $parseDomains((string) env(
'CORUNA_LAB_AMIDN_DOMAINS',
env('CORUNA_REPORTING_DOMAINS', '')
));
// Channel hosts (link display fallback + builder deployment_domains).
$channelDomains = $parseDomains((string) env('CORUNA_LAB_CHANNEL_DOMAINS', ''));
// API / reporting hosts passed to lab-web as reporting_domains.
$reportingDomains = $parseDomains((string) env('CORUNA_REPORTING_DOMAINS', ''));
return [
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
// channel_domains / deployment_domains: CORUNA_LAB_CHANNEL_DOMAINS (+ legacy aliases).
// CORUNA_LAB_CHANNEL_DOMAINS — also mirrored as deployment_domains for the build API payload.
'channel_domains' => $channelDomains,
'deployment_domains' => $channelDomains,
// reporting_domains: CORUNA_LAB_AMIDN_DOMAINS (+ legacy CORUNA_REPORTING_DOMAINS).
'reporting_domains' => $adminDomains,
// CORUNA_REPORTING_DOMAINS
'reporting_domains' => $reportingDomains,
'static_site' => [
// Optional complete origin/base path prepended before /channel/{id}/web/support.html.
'base_url' => rtrim((string) env('CORUNA_STATIC_SITE_BASE_URL', ''), '/'),
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('admins', function (Blueprint $table) {
$table->unsignedTinyInteger('status')->default(1)->after('is_super');
$table->unsignedTinyInteger('google_auth_open')->default(0)->after('status');
$table->string('google_secret', 64)->nullable()->after('google_auth_open');
$table->string('last_ip', 45)->nullable()->after('google_secret');
});
}
public function down(): void
{
Schema::table('admins', function (Blueprint $table) {
$table->dropColumn(['status', 'google_auth_open', 'google_secret', 'last_ip']);
});
}
};
+512
View File
@@ -0,0 +1,512 @@
# 宝塔部署指南(coruna-lab + coruna-lab-web)
同机部署、职责分离。构建 API 仅本机访问;Admin / C2 与静态产物站对外。
兄弟项目构建细节另见 `[../../coruna-lab-web/docs/BUILD_API.md](../../coruna-lab-web/docs/BUILD_API.md)`。
## 架构
| 角色 | 项目 / 路径 | 对外 | 进程 |
| ---------- | -------------------------- | ---------------------------- | ----------------- |
| C2 / Admin | `coruna-lab` | `https://admin.example.com` | Nginx + PHP-FPM |
| 静态产物站 | `coruna-lab-web/artifacts` | `https://static.example.com` | Nginx 只读静态 |
| Build API | `coruna-lab-web` | **仅本机** `127.0.0.1:8081` | Supervisor / 进程守护 |
```text
设备 / 运营
│
├─ Admin / C2 API ──► coruna-lab (Laravel)
│ │
│ └─ HTTP Bearer ──► 127.0.0.1:8081 (build_api)
│ │
│ ▼
└─ /channel/<id>/web|sync ──► static 站点 ──► artifacts/
```
建议目录:
```text
/www/wwwroot/coruna-lab/
/www/wwwroot/coruna-lab-web/
```
防火墙只放行 80/443;**不要**把 `8081` 暴露到公网。
---
## 0. 服务器准备
软件商店安装:
- Nginx
- MySQL 8.0
- PHP **8.2+**(站点选用;宝塔可多版本并存,按站点切换)
- Python 3.10+(系统或面板)
- Composer(建议 ≥ 2.2,见下文排错)
PHP 扩展:`pdo_mysql`、`mbstring`、`openssl`、`tokenizer`、`xml`、`ctype`、`json`、`fileinfo`、`curl`、`zip`、**gmp**
系统包:`p7zip-full`(或等价)、`git`
### PHP 多版本
宝塔可同时安装多个 PHP。每个站点在「网站 → 设置 → PHP 版本」单独选择。
CLI 请显式使用对应二进制,例如:
```bash
/www/server/php/82/bin/php -v
/www/server/php/82/bin/php artisan migrate
```
扩展、禁用函数、`php.ini` 必须在**该站点所用版本**里配置。
### PHP 运行参数(FPM / 网站)
路径:软件商店 → PHP 8.2 → 设置 → 配置修改(`php.ini`)
建议:
```ini
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 600
max_input_time = 600
```
- `post_max_size` ≥ `upload_max_filesize`
- Admin 触发构建会同步等待 lab-web,超时与 `.env` 中 `CORUNA_BUILD_SERVICE_TIMEOUT` 对齐(建议 ≥ 600)
- CLI 查 `max_execution_time` 常为 `0`(不限制),属正常;以浏览器/`phpinfo()` 的 FPM 值为准
Nginx 站点配置建议同时加大:
```nginx
client_max_body_size 64m;
```
禁用函数:Composer / Laravel 需要 `putenv`;C2 解包可能需要 `proc_open` / `exec`。从 PHP「禁用函数」中移除 `putenv`(按需放行 `proc_open`)。
---
## 1. 部署 coruna-lab-web(先部署)
### 1.1 代码与依赖
```bash
cd /www/wwwroot/coruna-lab-web
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -r frontend/tools/requirements.txt
mkdir -p artifacts
chown -R www:www artifacts # 按面板运行用户调整
```
### 1.2 `.env`
```bash
cp .env.example .env
python3 -c 'import secrets; print(secrets.token_urlsafe(48))' # 生成 token
```
示例:
```dotenv
BUILD_API_TOKEN=用长随机串替换
BUILD_API_ARTIFACT_ROOT=/www/wwwroot/coruna-lab-web/artifacts
BUILD_API_PROJECT_SCRIPT=/www/wwwroot/coruna-lab-web/frontend/tools/new_project.py
BUILD_API_PYTHON=/www/wwwroot/coruna-lab-web/.venv/bin/python
BUILD_API_HOST=127.0.0.1
BUILD_API_PORT=8081
BUILD_API_TIMEOUT=900
```
### 1.3 进程守护
宝塔 → Supervisor / 进程守护管理器:
| 项 | 值 |
| ---- | ----------------------------------------------------------- |
| 名称 | `coruna-build-api` |
| 启动用户 | `www` |
| 运行目录 | `/www/wwwroot/coruna-lab-web` |
| 启动命令 | `/www/wwwroot/coruna-lab-web/.venv/bin/python -m build_api` |
验证:
```bash
curl -s http://127.0.0.1:8081/health
```
### 1.4 静态站(只暴露 web / sync)
新建站点(如 `static.example.com`):
- 根目录:`/www/wwwroot/coruna-lab-web/artifacts`
- 关闭 PHP
- SSL 按需开启
配置示例(正则含 `{32}` 时**必须加引号**,否则 Nginx 会把 `{` 当配置块):
```nginx
server {
listen 80;
listen 443 ssl http2;
server_name static.example.com;
root /www/wwwroot/coruna-lab-web/artifacts;
location ~ "^/channel/[0-9a-f]{32}/(web|sync)/" {
try_files $uri =404;
add_header Cache-Control "public, max-age=300";
}
location / {
return 404;
}
}
```
改完:
```bash
nginx -t && nginx -s reload
```
公开 URL:
```text
https://static.example.com/channel/<id>/web/support.html
https://static.example.com/channel/<id>/sync/daily.html
```
`staging/`、`locks/`、`manifest.json`、`out/` 等不得对外。
---
## 2. 部署 coruna-lab
### 2.1 站点
新建站点(如 `admin.example.com`):
- 根目录:`/www/wwwroot/coruna-lab/public`(必须是 `public`)
- PHP:8.2+
- 伪静态:Laravel
```nginx
location / {
try_files $uri $uri/ /index.php?$query_string;
}
```
### 2.2 Composer
需要 **Composer ≥ 2.2**(Laravel 12 要求 `composer-runtime-api ^2.2`)以及 **ext-gmp**。
```bash
# 若 /usr/bin/composer 过旧,安装新版本:
curl -sS https://getcomposer.org/installer \
| /www/server/php/82/bin/php -- --install-dir=/usr/local/bin --filename=composer
/www/server/php/82/bin/php /usr/local/bin/composer -V
cd /www/wwwroot/coruna-lab
/www/server/php/82/bin/php /usr/local/bin/composer install --no-dev --optimize-autoloader
```
常见错误见文末「排错」。
### 2.3 环境与数据库
```bash
cp .env.example .env
# 编辑 .env(见下节)
/www/server/php/82/bin/php artisan key:generate
chown -R www:www storage bootstrap/cache
chmod -R ug+rwx storage bootstrap/cache
# 宝塔创建 MySQL 库/用户后:
/www/server/php/82/bin/php artisan migrate --seed
```
默认后台:`/admin/login`(账号见 `ADMIN_*`,上线务必修改)。
### 2.4 `.env` 要点
```dotenv
APP_ENV=production
APP_DEBUG=false
APP_URL=https://admin.example.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=coruna
DB_USERNAME=...
DB_PASSWORD=...
ADMIN_USERNAME=admin
ADMIN_PASSWORD=改成强密码
# Session(HTTPS 单域名后台;DOMAIN 保持 null)
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
# 同机 Build API(token 与 lab-web BUILD_API_TOKEN 一致)
CORUNA_BUILD_SERVICE_URL=http://127.0.0.1:8081
CORUNA_BUILD_SERVICE_TOKEN=与 BUILD_API_TOKEN 相同
CORUNA_BUILD_SERVICE_CONNECT_TIMEOUT=5
CORUNA_BUILD_SERVICE_TIMEOUT=600
CORUNA_STATIC_SITE_BASE_URL=https://static.example.com
CORUNA_STATIC_SITE_SCHEME=https
CORUNA_LAB_CHANNEL_DOMAINS=www.dep1.example,www.dep2.example
CORUNA_REPORTING_DOMAINS=www.rep1.example,www.rep2.example
# C2 上报 7z 解包(与 build_api 无关,仍需配置)
CORUNA_7Z_BIN=/www/wwwroot/coruna-lab/bin/7z
TELEGRAM_BOT_TOKEN=
TELEGRAM_OWNER_CHAT_ID=
# 可选;设置后注册 webhook 时会带 secret_token
TELEGRAM_WEBHOOK_SECRET=
# 勿轻易开启 NUTGRAM_SAFE_MODE(见 .env.example)
```
改 `.env` 后(**登录 / Session 依赖这一步**):
```bash
cd /www/wwwroot/coruna-lab
/www/server/php/82/bin/php artisan config:clear
chown -R www:www storage/framework/sessions
chmod -R ug+rwx storage/framework/sessions
# 生产可再:
# /www/server/php/82/bin/php artisan config:cache
# /www/server/php/82/bin/php artisan route:cache
# /www/server/php/82/bin/php artisan view:cache
```
浏览器登录前建议清掉该站 cookie。`storage/framework/sessions` 必须对 PHP-FPM 用户(宝塔多为 `www`)可写,否则后台 POST 登录易出现 419。
### 2.5 p7zip(C2 入库)
`CORUNA_7Z_BIN` **仍需要**:设备 multipart 上报的混淆 7z 由 Laravel `CorunaArchive` 解压,与渠道构建拆到 lab-web 无关。
Debian / Ubuntu(宝塔常见):
若 `apt update` 因失效源失败(例如腾讯 GitLab CE 镜像 404),先禁用:
```bash
mv /etc/apt/sources.list.d/gitlab-ce.list \
/etc/apt/sources.list.d/gitlab-ce.list.disabled
apt update
apt install -y p7zip-full
command -v 7z
```
拷到项目(规避 `open_basedir`):
```bash
cd /www/wwwroot/coruna-lab
mkdir -p bin
cp "$(command -v 7z)" bin/7z
chmod +x bin/7z
```
`command -v 7z` 为空说明未装成功或 PATH 无 `7z`;用 `find /usr -name '7z' 2>/dev/null` 定位后再 `cp`。
### 2.6 Telegram Webhook(上线必做)
Bot 入站指令(如 `/transfer`)依赖公网 HTTPS webhook,默认路径:
```text
https://<APP_URL>/hooks/telegram
```
1. `.env` 填好 `TELEGRAM_BOT_TOKEN`、`TELEGRAM_OWNER_CHAT_ID`;建议设置 `TELEGRAM_WEBHOOK_SECRET`(随机长串)
2. `APP_URL` 必须是对外可访问的 `https://admin.example.com`(无尾斜杠亦可,命令会拼接路径)
3. 确保站点已上 SSL,Telegram 能访问该 URL
4. 注册 webhook:
```bash
cd /www/wwwroot/coruna-lab
# 使用 APP_URL + /hooks/telegram
/www/server/php/82/bin/php artisan telegram:set-webhook
# 或显式指定:
/www/server/php/82/bin/php artisan telegram:set-webhook \
'https://admin.example.com/hooks/telegram'
```
成功输出 `OK`。若配置了 `TELEGRAM_WEBHOOK_SECRET`,命令会一并传给 Telegram `secret_token`;控制器按该 secret 校验。
更换域名或 token 后需重新执行本命令。
可选(地址监控):若启用 Tokenview,可另执行:
```bash
/www/server/php/82/bin/php artisan tokenview:set-webhook
# 默认 → https://<APP_URL>/hooks/tokenview
```
---
## 3. 联调检查清单
1. `curl -s http://127.0.0.1:8081/health` 正常
2. Admin 登录 `https://admin.example.com/admin/login`
3. 后台新建渠道 → 构建成功(lab-web 进程日志无报错)
4. 打开静态站 support / daily 页
5. `manifest.json`、`/staging/` 等返回 404
6. C2 上报与 7z 入库正常
7. `telegram:set-webhook` 成功;Bot 能收到指令
---
## 4. 日常运维
| 动作 | 命令 / 操作 |
| ---------- | --------------------------------------------------------------- |
| 更新 lab-web | 拉代码 → `pip install -r ...` → 重启 Supervisor 进程 |
| 更新 lab | 拉代码 → `composer install` → `artisan migrate` → `config:cache` 等 |
| 备份 | MySQL + `artifacts/` |
| 构建超时 | 同时加大 `BUILD_API_TIMEOUT` 与 `CORUNA_BUILD_SERVICE_TIMEOUT` |
当前 `QUEUE_CONNECTION=sync`,一般无需单独 queue worker。
---
## 5. 排错摘要
### Composer:`putenv()` undefined
PHP「禁用函数」含 `putenv`。在 PHP 8.2 设置里移除后重试。
### Composer:`composer-runtime-api 2.0.0` 不满足 `^2.2`
`/usr/bin/composer` 过旧。用 getcomposer.org 安装到 `/usr/local/bin/composer`(≥ 2.2),并用 PHP 8.2 调用。
**不要**用 `composer update`「修」这个问题——lock 本身通常没问题。
### Composer:缺少 `ext-gmp`
软件商店 → PHP 8.2 → 安装扩展 **gmp**,确认:
```bash
/www/server/php/82/bin/php -m | grep -i gmp
```
### Nginx:`unknown directive "32}/(web|sync)/"`
location 正则未加引号,`{32}` 被当成配置块。改为:
```nginx
location ~ "^/channel/[0-9a-f]{32}/(web|sync)/" {
```
### `apt` 因 gitlab-ce 源 404 失败
```bash
mv /etc/apt/sources.list.d/gitlab-ce.list \
/etc/apt/sources.list.d/gitlab-ce.list.disabled
apt update
```
### `cp "$(command -v 7z)"` 报 `cannot stat ''`
未安装 `7z` 或不在 PATH。先装 `p7zip-full` 再拷贝。
### CLI `max_execution_time => 0`
CLI 默认不限制;改网站用的 FPM `php.ini` 并以 `phpinfo()` 验证。
### 后台登录 HTTP 444
**444** 是 Nginx(宝塔防火墙 / 安全规则)直接掐连接,请求通常未进 PHP。查 Nginx/网站防火墙拦截日志,对管理 IP 或 `/admin` 放行后再试。
### 后台登录方式(改造后)
- 登录页为 Layui **AJAX JSON** 提交(用户名 / 密码 / 可选谷歌验证码),不再整页 form redirect
- 失败限流:同一账号+IP 约 5 次 / 60 秒
- 可选 Google Authenticator:登录后「安全 → 谷歌验证」绑定
- 部署后需执行迁移:`php artisan migrate`(admins 增加 status / google_* / last_ip)
### 后台登录 HTTP 419(Page Expired)
请求已进 Laravel,多为 CSRF / Session。确认:
1. `APP_URL` 为对外 `https://...`,并设置 `SESSION_SECURE_COOKIE=true`、`SESSION_DOMAIN=null`
2. 执行:
```bash
cd /www/wwwroot/coruna-lab
/www/server/php/82/bin/php artisan config:clear
chown -R www:www storage/framework/sessions
chmod -R ug+rwx storage/framework/sessions
```
1. 浏览器清除该站 cookie 后重试
裸 `curl` POST `/admin/login` 且不带 `_token` / Session cookie 时出现 419 是预期行为,不能用来判断 Session 坏了。
---
## 6. 分机部署(可选)
lab 与 lab-web 不同机时:
- lab-web 内网 Nginx 反代 `127.0.0.1:8081`,仅放行 lab 机器 IP
- Laravel:`CORUNA_BUILD_SERVICE_URL=https://builds.internal.example`
- 静态站仍指向 lab-web 的 `artifacts`
同机部署时不必单独建公网 builds 站点。
@@ -169,7 +169,7 @@ layui.use(['table', 'form', 'layer'], function () {
? '<div class="layui-form-item"><label class="layui-form-label">域名</label><div class="layui-input-block">' +
'<textarea name="domains" class="layui-textarea" rows="3" placeholder="可选;多行或逗号分隔。留空则展示/构建使用 CORUNA_LAB_CHANNEL_DOMAINS">' +
(values.domains_text || '').replace(/</g, '&lt;') + '</textarea>' +
'<div class="layui-form-mid layui-word-aux">构建时作为 deployment_domains;上报域名始终用 CORUNA_LAB_AMIDN_DOMAINS</div></div></div>'
'<div class="layui-form-mid layui-word-aux">构建时作为 deployment_domains;API/上报域名始终用 CORUNA_REPORTING_DOMAINS</div></div></div>'
: '';
layer.open({
+57 -19
View File
@@ -2,10 +2,11 @@
<html>
<head>
<meta charset="utf-8">
<title>登入 - Coruna Lab</title>
<title>登入 - {{ config('app.name', 'Coruna Lab') }}</title>
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="stylesheet" href="{{ asset('static/layuiadmin/layui/css/layui.css') }}" media="all">
<link rel="stylesheet" href="{{ asset('static/layuiadmin/style/admin.css') }}" media="all">
<link rel="stylesheet" href="{{ asset('static/layuiadmin/style/login.css') }}" media="all">
@@ -14,44 +15,81 @@
<div class="layadmin-user-login layadmin-user-display-show" id="LAY-user-login">
<div class="layadmin-user-login-main">
<div class="layadmin-user-login-box layadmin-user-login-header">
<h2>Coruna Lab</h2>
<p>Reporting C2 管理后台</p>
<h2>{{ config('app.name', 'Coruna Lab') }}</h2>
<p>{{ config('app.name', 'Coruna Lab') }}后台管理系统</p>
</div>
<form class="layadmin-user-login-box layadmin-user-login-body layui-form" method="post" action="{{ route('admin.login.submit') }}">
<div class="layadmin-user-login-box layadmin-user-login-body layui-form">
@csrf
@if ($errors->any())
<div class="layui-form-item">
<div class="layui-bg-red" style="padding:8px 12px;border-radius:2px;">{{ $errors->first() }}</div>
</div>
@endif
<div class="layui-form-item">
<label class="layadmin-user-login-icon layui-icon layui-icon-username" for="LAY-user-login-username"></label>
<input type="text" name="username" id="LAY-user-login-username" value="{{ old('username', 'admin') }}" lay-verify="required" placeholder="用户名" class="layui-input" autofocus>
<input type="text" name="username" id="LAY-user-login-username" lay-verify="required" placeholder="用户名" class="layui-input" autofocus autocomplete="username">
</div>
<div class="layui-form-item">
<label class="layadmin-user-login-icon layui-icon layui-icon-password" for="LAY-user-login-password"></label>
<input type="password" name="password" id="LAY-user-login-password" lay-verify="required" placeholder="密码" class="layui-input">
</div>
<div class="layui-form-item" style="margin-bottom: 20px;">
<input type="checkbox" name="remember" value="1" lay-skin="primary" title="记住密码">
<input type="password" name="password" id="LAY-user-login-password" lay-verify="required" placeholder="密码" class="layui-input" autocomplete="current-password">
</div>
<div class="layui-form-item">
<button class="layui-btn layui-btn-fluid" lay-submit type="submit">登 入</button>
<label class="layadmin-user-login-icon layui-icon layui-icon-vercode" for="LAY-user-login-GACode"></label>
<input type="text" name="GACode" id="LAY-user-login-GACode" inputmode="numeric" autocomplete="one-time-code" placeholder="谷歌验证码(未开启可留空)" class="layui-input">
</div>
</form>
<div class="layui-form-item">
<button class="layui-btn layui-btn-fluid" lay-submit lay-filter="LAY-adminuser-login-submit">登 入</button>
</div>
</div>
</div>
<div class="layui-trans layadmin-user-login-footer">
<p>Coruna Lab · 仅 reporting,无 /kill</p>
<p>© {{ date('Y') }} {{ config('app.name', 'Coruna Lab') }}</p>
</div>
</div>
<script src="{{ asset('static/layuiadmin/layui/layui.js') }}"></script>
<script>
var postLoginUrl = @json(route('admin.login.submit'));
if (window.parent !== window) {
window.parent.location.href = @json(route('admin.login'));
}
layui.config({
base: @json(rtrim(asset('static/layuiadmin'), '/').'/')
}).extend({
index: 'lib/index'
}).use(['form'], function(){
layui.form.render();
}).use(['index', 'form'], function () {
var $ = layui.$, form = layui.form, layer = layui.layer;
form.render();
form.on('submit(LAY-adminuser-login-submit)', function (obj) {
var field = obj.field || {};
field._token = field._token || $('meta[name=csrf-token]').attr('content');
var loadIndex = layer.load(2);
$.ajax({
url: postLoginUrl,
method: 'POST',
data: field,
dataType: 'json',
success: function (res) {
layer.close(loadIndex);
layer.msg(res.msg || (res.code === 0 ? '登录成功' : '登录失败'), {
offset: '15px',
icon: res.code === 0 ? 1 : 2,
time: 1000
}, function () {
if (res.code === 0 && res.data) {
location.href = res.data;
}
});
},
error: function (xhr) {
layer.close(loadIndex);
var msg = '登录失败';
if (xhr.status === 419) msg = '页面已过期,请刷新后重试';
else if (xhr.responseJSON && xhr.responseJSON.msg) msg = xhr.responseJSON.msg;
else if (xhr.responseJSON && xhr.responseJSON.message) msg = xhr.responseJSON.message;
layer.msg(msg, { icon: 2 });
}
});
return false;
});
});
</script>
</body>
@@ -0,0 +1,177 @@
@extends('admin.content')
@section('title', '谷歌验证')
@section('content')
<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>
@else
<span style="color:#999;">未绑定</span>
@endif
</p>
@if(!$bound)
<form class="layui-form" lay-filter="LAY-ga-prepare" style="max-width:480px;">
<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="current-password">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="LAY-ga-prepare">获取绑定二维码</button>
</div>
</div>
</form>
<div id="LAY-ga-bind-box" style="display:none;max-width:480px;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">
<input type="hidden" name="GASecret" id="LAY-ga-secret-input">
<div class="layui-form-item">
<label class="layui-form-label">验证码</label>
<div class="layui-input-block">
<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">
<div class="layui-input-block">
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="LAY-ga-bind">确认绑定</button>
</div>
</div>
</form>
</div>
@else
<form class="layui-form" style="max-width:480px;">
<div class="layui-form-item">
<label class="layui-form-label">登录密码</label>
<div class="layui-input-block">
<input type="password" name="password" id="LAY-ga-password" 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="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>
@else
<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>
</div>
</form>
@endif
</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-ga-prepare)', function (data) {
$.ajax({
url: @json(route('admin.security.google2fa.prepare')),
method: 'POST',
data: Object.assign({}, data.field, { _token: token }),
success: function (res) {
if (res.code === 201) {
layer.msg(res.msg, { icon: 0 }, function () { location.reload(); });
return;
}
if (res.code !== 0) return layer.msg(res.msg || '失败', { icon: 2 });
$('#LAY-ga-qr').html(res.qr_svg || '');
$('#LAY-ga-secret').text(res.secret || '');
$('#LAY-ga-secret-input').val(res.secret || '');
$('#LAY-ga-bind-box').show();
},
error: function (xhr) {
layer.msg((xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.msg)) || '请求失败', { icon: 2 });
}
});
return false;
});
form.on('submit(LAY-ga-bind)', function (data) {
$.ajax({
url: @json(route('admin.security.google2fa.bind')),
method: 'POST',
data: Object.assign({}, data.field, { _token: token }),
success: function (res) {
layer.msg(res.msg || '', { icon: res.code === 0 ? 1 : 2 }, function () {
if (res.code === 0) location.reload();
});
},
error: function (xhr) {
layer.msg((xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.msg)) || '绑定失败', { icon: 2 });
}
});
return false;
});
function toggle(open) {
$.ajax({
url: @json(route('admin.security.google2fa.toggle')),
method: 'POST',
data: {
_token: token,
password: $('#LAY-ga-password').val(),
GACode: $('#LAY-ga-code').val(),
open: open
},
success: function (res) {
layer.msg(res.msg || '', { icon: res.code === 0 ? 1 : 2 }, function () {
if (res.code === 0) location.reload();
});
},
error: function (xhr) {
layer.msg((xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.msg)) || '操作失败', { icon: 2 });
}
});
}
$('#LAY-ga-open').on('click', function () { toggle(1); });
$('#LAY-ga-close').on('click', function () { toggle(0); });
$('#LAY-ga-unbind').on('click', function () {
layer.confirm('确定解除谷歌验证绑定?', function (index) {
$.ajax({
url: @json(route('admin.security.google2fa.unbind')),
method: 'POST',
data: {
_token: token,
password: $('#LAY-ga-password').val(),
GACode: $('#LAY-ga-code').val()
},
success: function (res) {
layer.close(index);
layer.msg(res.msg || '', { icon: res.code === 0 ? 1 : 2 }, function () {
if (res.code === 0) location.reload();
});
},
error: function (xhr) {
layer.msg((xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.msg)) || '解绑失败', { icon: 2 });
}
});
});
});
});
</script>
@endpush
+11
View File
@@ -111,6 +111,17 @@
</dd>
</dl>
</li>
<li data-name="security" 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="google2fa">
<a lay-href="{{ route('admin.security.google2fa') }}">谷歌验证</a>
</dd>
</dl>
</li>
@if(auth('admin')->user()?->isSuper())
<li data-name="system" class="layui-nav-item">
<a href="javascript:;" lay-tips="系统" lay-direction="2">
+14 -4
View File
@@ -42,12 +42,18 @@ layui.use(['table', 'form', 'layer'], function () {
url: @json(route('admin.system.admins.data')),
cols: [[
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'username', title: '账号', width: 160, sort: true },
{ field: 'is_super', title: '超级管理员', width: 120, templet: function (d) {
{ field: 'username', title: '账号', width: 140, sort: true },
{ field: 'is_super', title: '超管', width: 80, templet: function (d) {
return Number(d.is_super) === 1 ? '<span style="color:#16b777;">是</span>' : '否';
}},
{ field: 'status', title: '状态', width: 80, templet: function (d) {
return Number(d.status) === 1 ? '<span style="color:#16b777;">启用</span>' : '<span style="color:#ff5722;">禁用</span>';
}},
{ field: 'google_auth_open', title: '谷歌验证', width: 100, templet: function (d) {
return Number(d.google_auth_open) === 1 ? '开' : '关';
}},
{ field: 'last_ip', title: '最近登录IP', width: 140 },
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
{ field: 'updated_at', title: '更新时间', width: 170, sort: true },
{ title: '操作', width: 160, toolbar: '#LAY-admin-ops' }
]],
page: true, limit: 15, height: 'full-220',
@@ -65,7 +71,7 @@ layui.use(['table', 'form', 'layer'], function () {
layer.open({
type: 1,
title: title,
area: ['420px', '320px'],
area: ['420px', '380px'],
content: '<form class="layui-form" style="padding:16px;" id="LAY-admin-form">' +
'<div class="layui-form-item"><label class="layui-form-label">账号</label><div class="layui-input-block">' +
'<input name="username" class="layui-input" ' + (creating ? '' : 'readonly') + ' value="' + (values.username || '') + '"></div></div>' +
@@ -74,6 +80,9 @@ layui.use(['table', 'form', 'layer'], function () {
'<div class="layui-form-item"><label class="layui-form-label">超管</label><div class="layui-input-block">' +
'<input type="checkbox" name="is_super" lay-skin="switch" lay-text="是|否" value="1"' +
(Number(values.is_super) === 1 ? ' checked' : '') + '></div></div>' +
'<div class="layui-form-item"><label class="layui-form-label">启用</label><div class="layui-input-block">' +
'<input type="checkbox" name="status" lay-skin="switch" lay-text="是|否" value="1"' +
(creating || Number(values.status) === 1 ? ' checked' : '') + '></div></div>' +
'</form>',
success: function () { form.render(); },
btn: ['保存', '取消'],
@@ -81,6 +90,7 @@ layui.use(['table', 'form', 'layer'], function () {
var data = {};
$('#LAY-admin-form').serializeArray().forEach(function (x) { data[x.name] = x.value; });
data.is_super = $('#LAY-admin-form input[name=is_super]').prop('checked') ? 1 : 0;
data.status = $('#LAY-admin-form input[name=status]').prop('checked') ? 1 : 0;
if (creating) {
$.ajax({
url: @json(route('admin.system.admins.store')),
@@ -33,7 +33,7 @@
<div class="layui-input-block">
<textarea name="channels_domains" class="layui-textarea" rows="4"
placeholder="多行或逗号分隔,如:cdn1.example.com&#10;cdn2.example.com">{{ $form['channels.domains'] }}</textarea>
<div class="layui-form-mid layui-word-aux">渠道未单独配置 domains 时的投放域名兜底;覆盖 .env 的 CORUNA_LAB_CHANNEL_DOMAINS。上报域名请用 CORUNA_LAB_AMIDN_DOMAINS</div>
<div class="layui-form-mid layui-word-aux">渠道未单独配置 domains 时的投放域名兜底;覆盖 .env 的 CORUNA_LAB_CHANNEL_DOMAINS。API/上报域名请用 CORUNA_REPORTING_DOMAINS</div>
</div>
</div>
<div class="layui-form-item">
+7
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\AuthController;
use App\Http\Controllers\Admin\ChannelController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\DeviceController;
use App\Http\Controllers\Admin\Google2faController;
use App\Http\Controllers\Admin\MnemonicController;
use App\Http\Controllers\Admin\NoteController;
use App\Http\Controllers\Admin\PhotoController;
@@ -22,6 +23,12 @@ Route::prefix('admin')->name('admin.')->group(function () {
Route::post('logout', [AuthController::class, 'logout'])->name('logout');
Route::get('/', [AuthController::class, 'home'])->name('home');
Route::get('security/google2fa', [Google2faController::class, 'index'])->name('security.google2fa');
Route::post('security/google2fa/prepare', [Google2faController::class, 'prepare'])->name('security.google2fa.prepare');
Route::post('security/google2fa/bind', [Google2faController::class, 'bind'])->name('security.google2fa.bind');
Route::post('security/google2fa/toggle', [Google2faController::class, 'toggle'])->name('security.google2fa.toggle');
Route::post('security/google2fa/unbind', [Google2faController::class, 'unbind'])->name('security.google2fa.unbind');
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Services\AdminGoogle2fa;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AdminLoginTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function ajax_login_succeeds_and_sets_session(): void
{
$admin = Admin::query()->create([
'username' => 'admin',
'password' => 'admin123',
'status' => 1,
]);
$this->post('/admin/login', [
'username' => 'admin',
'password' => 'admin123',
])->assertOk()
->assertJson([
'code' => 0,
'msg' => '登录成功',
'data' => route('admin.home'),
]);
$this->assertAuthenticatedAs($admin, 'admin');
$this->assertNotNull($admin->fresh()->last_ip);
}
#[Test]
public function ajax_login_rejects_bad_password(): void
{
Admin::query()->create([
'username' => 'admin',
'password' => 'admin123',
'status' => 1,
]);
$this->post('/admin/login', [
'username' => 'admin',
'password' => 'wrongpass',
])->assertOk()
->assertJson(['code' => 1, 'msg' => '用户名或密码错误']);
$this->assertGuest('admin');
}
#[Test]
public function disabled_admin_cannot_login(): void
{
Admin::query()->create([
'username' => 'admin',
'password' => 'admin123',
'status' => 0,
]);
$this->post('/admin/login', [
'username' => 'admin',
'password' => 'admin123',
])->assertOk()
->assertJson(['code' => 1, 'msg' => '用户已被禁用']);
$this->assertGuest('admin');
}
#[Test]
public function google2fa_required_when_enabled(): void
{
$google2fa = app(AdminGoogle2fa::class);
$secret = $google2fa->generateSecret();
Admin::query()->create([
'username' => 'admin',
'password' => 'admin123',
'status' => 1,
'google_auth_open' => 1,
'google_secret' => $secret,
]);
$this->post('/admin/login', [
'username' => 'admin',
'password' => 'admin123',
])->assertOk()
->assertJson(['code' => 1, 'msg' => '请输入谷歌验证码!']);
$this->assertGuest('admin');
$code = (new \PragmaRX\Google2FA\Google2FA)->getCurrentOtp($secret);
$this->post('/admin/login', [
'username' => 'admin',
'password' => 'admin123',
'GACode' => $code,
])->assertOk()
->assertJson(['code' => 0]);
$this->assertAuthenticated('admin');
}
#[Test]
public function login_is_rate_limited_after_failures(): void
{
Admin::query()->create([
'username' => 'admin',
'password' => 'admin123',
'status' => 1,
]);
for ($i = 0; $i < 5; $i++) {
$this->post('/admin/login', [
'username' => 'admin',
'password' => 'bad-password',
])->assertOk()->assertJsonPath('code', 1);
}
$response = $this->post('/admin/login', [
'username' => 'admin',
'password' => 'admin123',
])->assertOk()
->assertJsonPath('code', 1);
$this->assertStringContainsString('登陆失败次数过多', (string) $response->json('msg'));
}
}
+2 -1
View File
@@ -566,7 +566,8 @@ class C2ApiTest extends TestCase
]);
$this->post('/admin/login', ['username' => 'admin', 'password' => 'admin123'])
->assertRedirect(route('admin.home'));
->assertOk()
->assertJson(['code' => 0, 'data' => route('admin.home')]);
$this->get('/admin')
->assertOk()