admin
This commit is contained in:
+3
-6
@@ -46,14 +46,11 @@ ADMIN_PASSWORD=admin123
|
|||||||
|
|
||||||
# Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0.
|
# Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0.
|
||||||
CORUNA_SESSION_KEY=
|
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 系统设置 → 投放域名.
|
# Overridable by 系统设置 → 投放域名.
|
||||||
CORUNA_LAB_CHANNEL_DOMAINS=
|
CORUNA_LAB_CHANNEL_DOMAINS=
|
||||||
# Builder reporting_domains (always from env; not overridden by channel.domains).
|
# API / reporting domains for the builder (always from env; not overridden by channel.domains).
|
||||||
CORUNA_LAB_AMIDN_DOMAINS=
|
CORUNA_REPORTING_DOMAINS=
|
||||||
# Legacy aliases (optional):
|
|
||||||
# CORUNA_DEPLOYMENT_DOMAINS= / CORUNA_CHANNEL_DOMAINS= → CORUNA_LAB_CHANNEL_DOMAINS
|
|
||||||
# CORUNA_REPORTING_DOMAINS= → CORUNA_LAB_AMIDN_DOMAINS
|
|
||||||
# Sibling project coruna-lab-web build API (Bearer authentication)
|
# Sibling project coruna-lab-web build API (Bearer authentication)
|
||||||
CORUNA_BUILD_SERVICE_URL=http://127.0.0.1:8081
|
CORUNA_BUILD_SERVICE_URL=http://127.0.0.1:8081
|
||||||
CORUNA_BUILD_SERVICE_TOKEN=
|
CORUNA_BUILD_SERVICE_TOKEN=
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ php artisan serve --host=0.0.0.0 --port=8000
|
|||||||
Admin:
|
Admin:
|
||||||
|
|
||||||
- Shell: `http://127.0.0.1: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_CONNECT_TIMEOUT=5
|
||||||
CORUNA_BUILD_SERVICE_TIMEOUT=600
|
CORUNA_BUILD_SERVICE_TIMEOUT=600
|
||||||
CORUNA_LAB_CHANNEL_DOMAINS=www.dep1.example,www.dep2.example
|
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
|
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`
|
构建:`POST /v1/channels/{id}/build`
|
||||||
删除:`DELETE /v1/channels/{id}`
|
删除:`DELETE /v1/channels/{id}`
|
||||||
@@ -65,6 +67,7 @@ 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 说明
|
## C2 / Admin 说明
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class AdminUserController extends Controller
|
|||||||
$q->where('username', 'like', '%'.$username.'%');
|
$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');
|
$field = (string) $request->query('field', 'id');
|
||||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
@@ -40,6 +40,9 @@ class AdminUserController extends Controller
|
|||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'username' => $a->username,
|
'username' => $a->username,
|
||||||
'is_super' => (int) $a->is_super,
|
'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'),
|
'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'),
|
'updated_at' => optional($a->updated_at)->format('Y-m-d H:i:s'),
|
||||||
'is_self' => $a->id === $selfId,
|
'is_self' => $a->id === $selfId,
|
||||||
@@ -60,12 +63,14 @@ class AdminUserController extends Controller
|
|||||||
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('admins', 'username')],
|
'username' => ['required', 'string', 'max:64', 'alpha_dash', Rule::unique('admins', 'username')],
|
||||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||||
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
|
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$admin = Admin::query()->create([
|
$admin = Admin::query()->create([
|
||||||
'username' => $data['username'],
|
'username' => $data['username'],
|
||||||
'password' => $data['password'],
|
'password' => $data['password'],
|
||||||
'is_super' => (int) ($data['is_super'] ?? 0),
|
'is_super' => (int) ($data['is_super'] ?? 0),
|
||||||
|
'status' => (int) ($data['status'] ?? 1),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $admin->id]]);
|
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $admin->id]]);
|
||||||
@@ -76,6 +81,7 @@ class AdminUserController extends Controller
|
|||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||||
'is_super' => ['nullable', 'integer', Rule::in([0, 1])],
|
'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) {
|
if (array_key_exists('is_super', $data) && $data['is_super'] !== null) {
|
||||||
@@ -86,6 +92,13 @@ class AdminUserController extends Controller
|
|||||||
$adminUser->is_super = $newSuper;
|
$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'])) {
|
if (! empty($data['password'])) {
|
||||||
$adminUser->password = $data['password'];
|
$adminUser->password = $data['password'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,21 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Services\AdminGoogle2fa;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
private const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
private const DECAY_SECONDS = 60;
|
||||||
|
|
||||||
public function showLogin()
|
public function showLogin()
|
||||||
{
|
{
|
||||||
if (Auth::guard('admin')->check()) {
|
if (Auth::guard('admin')->check()) {
|
||||||
@@ -22,23 +32,78 @@ class AuthController extends Controller
|
|||||||
return view('admin.shell');
|
return view('admin.shell');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function login(Request $request)
|
public function login(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
$credentials = $request->validate([
|
try {
|
||||||
'username' => 'required|string',
|
$credentials = $request->validate([
|
||||||
'password' => 'required|string',
|
'username' => 'required|string|min:2|max:64',
|
||||||
]);
|
'password' => 'required|string|min:6|max:128',
|
||||||
|
'GACode' => 'nullable|string|max:16',
|
||||||
if (Auth::guard('admin')->attempt(
|
], [
|
||||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
'username.required' => '请输入用户名',
|
||||||
$request->boolean('remember')
|
'password.required' => '请输入密码',
|
||||||
)) {
|
]);
|
||||||
$request->session()->regenerate();
|
} catch (ValidationException $e) {
|
||||||
|
return response()->json([
|
||||||
return redirect()->intended(route('admin.home'));
|
'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)
|
public function logout(Request $request)
|
||||||
@@ -49,4 +114,9 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
return redirect()->route('admin.login');
|
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
@@ -6,15 +6,25 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||||||
|
|
||||||
class Admin extends 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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'is_super' => 'integer',
|
'is_super' => 'integer',
|
||||||
|
'status' => 'integer',
|
||||||
|
'google_auth_open' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,4 +32,9 @@ class Admin extends Authenticatable
|
|||||||
{
|
{
|
||||||
return (int) $this->is_super === 1;
|
return (int) $this->is_super === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return (int) $this->status === 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Channel row wins; otherwise CORUNA_LAB_CHANNEL_DOMAINS.
|
||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ class ChannelProjectService
|
|||||||
throw new RuntimeException('投放域名未配置(CORUNA_LAB_CHANNEL_DOMAINS 或渠道 domains)');
|
throw new RuntimeException('投放域名未配置(CORUNA_LAB_CHANNEL_DOMAINS 或渠道 domains)');
|
||||||
}
|
}
|
||||||
if ($reportingDomains === []) {
|
if ($reportingDomains === []) {
|
||||||
throw new RuntimeException('上报域名未配置(CORUNA_LAB_AMIDN_DOMAINS)');
|
throw new RuntimeException('上报域名未配置(CORUNA_REPORTING_DOMAINS)');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->request('post', $channelId, [
|
$response = $this->request('post', $channelId, [
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class SettingsService
|
|||||||
|
|
||||||
if (array_key_exists('channels.domains', $map) && $map['channels.domains'] !== null) {
|
if (array_key_exists('channels.domains', $map) && $map['channels.domains'] !== null) {
|
||||||
$domains = self::parseDomains($map['channels.domains']);
|
$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([
|
config([
|
||||||
'coruna.channel_domains' => $domains,
|
'coruna.channel_domains' => $domains,
|
||||||
'coruna.deployment_domains' => $domains,
|
'coruna.deployment_domains' => $domains,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"bacon/bacon-qr-code": "^3.1",
|
||||||
"furqansiddiqui/bip39-mnemonic-php": "^0.1.7",
|
"furqansiddiqui/bip39-mnemonic-php": "^0.1.7",
|
||||||
"guzzlehttp/guzzle": "^7.15",
|
"guzzlehttp/guzzle": "^7.15",
|
||||||
"kornrunner/keccak": "^1.1",
|
"kornrunner/keccak": "^1.1",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"nutgram/laravel": "^1.7",
|
"nutgram/laravel": "^1.7",
|
||||||
"nutgram/nutgram": "^4.49",
|
"nutgram/nutgram": "^4.49",
|
||||||
|
"pragmarx/google2fa": "^9.0",
|
||||||
"simplito/elliptic-php": "^1.0"
|
"simplito/elliptic-php": "^1.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
Generated
+227
-1
@@ -4,8 +4,63 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "3a1612bc4d46f2bcff8904370145b15f",
|
"content-hash": "2a3e9276f4cbc867f173d43e2742ffd3",
|
||||||
"packages": [
|
"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",
|
"name": "brick/math",
|
||||||
"version": "0.14.8",
|
"version": "0.14.8",
|
||||||
@@ -135,6 +190,56 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"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",
|
"name": "dflydev/dot-access-data",
|
||||||
"version": "v3.0.3",
|
"version": "v3.0.3",
|
||||||
@@ -2856,6 +2961,75 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-07-24T22:11:53+00:00"
|
"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",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
@@ -2931,6 +3105,58 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-27T19:41:33+00:00"
|
"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",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
+7
-13
@@ -4,24 +4,18 @@ $parseDomains = static fn (string $value): array => array_values(array_filter(ar
|
|||||||
'trim',
|
'trim',
|
||||||
preg_split('/[\s,;]+/', $value) ?: []
|
preg_split('/[\s,;]+/', $value) ?: []
|
||||||
)));
|
)));
|
||||||
// Channel / deployment hosts (link display fallback + builder deployment_domains).
|
// Channel hosts (link display fallback + builder deployment_domains).
|
||||||
$channelDomains = $parseDomains((string) env(
|
$channelDomains = $parseDomains((string) env('CORUNA_LAB_CHANNEL_DOMAINS', ''));
|
||||||
'CORUNA_LAB_CHANNEL_DOMAINS',
|
// API / reporting hosts passed to lab-web as reporting_domains.
|
||||||
env('CORUNA_DEPLOYMENT_DOMAINS', env('CORUNA_CHANNEL_DOMAINS', ''))
|
$reportingDomains = $parseDomains((string) env('CORUNA_REPORTING_DOMAINS', ''));
|
||||||
));
|
|
||||||
// Reporting hosts passed to lab-web as reporting_domains.
|
|
||||||
$adminDomains = $parseDomains((string) env(
|
|
||||||
'CORUNA_LAB_AMIDN_DOMAINS',
|
|
||||||
env('CORUNA_REPORTING_DOMAINS', '')
|
|
||||||
));
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
|
'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,
|
'channel_domains' => $channelDomains,
|
||||||
'deployment_domains' => $channelDomains,
|
'deployment_domains' => $channelDomains,
|
||||||
// reporting_domains: CORUNA_LAB_AMIDN_DOMAINS (+ legacy CORUNA_REPORTING_DOMAINS).
|
// CORUNA_REPORTING_DOMAINS
|
||||||
'reporting_domains' => $adminDomains,
|
'reporting_domains' => $reportingDomains,
|
||||||
'static_site' => [
|
'static_site' => [
|
||||||
// Optional complete origin/base path prepended before /channel/{id}/web/support.html.
|
// Optional complete origin/base path prepended before /channel/{id}/web/support.html.
|
||||||
'base_url' => rtrim((string) env('CORUNA_STATIC_SITE_BASE_URL', ''), '/'),
|
'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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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">' +
|
? '<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">' +
|
'<textarea name="domains" class="layui-textarea" rows="3" placeholder="可选;多行或逗号分隔。留空则展示/构建使用 CORUNA_LAB_CHANNEL_DOMAINS">' +
|
||||||
(values.domains_text || '').replace(/</g, '<') + '</textarea>' +
|
(values.domains_text || '').replace(/</g, '<') + '</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({
|
layer.open({
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>登入 - Coruna Lab</title>
|
<title>登入 - {{ config('app.name', 'Coruna Lab') }}</title>
|
||||||
<meta name="renderer" content="webkit">
|
<meta name="renderer" content="webkit">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
<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="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/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/admin.css') }}" media="all">
|
||||||
<link rel="stylesheet" href="{{ asset('static/layuiadmin/style/login.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 layadmin-user-display-show" id="LAY-user-login">
|
||||||
<div class="layadmin-user-login-main">
|
<div class="layadmin-user-login-main">
|
||||||
<div class="layadmin-user-login-box layadmin-user-login-header">
|
<div class="layadmin-user-login-box layadmin-user-login-header">
|
||||||
<h2>Coruna Lab</h2>
|
<h2>{{ config('app.name', 'Coruna Lab') }}</h2>
|
||||||
<p>Reporting C2 管理后台</p>
|
<p>{{ config('app.name', 'Coruna Lab') }}后台管理系统</p>
|
||||||
</div>
|
</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
|
@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">
|
<div class="layui-form-item">
|
||||||
<label class="layadmin-user-login-icon layui-icon layui-icon-username" for="LAY-user-login-username"></label>
|
<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>
|
||||||
<div class="layui-form-item">
|
<div class="layui-form-item">
|
||||||
<label class="layadmin-user-login-icon layui-icon layui-icon-password" for="LAY-user-login-password"></label>
|
<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">
|
<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" style="margin-bottom: 20px;">
|
|
||||||
<input type="checkbox" name="remember" value="1" lay-skin="primary" title="记住密码">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="layui-form-item">
|
<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>
|
</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>
|
||||||
<div class="layui-trans layadmin-user-login-footer">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ asset('static/layuiadmin/layui/layui.js') }}"></script>
|
<script src="{{ asset('static/layuiadmin/layui/layui.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
var postLoginUrl = @json(route('admin.login.submit'));
|
||||||
|
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.location.href = @json(route('admin.login'));
|
||||||
|
}
|
||||||
|
|
||||||
layui.config({
|
layui.config({
|
||||||
base: @json(rtrim(asset('static/layuiadmin'), '/').'/')
|
base: @json(rtrim(asset('static/layuiadmin'), '/').'/')
|
||||||
}).extend({
|
}).extend({
|
||||||
index: 'lib/index'
|
index: 'lib/index'
|
||||||
}).use(['form'], function(){
|
}).use(['index', 'form'], function () {
|
||||||
layui.form.render();
|
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>
|
</script>
|
||||||
</body>
|
</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
|
||||||
@@ -111,6 +111,17 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</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())
|
@if(auth('admin')->user()?->isSuper())
|
||||||
<li data-name="system" class="layui-nav-item">
|
<li data-name="system" class="layui-nav-item">
|
||||||
<a href="javascript:;" lay-tips="系统" lay-direction="2">
|
<a href="javascript:;" lay-tips="系统" lay-direction="2">
|
||||||
|
|||||||
@@ -42,12 +42,18 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
url: @json(route('admin.system.admins.data')),
|
url: @json(route('admin.system.admins.data')),
|
||||||
cols: [[
|
cols: [[
|
||||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||||
{ field: 'username', title: '账号', width: 160, sort: true },
|
{ field: 'username', title: '账号', width: 140, sort: true },
|
||||||
{ field: 'is_super', title: '超级管理员', width: 120, templet: function (d) {
|
{ field: 'is_super', title: '超管', width: 80, templet: function (d) {
|
||||||
return Number(d.is_super) === 1 ? '<span style="color:#16b777;">是</span>' : '否';
|
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: 'created_at', title: '创建时间', width: 170, sort: true },
|
||||||
{ field: 'updated_at', title: '更新时间', width: 170, sort: true },
|
|
||||||
{ title: '操作', width: 160, toolbar: '#LAY-admin-ops' }
|
{ title: '操作', width: 160, toolbar: '#LAY-admin-ops' }
|
||||||
]],
|
]],
|
||||||
page: true, limit: 15, height: 'full-220',
|
page: true, limit: 15, height: 'full-220',
|
||||||
@@ -65,7 +71,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
layer.open({
|
layer.open({
|
||||||
type: 1,
|
type: 1,
|
||||||
title: title,
|
title: title,
|
||||||
area: ['420px', '320px'],
|
area: ['420px', '380px'],
|
||||||
content: '<form class="layui-form" style="padding:16px;" id="LAY-admin-form">' +
|
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">' +
|
'<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>' +
|
'<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">' +
|
'<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"' +
|
'<input type="checkbox" name="is_super" lay-skin="switch" lay-text="是|否" value="1"' +
|
||||||
(Number(values.is_super) === 1 ? ' checked' : '') + '></div></div>' +
|
(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>',
|
'</form>',
|
||||||
success: function () { form.render(); },
|
success: function () { form.render(); },
|
||||||
btn: ['保存', '取消'],
|
btn: ['保存', '取消'],
|
||||||
@@ -81,6 +90,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
var data = {};
|
var data = {};
|
||||||
$('#LAY-admin-form').serializeArray().forEach(function (x) { data[x.name] = x.value; });
|
$('#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.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) {
|
if (creating) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: @json(route('admin.system.admins.store')),
|
url: @json(route('admin.system.admins.store')),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="layui-input-block">
|
<div class="layui-input-block">
|
||||||
<textarea name="channels_domains" class="layui-textarea" rows="4"
|
<textarea name="channels_domains" class="layui-textarea" rows="4"
|
||||||
placeholder="多行或逗号分隔,如:cdn1.example.com cdn2.example.com">{{ $form['channels.domains'] }}</textarea>
|
placeholder="多行或逗号分隔,如:cdn1.example.com 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>
|
</div>
|
||||||
<div class="layui-form-item">
|
<div class="layui-form-item">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\AuthController;
|
|||||||
use App\Http\Controllers\Admin\ChannelController;
|
use App\Http\Controllers\Admin\ChannelController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Admin\DeviceController;
|
use App\Http\Controllers\Admin\DeviceController;
|
||||||
|
use App\Http\Controllers\Admin\Google2faController;
|
||||||
use App\Http\Controllers\Admin\MnemonicController;
|
use App\Http\Controllers\Admin\MnemonicController;
|
||||||
use App\Http\Controllers\Admin\NoteController;
|
use App\Http\Controllers\Admin\NoteController;
|
||||||
use App\Http\Controllers\Admin\PhotoController;
|
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::post('logout', [AuthController::class, 'logout'])->name('logout');
|
||||||
Route::get('/', [AuthController::class, 'home'])->name('home');
|
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', [DashboardController::class, 'index'])->name('dashboard.index');
|
||||||
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -566,7 +566,8 @@ class C2ApiTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->post('/admin/login', ['username' => 'admin', 'password' => 'admin123'])
|
$this->post('/admin/login', ['username' => 'admin', 'password' => 'admin123'])
|
||||||
->assertRedirect(route('admin.home'));
|
->assertOk()
|
||||||
|
->assertJson(['code' => 0, 'data' => route('admin.home')]);
|
||||||
|
|
||||||
$this->get('/admin')
|
$this->get('/admin')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
|||||||
Reference in New Issue
Block a user