fix(rbac,honeypot): gate settings/release surfaces + per-install canaries, no reflected/auth-user honeypot bans
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
e58d1a4b07
commit
e602320c9d
|
|
@ -110,6 +110,11 @@ UPDATE_HMAC_KEY=
|
||||||
# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates
|
# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates
|
||||||
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).
|
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).
|
||||||
TERMINAL_SIDECAR_SECRET=
|
TERMINAL_SIDECAR_SECRET=
|
||||||
|
# Per-install RANDOM honeypot canary values embedded in the served fake .env. install.sh generates
|
||||||
|
# them; leave empty to DISABLE honeytoken detection (the deception layer still serves plausible bait).
|
||||||
|
CLUSEV_HONEYPOT_CANARY_APP=
|
||||||
|
CLUSEV_HONEYPOT_CANARY_DB=
|
||||||
|
CLUSEV_HONEYPOT_CANARY_API=
|
||||||
|
|
||||||
# ── Optional demo server (seeded by FleetSeeder) ─────────────────────
|
# ── Optional demo server (seeded by FleetSeeder) ─────────────────────
|
||||||
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,
|
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ class HoneypotController extends Controller
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An AUTHENTICATED user hitting a decoy is a stray request, NOT an attacker: behave like an
|
||||||
|
// ordinary 404 (no ban, no deception, no audit) so a logged-in operator/admin never self-bans.
|
||||||
|
if (auth()->check()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$ip = (string) $request->ip();
|
$ip = (string) $request->ip();
|
||||||
|
|
||||||
AuditEvent::create([
|
AuditEvent::create([
|
||||||
|
|
@ -54,6 +60,10 @@ class HoneypotController extends Controller
|
||||||
return response($this->fakeDotenv(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
return response($this->fakeDotenv(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($path === '.git/config') {
|
||||||
|
return response($this->fakeGitConfig(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||||
|
}
|
||||||
|
|
||||||
if (str_starts_with($path, 'wp-login.php') || str_starts_with($path, 'wp-admin')) {
|
if (str_starts_with($path, 'wp-login.php') || str_starts_with($path, 'wp-admin')) {
|
||||||
return response($this->fakeWordPress(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
|
return response($this->fakeWordPress(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
|
||||||
}
|
}
|
||||||
|
|
@ -65,15 +75,23 @@ class HoneypotController extends Controller
|
||||||
return response($this->fakeGenericLogin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
|
return response($this->fakeGenericLogin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plausible-looking dotenv seeded with the three config canaries (the bait). */
|
/**
|
||||||
|
* Plausible-looking dotenv seeded with the per-install canaries (the bait) when configured. When a
|
||||||
|
* canary is empty (unconfigured/dev), a plausible static value is served instead so the decoy still
|
||||||
|
* looks authentic — it is simply not registered as a detectable honeytoken.
|
||||||
|
*/
|
||||||
private function fakeDotenv(): string
|
private function fakeDotenv(): string
|
||||||
{
|
{
|
||||||
$c = (array) config('clusev.honeypot.canaries');
|
$c = (array) config('clusev.honeypot.canaries');
|
||||||
|
|
||||||
|
$appKey = ($c['app_key'] ?? '') !== '' ? $c['app_key'] : 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0=';
|
||||||
|
$dbPassword = ($c['db_password'] ?? '') !== '' ? $c['db_password'] : 'Sup3rS3cr3t-Pr0d-DB-9f2a7c';
|
||||||
|
$apiKey = ($c['api_key'] ?? '') !== '' ? $c['api_key'] : 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84';
|
||||||
|
|
||||||
return implode("\n", [
|
return implode("\n", [
|
||||||
'APP_NAME=Application',
|
'APP_NAME=Application',
|
||||||
'APP_ENV=production',
|
'APP_ENV=production',
|
||||||
'APP_KEY='.($c['app_key'] ?? ''),
|
'APP_KEY='.$appKey,
|
||||||
'APP_DEBUG=false',
|
'APP_DEBUG=false',
|
||||||
'APP_URL=https://app.internal.example.com',
|
'APP_URL=https://app.internal.example.com',
|
||||||
'',
|
'',
|
||||||
|
|
@ -82,15 +100,35 @@ class HoneypotController extends Controller
|
||||||
'DB_PORT=3306',
|
'DB_PORT=3306',
|
||||||
'DB_DATABASE=app_production',
|
'DB_DATABASE=app_production',
|
||||||
'DB_USERNAME=app_prod',
|
'DB_USERNAME=app_prod',
|
||||||
'DB_PASSWORD='.($c['db_password'] ?? ''),
|
'DB_PASSWORD='.$dbPassword,
|
||||||
'',
|
'',
|
||||||
|
'API_KEY='.$apiKey,
|
||||||
'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
|
'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
|
||||||
'AWS_SECRET_ACCESS_KEY='.($c['aws_secret'] ?? ''),
|
'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||||
'AWS_DEFAULT_REGION=eu-central-1',
|
'AWS_DEFAULT_REGION=eu-central-1',
|
||||||
'',
|
'',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plausible fake git config (an [core]/[remote "origin"] INI) so the /.git/config decoy looks real. */
|
||||||
|
private function fakeGitConfig(): string
|
||||||
|
{
|
||||||
|
return implode("\n", [
|
||||||
|
'[core]',
|
||||||
|
"\trepositoryformatversion = 0",
|
||||||
|
"\tfilemode = true",
|
||||||
|
"\tbare = false",
|
||||||
|
"\tlogallrefupdates = true",
|
||||||
|
'[remote "origin"]',
|
||||||
|
"\turl = https://git.internal.example.com/app/backend.git",
|
||||||
|
"\tfetch = +refs/heads/*:refs/remotes/origin/*",
|
||||||
|
'[branch "main"]',
|
||||||
|
"\tremote = origin",
|
||||||
|
"\tmerge = refs/heads/main",
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function fakeWordPress(): string
|
private function fakeWordPress(): string
|
||||||
{
|
{
|
||||||
return <<<'HTML'
|
return <<<'HTML'
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,22 @@ use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Honeytoken tripwire. The honeypot's fake .env seeds three canary secret VALUES that no real
|
* Honeytoken tripwire. The honeypot's fake .env seeds per-install canary secret VALUES that no real
|
||||||
* system secret ever equals (config('clusev.honeypot.canaries')). If an attacker who scraped the
|
* system secret ever equals (config('clusev.honeypot.canaries')). If an UNAUTHENTICATED attacker who
|
||||||
* decoy .env replays one of those values — as a form field, a bearer token, or an Authorization
|
* scraped the decoy .env replays one of those values — as a request-BODY credential field, a bearer
|
||||||
* header — it is undeniable proof they took the bait, so we instant-ban + 403.
|
* token, or an Authorization header — it is undeniable proof they took the bait, so we instant-ban + 403.
|
||||||
*
|
*
|
||||||
* Deliberately CHEAP: it inspects only a fixed handful of well-known credential inputs plus the
|
* Deliberately CHEAP and FALSE-POSITIVE-FREE: it only trips for anonymous requests (a logged-in user
|
||||||
* bearer token / Authorization header — never a recursive walk of every nested input — so a normal
|
* is never a honeytoken replayer, and this stops a reflected decoy link banning a real victim), it
|
||||||
* request (login, form post, API call) not carrying a canary is a near-free no-op. Appended to the
|
* scans only a fixed handful of well-known credential inputs from the request BODY (never the query
|
||||||
* web group AFTER AuthenticateSession so the session/IP are resolved.
|
* string, never `password`) plus the bearer token / Authorization header — never a recursive walk.
|
||||||
|
* A normal request not carrying a canary is a near-free no-op. Appended to the web group AFTER
|
||||||
|
* AuthenticateSession so the session/IP are resolved.
|
||||||
*/
|
*/
|
||||||
class DetectHoneytoken
|
class DetectHoneytoken
|
||||||
{
|
{
|
||||||
/** Request input keys that plausibly carry a leaked secret. Kept tiny on purpose. */
|
/** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */
|
||||||
private const CANDIDATE_KEYS = ['password', 'api_key', 'token', 'secret', 'key'];
|
private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key'];
|
||||||
|
|
||||||
public function __construct(private BruteforceGuard $guard) {}
|
public function __construct(private BruteforceGuard $guard) {}
|
||||||
|
|
||||||
|
|
@ -32,15 +34,22 @@ class DetectHoneytoken
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$canaries = (array) config('clusev.honeypot.canaries');
|
// Only trap UNAUTHENTICATED attackers. A logged-in user is never a honeytoken replayer, and
|
||||||
|
// this stops a reflected decoy link from banning an authenticated victim.
|
||||||
|
if (auth()->check()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$canaries = config('clusev.honeypot.canaries', []);
|
||||||
if ($canaries === []) {
|
if ($canaries === []) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A small, bounded set of candidate values — no recursion into nested arrays.
|
// A small, bounded set of candidate values — request BODY only (post()), no query string,
|
||||||
|
// no recursion into nested arrays.
|
||||||
$candidates = [];
|
$candidates = [];
|
||||||
foreach (self::CANDIDATE_KEYS as $key) {
|
foreach (self::CANDIDATE_KEYS as $key) {
|
||||||
$value = $request->input($key);
|
$value = $request->post($key);
|
||||||
if (is_string($value) && $value !== '') {
|
if (is_string($value) && $value !== '') {
|
||||||
$candidates[] = $value;
|
$candidates[] = $value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ class Index extends Component
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
abort_unless((bool) config('clusev.release_controls'), 404);
|
abort_unless((bool) config('clusev.release_controls'), 404);
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging beta of $target. */
|
/** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging beta of $target. */
|
||||||
public function confirmDeployStaging(string $target, ReleasePlanner $planner): void
|
public function confirmDeployStaging(string $target, ReleasePlanner $planner): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
|
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
|
||||||
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
|
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
|
||||||
|
|
||||||
|
|
@ -63,6 +65,7 @@ class Index extends Component
|
||||||
#[On('releaseStaged')]
|
#[On('releaseStaged')]
|
||||||
public function applyDeployStaging(string $confirmToken, ReleasePlanner $planner, ReleaseBridge $bridge): void
|
public function applyDeployStaging(string $confirmToken, ReleasePlanner $planner, ReleaseBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'releaseStaged');
|
$payload = ConfirmToken::consume($confirmToken, 'releaseStaged');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -80,6 +83,7 @@ class Index extends Component
|
||||||
/** Cut + push a beta of $target via the host bridge. $target must be one of the proposed values. */
|
/** Cut + push a beta of $target via the host bridge. $target must be one of the proposed values. */
|
||||||
public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void
|
public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
|
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
|
||||||
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
|
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
|
||||||
|
|
||||||
|
|
@ -192,6 +196,7 @@ class Index extends Component
|
||||||
|
|
||||||
public function confirmDeployPublic(): void
|
public function confirmDeployPublic(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$tag = $this->currentBetaTag();
|
$tag = $this->currentBetaTag();
|
||||||
if ($tag === null) {
|
if ($tag === null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -204,6 +209,7 @@ class Index extends Component
|
||||||
#[On('releasePublic')]
|
#[On('releasePublic')]
|
||||||
public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void
|
public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'releasePublic');
|
$payload = ConfirmToken::consume($confirmToken, 'releasePublic');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -222,6 +228,7 @@ class Index extends Component
|
||||||
|
|
||||||
public function confirmYank(string $tag, PromotionService $promotion): void
|
public function confirmYank(string $tag, PromotionService $promotion): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
if (! in_array($tag, $promotion->publicTags(), true)) {
|
if (! in_array($tag, $promotion->publicTags(), true)) {
|
||||||
$this->dispatch('notify', message: __('release.yank_unknown'), level: 'error');
|
$this->dispatch('notify', message: __('release.yank_unknown'), level: 'error');
|
||||||
|
|
||||||
|
|
@ -235,6 +242,7 @@ class Index extends Component
|
||||||
#[On('releaseYank')]
|
#[On('releaseYank')]
|
||||||
public function applyYank(string $confirmToken, PromotionService $promotion): void
|
public function applyYank(string $confirmToken, PromotionService $promotion): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'releaseYank');
|
$payload = ConfirmToken::consume($confirmToken, 'releaseYank');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class Email extends Component
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$this->mail_host = Setting::get('mail_host', '') ?? '';
|
$this->mail_host = Setting::get('mail_host', '') ?? '';
|
||||||
$this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587);
|
$this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587);
|
||||||
$this->mail_username = Setting::get('mail_username', '') ?? '';
|
$this->mail_username = Setting::get('mail_username', '') ?? '';
|
||||||
|
|
@ -68,6 +69,7 @@ class Email extends Component
|
||||||
|
|
||||||
public function save(): void
|
public function save(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
Setting::put('mail_host', $this->mail_host);
|
Setting::put('mail_host', $this->mail_host);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class LoginProtection extends Component
|
||||||
|
|
||||||
public function mount(BruteforceGuard $guard): void
|
public function mount(BruteforceGuard $guard): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$this->enabled = $guard->enabled();
|
$this->enabled = $guard->enabled();
|
||||||
$this->maxretry = $guard->maxretry();
|
$this->maxretry = $guard->maxretry();
|
||||||
$this->findtime = $guard->findtime();
|
$this->findtime = $guard->findtime();
|
||||||
|
|
@ -37,6 +38,7 @@ class LoginProtection extends Component
|
||||||
|
|
||||||
public function save(BruteforceGuard $guard): void
|
public function save(BruteforceGuard $guard): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'maxretry' => ['required', 'integer', 'min:1', 'max:1000'],
|
'maxretry' => ['required', 'integer', 'min:1', 'max:1000'],
|
||||||
'findtime' => ['required', 'integer', 'min:1', 'max:1440'],
|
'findtime' => ['required', 'integer', 'min:1', 'max:1440'],
|
||||||
|
|
@ -72,6 +74,7 @@ class LoginProtection extends Component
|
||||||
|
|
||||||
public function whitelistMyIp(): void
|
public function whitelistMyIp(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$ip = (string) request()->ip();
|
$ip = (string) request()->ip();
|
||||||
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
|
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
|
||||||
if (! in_array($ip, $lines, true)) {
|
if (! in_array($ip, $lines, true)) {
|
||||||
|
|
@ -82,6 +85,7 @@ class LoginProtection extends Component
|
||||||
|
|
||||||
public function confirmUnban(string $ip): void
|
public function confirmUnban(string $ip): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$this->dispatch('openModal',
|
$this->dispatch('openModal',
|
||||||
component: 'modals.confirm-action',
|
component: 'modals.confirm-action',
|
||||||
arguments: [
|
arguments: [
|
||||||
|
|
@ -99,6 +103,7 @@ class LoginProtection extends Component
|
||||||
#[On('banCleared')]
|
#[On('banCleared')]
|
||||||
public function unban(string $confirmToken, BruteforceGuard $guard): void
|
public function unban(string $confirmToken, BruteforceGuard $guard): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'banCleared');
|
$payload = ConfirmToken::consume($confirmToken, 'banCleared');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -111,6 +116,7 @@ class LoginProtection extends Component
|
||||||
|
|
||||||
public function confirmUnbanAll(): void
|
public function confirmUnbanAll(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
$this->dispatch('openModal',
|
$this->dispatch('openModal',
|
||||||
component: 'modals.confirm-action',
|
component: 'modals.confirm-action',
|
||||||
arguments: [
|
arguments: [
|
||||||
|
|
@ -128,6 +134,7 @@ class LoginProtection extends Component
|
||||||
#[On('bansClearedAll')]
|
#[On('bansClearedAll')]
|
||||||
public function unbanAll(string $confirmToken, BruteforceGuard $guard): void
|
public function unbanAll(string $confirmToken, BruteforceGuard $guard): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
try {
|
try {
|
||||||
ConfirmToken::consume($confirmToken, 'bansClearedAll');
|
ConfirmToken::consume($confirmToken, 'bansClearedAll');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class Sessions extends Component
|
||||||
*/
|
*/
|
||||||
public function confirmLogoutAll(): void
|
public function confirmLogoutAll(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||||
$this->dispatch('openModal',
|
$this->dispatch('openModal',
|
||||||
component: 'modals.confirm-action',
|
component: 'modals.confirm-action',
|
||||||
arguments: [
|
arguments: [
|
||||||
|
|
@ -81,6 +82,7 @@ class Sessions extends Component
|
||||||
#[On('sessionsLogoutAll')]
|
#[On('sessionsLogoutAll')]
|
||||||
public function logoutAll(string $confirmToken, SessionService $sessions)
|
public function logoutAll(string $confirmToken, SessionService $sessions)
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||||
try {
|
try {
|
||||||
ConfirmToken::consume($confirmToken, 'sessionsLogoutAll');
|
ConfirmToken::consume($confirmToken, 'sessionsLogoutAll');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
|
||||||
|
|
@ -122,13 +122,15 @@ class BruteforceGuard
|
||||||
* Ban an IP on the FIRST hit — no maxretry threshold, no RateLimiter window. Used by the
|
* Ban an IP on the FIRST hit — no maxretry threshold, no RateLimiter window. Used by the
|
||||||
* honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
|
* honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
|
||||||
* hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
|
* hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
|
||||||
* throttling. Returns true when a ban was written, false when it no-op'd (disabled / invalid
|
* throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the
|
||||||
* / exempt IP) — the caller stays a cheap no-op for legitimate traffic. Reuses the same
|
* trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a
|
||||||
* private helpers as record() (canonical/isExempt/enabled/bantime/maxretry).
|
* disabled brute-force protection must not silently disable honeypot bans). Returns true when a
|
||||||
|
* ban was written, false when it no-op'd (invalid / exempt IP) — the caller stays a cheap no-op
|
||||||
|
* for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry).
|
||||||
*/
|
*/
|
||||||
public function banNow(?string $ip, string $reason): bool
|
public function banNow(?string $ip, string $reason): bool
|
||||||
{
|
{
|
||||||
if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
|
if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,14 @@ return [
|
||||||
// Deception layer for unauthenticated attacker probes. ON by default: legit users never hit
|
// Deception layer for unauthenticated attacker probes. ON by default: legit users never hit
|
||||||
// decoy paths, so there are no false positives. Set CLUSEV_HONEYPOT=false to disable.
|
// decoy paths, so there are no false positives. Set CLUSEV_HONEYPOT=false to disable.
|
||||||
'enabled' => (bool) env('CLUSEV_HONEYPOT', true),
|
'enabled' => (bool) env('CLUSEV_HONEYPOT', true),
|
||||||
// Canary secret VALUES embedded in the fake .env we serve. No real system secret equals these;
|
// Per-install RANDOM canary secret values (install.sh generates them into .env). EMPTY here so
|
||||||
// if any reappears in a later request it PROVES an attacker took the bait -> instant ban.
|
// no guessable/public constant ships; empty => honeytoken detection is OFF (the served fake .env
|
||||||
'canaries' => [
|
// still uses plausible static values, they are just not registered as detectable canaries).
|
||||||
'app_key' => 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0=',
|
'canaries' => array_filter([
|
||||||
'db_password' => 'Sup3rS3cr3t-Pr0d-DB-9f2a7c',
|
'app_key' => (string) env('CLUSEV_HONEYPOT_CANARY_APP', ''),
|
||||||
'aws_secret' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
'db_password' => (string) env('CLUSEV_HONEYPOT_CANARY_DB', ''),
|
||||||
],
|
'api_key' => (string) env('CLUSEV_HONEYPOT_CANARY_API', ''),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
|
|
||||||
'license' => 'AGPL-3.0',
|
'license' => 'AGPL-3.0',
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,11 @@ set_kv REVERB_APP_KEY "$(rand_hex 16)"
|
||||||
set_kv REVERB_APP_SECRET "$(rand_hex 32)"
|
set_kv REVERB_APP_SECRET "$(rand_hex 32)"
|
||||||
set_kv UPDATE_HMAC_KEY "$(rand_hex 32)"
|
set_kv UPDATE_HMAC_KEY "$(rand_hex 32)"
|
||||||
set_kv TERMINAL_SIDECAR_SECRET "$(rand_hex 24)"
|
set_kv TERMINAL_SIDECAR_SECRET "$(rand_hex 24)"
|
||||||
|
# Per-install RANDOM honeypot canary values. Seeded into the served fake .env; if any reappears
|
||||||
|
# in a later request it PROVES an attacker took the bait -> instant ban (DetectHoneytoken).
|
||||||
|
set_kv CLUSEV_HONEYPOT_CANARY_APP "base64:$(openssl rand -base64 24)"
|
||||||
|
set_kv CLUSEV_HONEYPOT_CANARY_DB "$(rand_hex 20)"
|
||||||
|
set_kv CLUSEV_HONEYPOT_CANARY_API "$(rand_hex 24)"
|
||||||
|
|
||||||
force_kv APP_ENV production
|
force_kv APP_ENV production
|
||||||
force_kv APP_DEBUG false
|
force_kv APP_DEBUG false
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,23 @@
|
||||||
$u = auth()->user();
|
$u = auth()->user();
|
||||||
$initials = strtoupper(mb_substr($u->name, 0, 2));
|
$initials = strtoupper(mb_substr($u->name, 0, 2));
|
||||||
$twoFactorEnabled = $u->hasTwoFactorEnabled();
|
$twoFactorEnabled = $u->hasTwoFactorEnabled();
|
||||||
|
// Profile, security and sessions are self-service (visible to everyone). The
|
||||||
|
// login-protection + email tabs need manage-panel; the users tab needs
|
||||||
|
// manage-users — the tab bodies re-guard, so hiding the nav entry is just UX.
|
||||||
$tabs = [
|
$tabs = [
|
||||||
['key' => 'profile', 'label' => __('settings.tab_profile'), 'icon' => 'settings'],
|
['key' => 'profile', 'label' => __('settings.tab_profile'), 'icon' => 'settings'],
|
||||||
['key' => 'security', 'label' => __('settings.tab_security'), 'icon' => 'shield'],
|
['key' => 'security', 'label' => __('settings.tab_security'), 'icon' => 'shield'],
|
||||||
['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield'],
|
|
||||||
['key' => 'users', 'label' => __('settings.tab_users'), 'icon' => 'user-plus'],
|
|
||||||
['key' => 'sessions', 'label' => __('settings.tab_sessions'), 'icon' => 'logout'],
|
|
||||||
['key' => 'email', 'label' => __('settings.tab_email'), 'icon' => 'mail'],
|
|
||||||
];
|
];
|
||||||
|
if (auth()->user()?->can('manage-panel')) {
|
||||||
|
$tabs[] = ['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield'];
|
||||||
|
}
|
||||||
|
if (auth()->user()?->can('manage-users')) {
|
||||||
|
$tabs[] = ['key' => 'users', 'label' => __('settings.tab_users'), 'icon' => 'user-plus'];
|
||||||
|
}
|
||||||
|
$tabs[] = ['key' => 'sessions', 'label' => __('settings.tab_sessions'), 'icon' => 'logout'];
|
||||||
|
if (auth()->user()?->can('manage-panel')) {
|
||||||
|
$tabs[] = ['key' => 'email', 'label' => __('settings.tab_email'), 'icon' => 'mail'];
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,11 @@
|
||||||
<x-modal-trigger variant="danger-soft" class="shrink-0" action="confirmLogoutOthers">
|
<x-modal-trigger variant="danger-soft" class="shrink-0" action="confirmLogoutOthers">
|
||||||
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('sessions.logout_others') }}
|
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('sessions.logout_others') }}
|
||||||
</x-modal-trigger>
|
</x-modal-trigger>
|
||||||
|
@can('manage-users')
|
||||||
<x-modal-trigger variant="danger" class="shrink-0" action="confirmLogoutAll">
|
<x-modal-trigger variant="danger" class="shrink-0" action="confirmLogoutAll">
|
||||||
<x-icon name="power" class="h-3.5 w-3.5" /> {{ __('sessions.logout_all') }}
|
<x-icon name="power" class="h-3.5 w-3.5" /> {{ __('sessions.logout_all') }}
|
||||||
</x-modal-trigger>
|
</x-modal-trigger>
|
||||||
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
use App\Models\AuditEvent;
|
||||||
use App\Models\BannedIp;
|
use App\Models\BannedIp;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BruteforceGuard;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
@ -15,10 +18,26 @@ class HoneypotTest extends TestCase
|
||||||
/** A non-exempt (public) client IP — loopback/private ranges are exempt from banNow. */
|
/** A non-exempt (public) client IP — loopback/private ranges are exempt from banNow. */
|
||||||
private const ATTACKER_IP = '203.0.113.99';
|
private const ATTACKER_IP = '203.0.113.99';
|
||||||
|
|
||||||
|
/** Per-install canary values registered for the tests (config default is now empty). */
|
||||||
|
private const CANARY_APP = 'base64:T3sTC4n4ryAppK3yAAAAAAAAAAAAAAAAAAAAAAAAA0=';
|
||||||
|
|
||||||
|
private const CANARY_DB = 'T3sT-Pr0d-DB-Pass-9f2a7c11';
|
||||||
|
|
||||||
|
private const CANARY_API = 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84';
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
Cache::flush();
|
Cache::flush();
|
||||||
|
|
||||||
|
// Register per-install canaries for detection (config ships EMPTY so nothing guessable leaks).
|
||||||
|
config([
|
||||||
|
'clusev.honeypot.canaries' => [
|
||||||
|
'app_key' => self::CANARY_APP,
|
||||||
|
'db_password' => self::CANARY_DB,
|
||||||
|
'api_key' => self::CANARY_API,
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simulate a request from a specific client IP via REMOTE_ADDR (trustProxies is prod-gated). */
|
/** Simulate a request from a specific client IP via REMOTE_ADDR (trustProxies is prod-gated). */
|
||||||
|
|
@ -52,26 +71,82 @@ class HoneypotTest extends TestCase
|
||||||
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_honeytoken_replay_is_blocked_banned_and_audited(): void
|
public function test_git_config_decoy_serves_fake_git_config_and_bans(): void
|
||||||
{
|
{
|
||||||
$canary = config('clusev.honeypot.canaries.db_password');
|
$response = $this->fromIp('GET', '/.git/config', self::ATTACKER_IP);
|
||||||
|
|
||||||
// A normal POST web route carrying the leaked canary in a form field trips the middleware
|
$response->assertStatus(200);
|
||||||
// (broadcasting/auth is in the web group, so the appended DetectHoneytoken runs). The 403
|
$response->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
|
||||||
// fires before the route's own handler, proving the tripwire, not the endpoint, responded.
|
$response->assertSee('[remote "origin"]', false);
|
||||||
$response = $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['password' => $canary]);
|
$response->assertSee('[core]', false);
|
||||||
|
|
||||||
|
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
|
||||||
|
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_honeytoken_replay_in_post_body_api_key_is_blocked_banned_and_audited(): void
|
||||||
|
{
|
||||||
|
$canary = config('clusev.honeypot.canaries.api_key');
|
||||||
|
|
||||||
|
// An unauthenticated POST web route carrying the leaked canary in the api_key BODY field trips
|
||||||
|
// the middleware (broadcasting/auth is in the web group, so the appended DetectHoneytoken runs).
|
||||||
|
// The 403 fires before the route's own handler, proving the tripwire — not the endpoint — responded.
|
||||||
|
$response = $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['api_key' => $canary]);
|
||||||
|
|
||||||
$response->assertStatus(403);
|
$response->assertStatus(403);
|
||||||
|
|
||||||
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
|
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
|
||||||
$this->assertTrue(
|
$this->assertTrue(
|
||||||
AuditEvent::where('action', 'security.honeytoken_used')
|
AuditEvent::where('action', 'security.honeytoken_used')
|
||||||
->where('target', 'db_password')
|
->where('target', 'api_key')
|
||||||
->where('ip', self::ATTACKER_IP)
|
->where('ip', self::ATTACKER_IP)
|
||||||
->exists()
|
->exists()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_login_post_with_password_equal_to_canary_does_not_ban(): void
|
||||||
|
{
|
||||||
|
// `password` is no longer scanned — a login carrying a value that happens to equal a canary
|
||||||
|
// (e.g. a reflected value) must NOT trip the honeytoken tripwire.
|
||||||
|
$this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['password' => self::CANARY_DB]);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('banned_ips', 0);
|
||||||
|
$this->assertFalse(AuditEvent::where('action', 'security.honeytoken_used')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_query_string_with_canary_token_does_not_ban(): void
|
||||||
|
{
|
||||||
|
// The query string is never scanned — a reflected GET decoy link ?token=<canary> must NOT ban
|
||||||
|
// the victim who clicks it.
|
||||||
|
$this->fromIp('GET', '/login?token='.self::CANARY_API, self::ATTACKER_IP);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('banned_ips', 0);
|
||||||
|
$this->assertFalse(AuditEvent::where('action', 'security.honeytoken_used')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_user_hitting_decoy_gets_404_and_is_not_banned(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['must_change_password' => false]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->fromIp('GET', '/wp-login.php', self::ATTACKER_IP);
|
||||||
|
|
||||||
|
$response->assertStatus(404);
|
||||||
|
$this->assertDatabaseCount('banned_ips', 0);
|
||||||
|
$this->assertFalse(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ban_now_fires_even_when_bruteforce_protection_is_disabled(): void
|
||||||
|
{
|
||||||
|
// Disabling the brute-force login toggle must NOT silently disable honeypot bans: banNow no
|
||||||
|
// longer consults enabled(). The honeypot flag alone gates whether the trap runs.
|
||||||
|
Setting::put('bruteforce_enabled', '0');
|
||||||
|
|
||||||
|
$banned = app(BruteforceGuard::class)->banNow(self::ATTACKER_IP, 'honeytoken');
|
||||||
|
|
||||||
|
$this->assertTrue($banned);
|
||||||
|
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_disabled_honeypot_returns_404_without_ban_or_audit(): void
|
public function test_disabled_honeypot_returns_404_without_ban_or_audit(): void
|
||||||
{
|
{
|
||||||
config(['clusev.honeypot.enabled' => false]);
|
config(['clusev.honeypot.enabled' => false]);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Release\Index as ReleaseIndex;
|
||||||
|
use App\Livewire\Settings\Email;
|
||||||
|
use App\Livewire\Settings\LoginProtection;
|
||||||
|
use App\Livewire\Settings\Sessions;
|
||||||
|
use App\Models\BannedIp;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\SessionService;
|
||||||
|
use App\Support\Confirm\ConfirmToken;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RBAC guards on the SETTINGS + RELEASE admin surfaces. The RBAC foundation (Role enum, Gates
|
||||||
|
* manage-panel/manage-users) is committed separately; this proves the abort_unless(...) guards are
|
||||||
|
* wired at every dangerous entry point:
|
||||||
|
* - manage-panel gates LoginProtection (brute-force config / whitelist / ban clearing), Email (SMTP
|
||||||
|
* config) and the whole Release page + its actions;
|
||||||
|
* - manage-users gates the destructive GLOBAL logout (Sessions::logoutAll) while per-session
|
||||||
|
* self-revoke stays open to everyone.
|
||||||
|
* Admin (factory default) may act; operator + viewer are refused with 403.
|
||||||
|
*/
|
||||||
|
class RbacSettingsGateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function admin(): User
|
||||||
|
{
|
||||||
|
return User::factory()->create(['must_change_password' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operator(): User
|
||||||
|
{
|
||||||
|
return User::factory()->operator()->create(['must_change_password' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viewer(): User
|
||||||
|
{
|
||||||
|
return User::factory()->viewer()->create(['must_change_password' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── manage-panel: LoginProtection (mount + save + unban + unbanAll + whitelistMyIp) ──
|
||||||
|
|
||||||
|
public function test_operator_cannot_mount_login_protection(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->operator())
|
||||||
|
->test(LoginProtection::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_viewer_cannot_mount_login_protection(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->viewer())
|
||||||
|
->test(LoginProtection::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_mount_login_protection(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(LoginProtection::class)
|
||||||
|
->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_operator_cannot_save_login_protection(): void
|
||||||
|
{
|
||||||
|
// Boot as admin so the snapshot is valid, then swap to an operator and call the mutation —
|
||||||
|
// the save() method-level guard must still fire 403 (models a demotion / crafted update).
|
||||||
|
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
|
||||||
|
$component->set('maxretry', 5)->call('save')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_viewer_cannot_call_whitelist_my_ip(): void
|
||||||
|
{
|
||||||
|
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($this->viewer());
|
||||||
|
|
||||||
|
$component->call('whitelistMyIp')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_operator_cannot_unban(): void
|
||||||
|
{
|
||||||
|
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
|
||||||
|
$component->call('unban', 'any-token')->assertForbidden();
|
||||||
|
$this->assertDatabaseCount('banned_ips', 1); // guard fired before the ban was touched
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_viewer_cannot_unban_all(): void
|
||||||
|
{
|
||||||
|
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($this->admin())->test(LoginProtection::class)->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($this->viewer());
|
||||||
|
|
||||||
|
$component->call('unbanAll', 'any-token')->assertForbidden();
|
||||||
|
$this->assertDatabaseCount('banned_ips', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_save_login_protection(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(LoginProtection::class)
|
||||||
|
->set('maxretry', 7)
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors(); // allowed through the guard
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── manage-panel: Email (mount + save) ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_operator_cannot_mount_email(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->operator())
|
||||||
|
->test(Email::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_viewer_cannot_mount_email(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->viewer())
|
||||||
|
->test(Email::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_operator_cannot_save_email(): void
|
||||||
|
{
|
||||||
|
$component = Livewire::actingAs($this->admin())->test(Email::class)->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
|
||||||
|
$component
|
||||||
|
->set('mail_host', 'smtp.example.com')
|
||||||
|
->set('mail_from_address', 'noreply@example.com')
|
||||||
|
->set('mail_from_name', 'Clusev')
|
||||||
|
->call('save')
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_save_email(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(Email::class)
|
||||||
|
->set('mail_host', 'smtp.example.com')
|
||||||
|
->set('mail_from_address', 'noreply@example.com')
|
||||||
|
->set('mail_from_name', 'Clusev')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── manage-users: Sessions::logoutAll (destructive GLOBAL logout) ─────────────
|
||||||
|
|
||||||
|
public function test_operator_cannot_logout_all(): void
|
||||||
|
{
|
||||||
|
$component = Livewire::actingAs($this->admin())->test(Sessions::class)->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
|
||||||
|
$component->call('logoutAll', 'any-token')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_viewer_cannot_logout_all(): void
|
||||||
|
{
|
||||||
|
$component = Livewire::actingAs($this->admin())->test(Sessions::class)->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($this->viewer());
|
||||||
|
|
||||||
|
$component->call('logoutAll', 'any-token')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_logout_all(): void
|
||||||
|
{
|
||||||
|
$admin = $this->admin();
|
||||||
|
|
||||||
|
// The global logout truncates every session — mock it so the test session survives.
|
||||||
|
$sessions = Mockery::mock(SessionService::class)->makePartial();
|
||||||
|
$sessions->shouldReceive('logoutEveryone')->once();
|
||||||
|
$sessions->shouldReceive('forUser')->andReturn([]);
|
||||||
|
$this->app->instance(SessionService::class, $sessions);
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
$token = ConfirmToken::issue('sessionsLogoutAll', [], 'session.logout_all', $admin->email);
|
||||||
|
ConfirmToken::confirm($token);
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(Sessions::class)
|
||||||
|
->call('logoutAll', $token)
|
||||||
|
->assertRedirect(route('login')); // allowed through the guard → redirect to login
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_admin_self_session_revoke_is_not_forbidden(): void
|
||||||
|
{
|
||||||
|
// A non-admin managing their OWN sessions (per-session self-revoke) is NOT gated. Opening the
|
||||||
|
// "sign out other devices" confirm must not 403 for an operator.
|
||||||
|
Livewire::actingAs($this->operator())
|
||||||
|
->test(Sessions::class)
|
||||||
|
->call('confirmLogoutOthers')
|
||||||
|
->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── manage-panel: Release\Index (flag ON, but role still enforced) ───────────
|
||||||
|
|
||||||
|
public function test_operator_cannot_mount_release_even_with_flag_on(): void
|
||||||
|
{
|
||||||
|
config()->set('clusev.release_controls', true);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->operator())
|
||||||
|
->test(ReleaseIndex::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_viewer_cannot_mount_release_even_with_flag_on(): void
|
||||||
|
{
|
||||||
|
config()->set('clusev.release_controls', true);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->viewer())
|
||||||
|
->test(ReleaseIndex::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_mount_release_with_flag_on(): void
|
||||||
|
{
|
||||||
|
config()->set('clusev.release_controls', true);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(ReleaseIndex::class)
|
||||||
|
->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue