Commit Graph

60 Commits (dd891cd11e883598f239e595cdec2e2c0c69c71b)

Author SHA1 Message Date
boban dd891cd11e Reveal backup codes only when freshly generated
Backup (recovery) codes must be viewable ONLY at the moment they are
generated — at first-factor enrollment or on regenerate — and NEVER
re-viewable afterward from "Backup-Codes verwalten".

- Modals\RecoveryCodes: add server-gated $revealed. mount() pulls a
  one-time session flag (2fa.codes_fresh); regenerate() sets it. render()
  emits codes ONLY when revealed, so a client cannot tamper a flag to
  re-view stored codes.
- View: revealed state shows codes grid + download + warning + close;
  hidden state shows a security notice + Neu erzeugen + close (no codes,
  no download).
- TwoFactorSetup::confirm() and WebauthnKeys::register() put
  2fa.codes_fresh on first-factor code generation (put, survives the
  redirect + later openModal request).
- Add recovery_hidden_notice (DE/EN parity).
- Tests: manage view hides codes; fresh flag reveals once; regenerate
  reveals the new set; first-factor paths set the flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:14:59 +02:00
boban 4eeff4916a feat(fail2ban): lazy-load Gesperrte IPs modal behind a skeleton
The "Gesperrte IPs ansehen" modal did its SSH Fail2banService::status read
in mount(), so clicking the trigger blocked for seconds with no spinner
before the modal appeared.

Split mount() into an instant mount() (stores serverId only) plus a
load() method fired by wire:init, mirroring Services\Index. While
! $loaded the view renders a 4-row skeleton inside the max-h-96 scroll
container; once loaded it renders the real jail/IP list (or empty/error).
The unban flow still re-reads via loadJails() and dispatches
fail2banChanged, keeping #[Locked] $serverId and the audit intact.

Adds Fail2banBansModalTest covering the instant mount (status never
called), load() rendering the list, read-error surfacing, and both
unban success (audit + refresh + dispatch) and failure paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:05:35 +02:00
boban 3399a5017a feat(system): auto-restart sentinel — one-click restart via host watcher (no docker socket)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:50 +02:00
boban 73eecd03e0 feat(mail): SMTP configuration in Settings (encrypted password, runtime override, test-send)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:19 +02:00
boban bb1ad53030 feat(audit): configurable retention + scheduled clusev:prune-audit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:40:17 +02:00
boban 4ced37ac40 feat(accounts): multi-user admin management — create (temp pw), list, remove, force-logout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:39:34 +02:00
boban 0cdc6c3a7b feat(sessions): database sessions + list/revoke (other devices, per-user, global) with remember-token rotation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:34:00 +02:00
boban 5f6ad27329 refactor(settings): split into per-tab nested components (Profile, Security) + tab hosts for Users/Sessions/Email
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:23:25 +02:00
boban 991e17fcee fix(tls): audit TLS-mode changes in applyTlsMode (covers direct Livewire calls, no double-audit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:49:31 +02:00
boban bd6ba03c3c fix(security): register EnsureSecurityOnboarded as persistent Livewire middleware
Re-applies the onboarding gate to /livewire/update component calls, not just the
initial page GET, so a component mounted while onboarded cannot run destructive
actions after the account is reverted to must_change_password mid-session.
Addresses the codebase-wide Codex finding flagged during the SSH-key-provision review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:40:31 +02:00
boban 8ef907d429 feat(tls): System TLS-mode toggle + Caddy trusted_proxies (TRUSTED_PROXY_CIDR) + firewall hint
- Add TLS_MODES const, tlsMode property, confirmTlsMode/applyTlsMode (mirroring channel toggle) to System\Index
- Segmented TLS-mode selector + external-proxy warning hint in system/index.blade.php
- DE + EN lang keys for all new strings (tls_mode_*, change_tls_*, tls_saved_notify)
- Caddyfile: servers { trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32} } in global block
- docker-compose.prod.yml caddy service: TRUSTED_PROXY_CIDR env var (safe default 127.0.0.1/32)
- .env.example: documented TRUSTED_PROXY_CIDR knob
- tests/Feature/TlsModeToggleTest.php: 2 new tests (all 7 TLS tests green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:38:21 +02:00
boban 99a183bcc7 feat(tls): external reverse-proxy mode core — no ACME + no forced HTTPS redirect (host check kept)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:31:04 +02:00
boban 588f5aee2f fix(ssh): best-effort audit + exception-safe switch/verify and modal run(); lock serverId
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:16:54 +02:00
boban 8d7f2da8d0 feat(ssh): provision modal + hardening-row action — confirm, run, reveal key once
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:09:59 +02:00
boban 6f8f5dbc2b fix(ssh): graceful failure if password-disable throws post-switch; document privateKey-on-failure contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:04:56 +02:00
boban b847b80ab6 feat(ssh): SshKeyProvisioner — generate+install key, verify, switch credential, then disable password (rollback-safe)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:57:45 +02:00
boban 4f4b7225f5 fix(2fa): narrow TOTP verify exception handling; guard enrollment against a tampered secret
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:45:12 +02:00
boban 5b1e7e5168 fix(2fa): TwoFactorSetup back to auth layout; exception-safe User::verifyTotp at all call sites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:15 +02:00
boban 61df4497d7 feat(2fa): forgot-password is key-only safe + states the no-2FA recovery path
Guard the TOTP verifyKey call behind hasTotp() so a key-only user's null
two_factor_secret never reaches Google2FA::verifyKey(); they reset via a
backup code. Also adds the reset_no_2fa_note lang key (EN + DE) rendered
on the forgot-password form explaining the no-2FA recovery options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:26:59 +02:00
boban 00337e038c feat(2fa): Settings manages TOTP + keys independently; key can be only factor; empfohlen hint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:21:28 +02:00
boban aa43947dcf fix(2fa): memoize challenge pending-user lookup; assert key-only view; tidy hint wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:17:18 +02:00
boban 55d513f241 feat(2fa): challenge adapts to factor — TOTP field gated, key-only leads with the key + backup code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:10:25 +02:00
boban c19a35e43d feat(2fa): generate recovery codes on first factor + open modal; persist Settings tab in URL
- TwoFactorSetup: move to app layout (hosts wire-elements/modal), guard on hasTotp() not hasTwoFactorEnabled(), redirect to settings and flash open_recovery_modal on first TOTP enrollment
- WebauthnKeys.register(): drop hasTwoFactorEnabled() guard (first key may be the sole factor), generate codes + dispatch openModal on first key enrollment
- Settings\Index: add #[Url] on $tab, add $openRecoveryModal flag, mount() reads the flash and lands on the security tab
- settings/index.blade.php: x-init block dispatches openModal when $openRecoveryModal is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:04:06 +02:00
boban a8616809f1 fix(services): cap live journal at 200 rows in a scrollable, auto-sticking box 2026-06-14 20:58:48 +02:00
boban e9a6a6c083 feat(2fa): recovery codes become a modal; drop the dedicated page + route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:50:11 +02:00
boban 82d5644767 feat(2fa): onboarding no longer forces 2FA (only password rotation) 2026-06-14 20:44:12 +02:00
boban 9a01bfedf7 feat(2fa): pluggable factor semantics on User (hasTotp, either-factor, resetIfNoFactor) 2026-06-14 20:38:36 +02:00
boban 44d4046526 fix(services): live journal poll + command-palette server search
Journal was a one-time snapshot mislabeled "journalctl -f": it loaded the last
N lines once and never refreshed, so it was neither live nor complete.

- FleetService: `journalctl --show-cursor` + new journalSince() reads only entries
  after the stored journald cursor (no gaps, no dup appends), capped to a bounded
  live tail; parseJournal split into splitJournalCursor + parseJournalLines.
- Services\Index: pollJournal() (wire:poll.5s) appends new entries since the cursor
  and caps retained rows at 300 (fetch ceiling == display ceiling).
- Honest labels: drop the misleading "-f"; badge stays "live" (now true).

Command palette (Strg/⌘ K) can now jump to a server: the fleet feeds the Alpine
cmdk x-data; servers surface only while searching, matched on name OR IP, and
selecting one navigates to /servers/<uuid>.

Cursor is validated (^[a-z0-9;=]+$) and single-quoted before the remote shell —
no injection (Codex-reviewed: no P1, no security findings). Removes stock
ExampleTest (asserted 200 on an auth-gated route → always 302).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:13:08 +02:00
boban 0b1b5357d2 fix(ui): set active server at mount only (race-free)
Revert the hydrate-on-every-request sync — writing the single global active-server
session on every async request (slow load(), polls, parallel tabs) is inherently
last-writer-wins and races. Setting it only at mount (once per deliberate
navigation) is race-free and fixes the visible drift. bfcache restore + multi-tab
remain inherent edges of the single-session model — documented in the handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:43:24 +02:00
boban 7ea789c078 fix(ui): re-sync active server on every details-page request (bfcache-safe)
Add a hydrate() hook so Show re-asserts its server as active on the wire:poll tick
and any action — covering a wire:navigate bfcache restore that skips mount(),
without the earlier racy client-side POST. Idempotent + server-side, no race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:40:24 +02:00
boban 0088122ff1 revert(ui): drop racy bfcache JS auto-sync; keep race-free mount/targetFor
The livewire:navigated activate-POST hook introduced unbounded cross-request
ordering races (the on-screen confusion is already fixed race-free by
Show::mount setting the active server + ServerSwitcher::targetFor navigating on
switch). Remove the JS hook + activate endpoint. The remaining bfcache edge is
invisible (the cached page shows the right server) and a transient stale session
resolves on the next deliberate navigation — documented in the handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:37:54 +02:00
boban f4c24d51a6 fix(ui): cancel stale activate requests + onboarded user in test
Abort any in-flight active-server POST on each navigation so a slower stale
request can't overwrite the latest selection. Fix the activate-endpoint test to
use an onboarded user (the route is behind EnsureSecurityOnboarded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:32:16 +02:00
boban 6e0ec724ca fix(ui): re-sync active server on bfcache restore of a details page
wire:navigate restores Back/Forward pages from its client cache without re-running
Show::mount(), which could leave active_server_id pointing at a different server
than the details page on screen. Add a servers.activate endpoint + a global
livewire:navigated hook that re-asserts the on-screen server on every navigation
(including cache restores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:29:24 +02:00
boban 784566d06b fix(ui): keep the sidebar server-switcher in sync with the viewed server
Two "current server" notions diverged: the sidebar switcher (session active_server_id)
vs the server-details URL. Now viewing a server's details sets it active (sidebar
always matches the page), and switching the sidebar while on a details page navigates
to the chosen server (instead of reloading the old one). One current server, in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:24:56 +02:00
boban 2b04a18023 fix(webauthn): counter zero only when stored is zero + CLI clears keys
- ZeroTolerantCounterChecker only tolerates a 0 counter when the stored counter is
  also 0; a 0 after a non-zero counter is a rollback and is rejected.
- clusev:reset-admin --disable-2fa now also deletes the user's security keys, so a
  compromised key can't revive when 2FA is re-enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:47:07 +02:00
boban 44b87d78ba fix(webauthn): tolerate zero-counter authenticators + recheck 2FA on register
- Swap the strict counter checker for a zero-tolerant one so platform
  authenticators / synced passkeys (which always report counter 0) can assert,
  while non-zero counters still must strictly increase (clone detection).
- Guard registration (options + register) and the UI on hasTwoFactorEnabled, so a
  key is never created against a disabled/reset second factor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:44:24 +02:00
boban 98a39e1257 fix(webauthn): validate the key label before the browser ceremony
options() now validates the label before returning creation options, so clicking
Add with a blank/invalid name aborts before navigator.credentials.create() and
never leaves an orphaned credential on the authenticator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:35:25 +02:00
boban fe5c190eb0 feat(webauthn): register/list/remove security keys in settings
A "Security-Keys" panel in Settings → Security: add (name + navigator.credentials
.create via resources/js/webauthn.js), list (name/added/last-used), remove (R5
confirm modal), all audited. Hidden with a hint when domain+HTTPS is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:29:51 +02:00
boban 7afa50d253 feat(webauthn): use a security key at the login challenge
When the panel runs on a domain+HTTPS and the user has a registered key, the 2FA
challenge offers "Mit Security-Key anmelden" alongside the TOTP/backup-code field.
A verified assertion completes login exactly like a TOTP success; rate-limit +
session gate reused. TOTP and backup codes remain fully usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:26:58 +02:00
boban 0cb9b073ce feat(webauthn): registration/assertion options + ceremony verification
WebauthnService builds creation/request options (rpId = active domain, ES256/RS256,
attestation none, exclude/allow lists) via web-auth/webauthn-lib v5, stores the
challenge in the session, and verifies attestation/assertion responses (allowed
origin = https://<rpId>, sign-count tracked). Option building + serialization are
unit-tested; the cryptographic verify path is browser-verified on a domain host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:24:10 +02:00
boban 54ebb49941 feat(webauthn): WebauthnService::available gate (domain + https)
WebAuthn is offered only with an active domain (the rpId) over HTTPS; bare-IP/HTTP
returns false so a ceremony never runs with an invalid IP rpId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:43 +02:00
boban 78b1017844 feat(webauthn): install web-auth/webauthn-lib + credential storage
Add web-auth/webauthn-lib ^5.3, the webauthn_credentials table (per-user unique
credential_id), the WebauthnCredential model and User hasMany relation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:05 +02:00
boban 3a67aa99ec fix(auth): rotate remember_token on reset + atomic recovery-code use
Address Codex security review:
- ForgotPassword + clusev:reset-admin now rotate remember_token, so a stolen
  remember-me cookie cannot survive a password reset (the email-token path
  already did this).
- useRecoveryCode reads/checks/removes under a row lock (DB transaction +
  lockForUpdate), so two concurrent requests can't both spend the same one-time
  code (replay).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:04:12 +02:00
boban f6a65827fd feat(auth): clusev:reset-admin CLI lockout fallback
Shell-only emergency recovery: resets the admin password (prints a random one if
omitted) and, with --disable-2fa, clears the 2FA secret + backup codes so the
operator can re-enroll. Requires server access — the safe last resort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:57:44 +02:00
boban abaed12b0b feat(auth): email reset-link path (gated on a configured mailer)
ForgotPassword gains sendResetLink() + a ResetPassword component at
/reset-password/{token} using Laravel's password broker. The email option is
shown only when mail.default is a real mailer (hidden while MAIL_MAILER=log), so
it activates automatically once SMTP is configured. Tested with Notification::fake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:57:05 +02:00
boban cb8b906aa8 feat(auth): forgot-password reset via 2FA or backup code
"Passwort vergessen?" on login -> a reset view that proves identity with a TOTP
or one-time backup code (no email needed), then sets a new password under the
min(12) mixedCase numbers policy. Rate-limited, generic messages (no account
enumeration), audited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:55:01 +02:00
boban 764e1e5e53 feat(auth): show + download + regenerate 2FA backup codes
2FA confirm now generates 8 backup codes and redirects to a show-once recovery
view (download as .txt, regenerate). Reachable again from Settings. Codes are
shown before the onboarding gate so a fresh enrollee can save them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:52:23 +02:00
boban b2cb94bb32 feat(auth): accept a 2FA backup code at the login challenge
The challenge now falls back to consuming a one-time recovery code when the TOTP
check fails. Relaxed the code input (was numeric, maxlength 6) so a backup code
is typable, and added a hint. Bilingual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:50:36 +02:00
boban f3e595ed45 feat(auth): store 2FA recovery codes (encrypted) on users
Add the two_factor_recovery_codes column (encrypted:array) + User helpers
replaceRecoveryCodes / recoveryCodes / hasRecoveryCodes / useRecoveryCode
(one-time consumption). Hidden from serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:48:21 +02:00
boban ecf5bf5fc5 feat(i18n): custom bilingual validation messages (DE + EN)
There was no lang/{de,en}/validation.php, so Laravel fell back to its built-in
English messages for every input-field validation error regardless of locale.
Add the full validation language file in German (source of truth) and English,
plus a localized `attributes` map so :attribute reads naturally (e.g. "SSH-Port",
"E-Mail-Adresse"). Per-form validationAttributes() still override these.

Verified: Validator resolves DE/EN messages + attribute labels; submitting the
empty login form renders "E-Mail-Adresse muss ausgefüllt werden." in German.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:45:02 +02:00