Ein Zugang zur Konsole, dessen Passwort niemand kennt

Bisher erzeugte Settings::inviteStaff() beim Einladen eines
Betreiber-Mitarbeiters ein Passwort und zeigte es dem Inhaber einmal auf dem
Bildschirm, damit er es "sicher weitergibt" -- fuer einen Zugang zur Konsole,
die die ganze Flotte verwaltet. Der Kommentar dort nannte sich selbst eine
Attrappe.

Jetzt geht eine Einladung ohne Passwort hinaus: der Eingeladene bekommt eine
Mail mit einem Link und vergibt sein Passwort selbst, ueber
App\Livewire\Auth\OperatorSetPassword. Niemand -- auch der Inhaber nicht --
kennt je ein fremdes Passwort.

Dafuer brauchte es einen eigenen Weg, den es fuer Betreiber noch nicht gab
(R21: Konsole und Portal teilen keine Identitaet). Ein eigener Broker mit
eigener Tabelle (operator_password_reset_tokens, config/auth.php), eine
eigene Route in der Gast-Gruppe der Konsole (admin.invitation), und
Operator::sendPasswordResetNotification() wirft jetzt, statt Laravels
Vorgabe zu nutzen, die auf die Portalseite verlinkt haette.

Der Einladungslink gilt 72 Stunden -- lang genug fuer ein Wochenende, kurz
genug, dass ein altes Postfach nicht auf Dauer einen Schluessel zur Konsole
haelt. Zwei-Faktor bleibt unberuehrt: die neue Seite meldet niemanden
automatisch an, der Eingeladene durchlaeuft danach die normale Anmeldung
samt ihrer bestehenden Zwei-Faktor-Pruefung. Die Route liegt hinter denselben
Netz- und Hostwaechtern wie der Rest der Konsole (RestrictAdminHost,
RestrictConsoleNetwork), ohne Sonderfall.

OperatorInvitationMail reiht sich in den Versandtakt ein (MailLane::LOCKED,
wie ResetPasswordMail und VerifyEmailMail -- ein Mensch wartet gerade) und
ist damit in der Vorschau- und Versandtakt-Uebersicht der Konsole sichtbar,
statt lautlos in die gedrosselte Spur zu fallen.

Zwei Mutationsproben gegen die tragende Zusicherung durchgefuehrt (Bericht:
.superpowers/sdd/betreiber-einladung-report.md): die erste (durch die
Vorgaengersitzung) hielt fest, dass drei Zusicherungen fallen wuerden, waere
das Konto sofort anmeldbar; die zweite -- das Passwort testweise wieder auf
die Seite gebracht -- faellt exakt an der dafuer gebauten reflektierenden
Pruefung ueber alle oeffentlichen Eigenschaften der Seite.

Suite: 2771 passed (9683 assertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 01:50:42 +02:00
parent 8174ad77cc
commit 7edd88b71c
23 changed files with 909 additions and 51 deletions

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Admin;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ResolvesOperator;
use App\Mail\OperatorInvitationMail;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\VpnPeer;
@ -15,7 +16,8 @@ use App\Support\PasswordPolicy;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -111,11 +113,6 @@ class Settings extends Component
public string $staffRole = 'Support';
/** Shown once after inviting, since email delivery is still mocked. */
public ?string $invitedEmail = null;
public ?string $invitedPassword = null;
public function savePasswordPolicy(): void
{
$this->authorize('settings.manage');
@ -240,19 +237,42 @@ class Settings extends Component
return;
}
// Email/password-setup delivery is still mocked, so generate a temporary
// password and surface it once to the Owner to share securely — the
// account is usable immediately (a proper invite link follows with mail).
$temp = Str::password(14);
// Ein Konto, dessen Passwort niemand kennt — auch der Inhaber nicht.
//
// Hier wurde bis zuletzt ein Passwort erzeugt und dem Inhaber einmal
// angezeigt, damit er es „sicher weitergibt": per Chat, per Zuruf. Für
// einen Zugang zur Konsole, die die ganze Flotte und jede
// Kundeninstanz verwaltet. Ein Passwort, das zwei Menschen kennen,
// gehört keinem von beiden.
//
// Die Zufallszeichenkette unten ist deshalb kein „temporäres
// Passwort", sondern das Gegenteil: sie entsteht in dieser Zeile, wird
// vom `hashed`-Cast verhasht und ist danach fort — sie steht in keiner
// Eigenschaft, in keiner Mail und in keinem Protokoll. Anmeldbar wird
// das Konto erst, wenn der Eingeladene über den Link sein eigenes
// Passwort vergibt (App\Livewire\Auth\OperatorSetPassword).
$newStaff = Operator::create([
'name' => $data['staffName'],
'email' => $data['staffEmail'],
'password' => Hash::make($temp),
'password' => Str::random(64),
]);
$newStaff->assignRole($data['staffRole']);
$this->invitedEmail = $data['staffEmail'];
$this->invitedPassword = $temp;
// Der Token kommt vom BETREIBER-Broker: eigene Tabelle, eigener
// Provider (R21, siehe config/auth.php). Nicht sendResetLink() —
// das verschickt Laravels eigene Benachrichtigung mit einem Link auf
// die Seite des PORTALS und wäre genau die Vermischung, die diese
// Trennung beendet hat.
$token = Password::broker('operators')->createToken($newStaff);
$mail = new OperatorInvitationMail(
$newStaff,
$operator->name,
route('admin.invitation', ['token' => $token, 'email' => $newStaff->email]),
OperatorInvitationMail::validHours(),
);
Mail::mailer($mail->mailer)->to($newStaff->email)->queue($mail);
$this->reset('staffName', 'staffEmail');
$this->staffRole = 'Support';
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
@ -645,6 +665,9 @@ class Settings extends Component
'roles' => Operator::operatorRoles(),
'canManageStaff' => $operator?->can('staff.manage') ?? false,
'isOwner' => $operator?->hasRole('Owner') ?? false,
// Damit der Hinweis unter dem Einladen-Formular dieselbe Frist
// nennt wie die Mail und der Broker — eine Quelle, drei Leser.
'inviteHours' => OperatorInvitationMail::validHours(),
// Only the tabs this operator can actually open. A tab that raises
// a 403 is worse than one that is not there.
'tabs' => array_values(array_filter(

View File

@ -0,0 +1,115 @@
<?php
namespace App\Livewire\Auth;
use App\Models\Operator;
use App\Support\PasswordPolicy;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* Das andere Ende des Einladungslinks: hier vergibt ein eingeladener Betreiber
* sein Passwort.
*
* Eigene Seite und nicht die des Portals (R21). Auch wenn beide Seiten dasselbe
* Formular zeigen, lösen sie gegen verschiedene Broker und damit gegen
* verschiedene Tabellen ein ein Betreiber-Token gilt hier und sonst nirgends,
* ein Kunden-Token nirgends hier. Eine gemeinsame Seite wäre genau die
* Vermischung, die R21 beendet hat.
*
* Der Token wird von Laravels Broker geprüft, nicht hier: er hält den Hash, die
* Frist und das Löschen nach dem Einlösen. Ein Link, der zweimal funktioniert,
* entsteht dadurch, dass jemand eines dieser drei Dinge nachbaut.
*
* Angemeldet wird danach NIEMAND. Der Link kam per E-Mail, und wer dieses
* Postfach lesen kann, bekäme sonst die Sitzung gleich dazu auf einer
* Konsole, die die ganze Flotte verwaltet. Der Eingeladene tippt das Passwort
* einmal mehr auf der Anmeldeseite, und dort greift dann auch die
* Zwei-Faktor-Pflicht, falls der Inhaber sie eingeschaltet hat.
*/
#[Layout('layouts.portal')]
class OperatorSetPassword extends Component
{
public string $token = '';
#[Validate('required|email|max:255')]
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public bool $done = false;
/**
* Die Länge kommt aus PasswordPolicy, nicht aus einem `#[Validate]`-Attribut:
* ein Attribut ist ein fester String und kann eine Einstellung nicht lesen.
*
* @return array<string, mixed>
*/
protected function rules(): array
{
return ['password' => [...PasswordPolicy::rules(), 'confirmed']];
}
public function mount(string $token): void
{
$this->token = $token;
// Aus der Adresszeile vorbelegt, aber als Feld sichtbar: eine Mail, die
// unterwegs umgebrochen wurde, lässt sich so noch von Hand retten.
$this->email = (string) request()->query('email', '');
}
/**
* NICHT reset(): Livewire\Component::reset() leert Eigenschaften, und eine
* Komponente, die sie neu deklariert, lädt gar nicht erst.
*/
public function save(): void
{
$this->validate();
$status = Password::broker('operators')->reset(
[
'email' => $this->email,
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
'token' => $this->token,
],
function (Operator $operator, string $password) {
$operator->forceFill([
'password' => Hash::make($password),
// Jede bestehende „Angemeldet bleiben"-Sitzung stirbt mit.
// Beim ersten Setzen ist keine da; beim zweiten Weg über
// diese Seite — einer erneuten Einladung — sehr wohl.
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($operator));
},
);
if ($status !== Password::PASSWORD_RESET) {
// EINE Meldung für alle Fehlschläge, und ausdrücklich nicht
// __($status): Laravels Statusschlüssel (passwords.token,
// passwords.user) haben in dieser Installation keine
// Übersetzungsdatei und stünden roh als „passwords.token" auf der
// Seite. Sie zu unterscheiden wäre ohnehin falsch — „diese Adresse
// kennen wir nicht" verrät einem Fremden, wer Betreiber ist.
$this->addError('email', __('auth.console_invite_invalid'));
return;
}
$this->done = true;
}
public function render()
{
return view('livewire.auth.operator-set-password');
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\RidesALane;
use App\Mail\Concerns\SendsFromMailbox;
use App\Models\Operator;
use App\Services\Mail\MailCatalogue;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* „Sie wurden in die CluPilot-Konsole eingeladen."
*
* Diese Mail trägt AUSDRÜCKLICH kein Passwort. Vorher erzeugte die Konsole
* eines und zeigte es dem Inhaber einmal an, damit er es „sicher weitergibt"
* für einen Zugang, der die ganze Flotte und jede Kundeninstanz verwaltet. Ein
* Passwort, das zwei Menschen kennen, gehört keinem von beiden; und was einmal
* durch einen Chat gegangen ist, steht dort für immer.
*
* Sie trägt deshalb nur zweierlei: den Link, an dem der Eingeladene sein
* eigenes Passwort vergibt, und den NAMEN dessen, der eingeladen hat. Der Name
* ist kein Beiwerk: eine Mail, die einem Menschen einen Zugang zu einer Konsole
* anbietet, ist von aussen nicht von einem Phishing-Versuch zu unterscheiden
* ausser dadurch, dass darin steht, wen er gerade danach fragen kann.
*/
class OperatorInvitationMail extends Mailable implements ShouldQueue
{
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
public function __construct(
public Operator $operator,
public string $invitedBy,
public string $url,
public int $hours,
) {
$this->mailer(MailCatalogue::mailer('operator-invitation'));
}
/**
* Wie lange der Link in dieser Mail gilt, in Stunden.
*
* Aus der Broker-Einstellung gelesen statt danebengeschrieben: die Zahl
* steht im Text der Mail, im Hinweis unter dem Einladen-Formular und in der
* Vorschau drei Orte mit derselben Zahl sind zwei Orte, an denen sie
* falsch wird. Die Frist selbst ist in config/auth.php begründet.
*/
public static function validHours(): int
{
return (int) round(((int) config('auth.passwords.operators.expire', 60)) / 60);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('operator_invitation.subject'), 'operator-invitation');
}
public function content(): Content
{
return new Content(view: 'mail.operator-invitation', with: [
'name' => $this->operator->name,
'invitedBy' => $this->invitedBy,
'url' => $this->url,
'hours' => $this->hours,
]);
}
}

View File

@ -63,6 +63,29 @@ class Operator extends Authenticatable
];
}
/**
* Für Betreiber gibt es keinen Selbstbedienungs-Weg zum Passwort.
*
* Der Broker `operators` beherrscht `sendResetLink()`, und die Vorgabe aus
* `CanResetPassword` verschickt dann Laravels eigene Benachrichtigung mit
* einem Link auf `password.reset`, die Seite des PORTALS, die gegen den
* KUNDEN-Broker einlöst. Ein Betreiber-Token landete damit auf der
* Kundenseite und liefe dort ins Leere: R21 an der unauffälligsten Stelle,
* an der er brechen kann, und zwar still.
*
* Deshalb laut statt still. Die Einladung geht über
* App\Livewire\Admin\Settings::inviteStaff(), die den Token selbst zieht
* und ihre eigene Mail verschickt; eine Seite „Passwort vergessen" für
* Betreiber gibt es (noch) nicht. Wer sie baut, überschreibt diese Methode
* mit einer Mail, die auf `admin.*` zeigt und löscht diesen Kommentar.
*/
public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
{
throw new \RuntimeException(
'Betreiber haben keinen Selbstbedienungs-Weg zum Passwort: Laravels Vorgabe verlinkt auf die Portal-Seite password.reset (R21).'
);
}
/** True when this operator holds a role that reaches the console. */
public function isOperator(): bool
{

View File

@ -24,6 +24,12 @@ final class MailCatalogue
return [
'verify-email' => ['label' => 'E-Mail bestätigen (Registrierung)', 'purpose' => MailPurpose::SYSTEM],
'reset-password' => ['label' => 'Passwort zurücksetzen', 'purpose' => MailPurpose::SYSTEM],
// Die Einladung in die Konsole. SYSTEM wie ihre beiden Nachbarn:
// eine Mail über den ZUGANG selbst, nicht über einen Kauf, einen
// Betriebsvorgang oder eine Supportantwort. Sie trägt kein
// Passwort, nur einen Link, an dem der Eingeladene sein eigenes
// vergibt — siehe App\Mail\OperatorInvitationMail.
'operator-invitation' => ['label' => 'Einladung in die Konsole', 'purpose' => MailPurpose::SYSTEM],
'order-confirmation' => ['label' => 'Bestellbestätigung', 'purpose' => MailPurpose::BILLING],
'cloud-ready' => ['label' => 'Cloud ist bereit (Zugangsdaten)', 'purpose' => MailPurpose::PROVISIONING],
'maintenance-announcement' => ['label' => 'Wartungsfenster angekündigt', 'purpose' => MailPurpose::MAINTENANCE],

View File

@ -11,6 +11,7 @@ use App\Mail\InvoiceMail;
use App\Mail\MaintenanceAnnouncementMail;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\NewDeviceSignInMail;
use App\Mail\OperatorInvitationMail;
use App\Mail\OperatorMessageMail;
use App\Mail\OrderConfirmationMail;
use App\Mail\ResetPasswordMail;
@ -41,7 +42,7 @@ final class MailLane
public const CALM = 'mail-ruhig';
/**
* Die sieben, auf die gerade jemand wartet.
* Die acht, auf die gerade jemand wartet.
*
* Sie entstehen einzeln, weil ein einzelner Mensch geklickt hat, und
* können gar keinen Schub bilden. Drosseln nützt dort nichts und kostet
@ -56,6 +57,11 @@ final class MailLane
ContactRequestMail::class,
OrderConfirmationMail::class,
OperatorMessageMail::class,
// Die Einladung in die Konsole. Der Inhaber hat gerade geklickt und
// sagt dem Eingeladenen im selben Moment „schau in dein Postfach" —
// eine Einladung, die eine Viertelstunde in der ruhigen Spur liegt,
// ist ein Anruf.
OperatorInvitationMail::class,
];
/** @var array<class-string, string> */

View File

@ -12,6 +12,7 @@ use App\Mail\InvoiceMail;
use App\Mail\MaintenanceAnnouncementMail;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\NewDeviceSignInMail;
use App\Mail\OperatorInvitationMail;
use App\Mail\OperatorMessageMail;
use App\Mail\OrderConfirmationMail;
use App\Mail\ResetPasswordMail;
@ -21,6 +22,7 @@ use App\Models\Customer;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\MaintenanceWindow;
use App\Models\Operator;
use App\Models\Order;
use App\Models\SecurityBlock;
use App\Models\User;
@ -91,6 +93,16 @@ class MailPreviews
return match ($key) {
'verify-email' => new VerifyEmailMail($user),
'reset-password' => new ResetPasswordMail($user, 'https://example.test/reset/beispiel-token', 60),
'operator-invitation' => new OperatorInvitationMail(
$this->invitedOperator(),
'Ines Inhaber',
'https://example.test/admin/invitation/beispiel-token',
// Aus der Broker-Einstellung, nicht als Zahl daneben: eine
// Vorschau, die eine andere Frist nennt als die echte Mail,
// ist genau die Sorte Beleg, auf die sich niemand verlassen
// kann.
OperatorInvitationMail::validHours(),
),
'order-confirmation' => new OrderConfirmationMail($this->order($customer), $customer->name),
'cloud-ready' => $this->cloudReady($customer),
'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer),
@ -146,6 +158,20 @@ class MailPreviews
->forceFill(['id' => 0, 'email_verified_at' => null]);
}
/**
* Der eingeladene Betreiber. make(), nie create() wie alles hier.
*
* Ein Betreiber und kein User: die Einladung geht in die Konsole, und die
* beiden Personengruppen teilen keine Identität (R21). Ein Beispiel, das
* die falsche Klasse benutzt, wäre eine Vorschau auf eine Mail, die es so
* nie gibt.
*/
private function invitedOperator(): Operator
{
return Operator::make(['name' => 'Tom Neu', 'email' => 'tom@clupilot.test'])
->forceFill(['id' => 0]);
}
private function order(Customer $customer): Order
{
return Order::make([

View File

@ -1,5 +1,6 @@
<?php
use App\Models\Operator;
use App\Models\User;
return [
@ -83,7 +84,7 @@ return [
'operators' => [
'driver' => 'eloquent',
'model' => App\Models\Operator::class,
'model' => Operator::class,
],
],
@ -114,10 +115,31 @@ return [
'throttle' => 60,
],
// Die Betreiber, mit EIGENER Tabelle (R21).
//
// Hier stand `password_reset_tokens` — die Tabelle der Kunden. Ein
// Token ist nur ein Zeilenschlüssel: teilen sich beide Personengruppen
// die Tabelle, kann ein Token der einen auf der Seite der anderen
// eingelöst werden. Dass die Adressen sich heute nicht überschneiden
// (Admin\Settings weist eine Kundenadresse ab), ist eine Prüfung an
// einer Stelle, keine Struktur.
//
// `expire` ist hier die Frist des EINLADUNGSLINKS, nicht die einer
// Selbstbedienungs-Rücksetzung: den Weg gibt es für Betreiber (noch)
// nicht, dieser Broker stellt heute ausschliesslich Einladungen aus.
// 4320 Minuten sind 72 Stunden und decken ein Wochenende ab — am
// Freitagnachmittag verschickt, am Montagfrüh noch brauchbar. Kürzer,
// und jede Einladung vor einem freien Tag ist beim Öffnen schon tot;
// deutlich länger, und ein altes Postfach hält auf Dauer einen
// Schlüssel zur Betreiberkonsole, die die ganze Flotte verwaltet.
//
// Kommt später eine Seite „Passwort vergessen" für Betreiber dazu,
// gehört ihr eine kürzere Frist — dann braucht sie einen eigenen
// Broker über derselben Tabelle, statt diese Zahl zu verbiegen.
'operators' => [
'provider' => 'operators',
'table' => 'password_reset_tokens',
'expire' => 60,
'table' => 'operator_password_reset_tokens',
'expire' => 4320,
'throttle' => 60,
],
],

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Eine eigene Tabelle für die Zurücksetz-Token der Betreiber.
*
* R21 Konsole und Portal teilen keine Identität. Der Broker `operators` stand
* bis hierher auf `password_reset_tokens`, also auf der Tabelle der Kunden. Ein
* Token ist nur ein Zeilenschlüssel: teilen sich beide Personengruppen die
* Tabelle, lässt sich ein Betreiber-Token auf der Kundenseite einlösen und
* umgekehrt. Dass die Adressen sich heute nicht überschneiden (Admin\Settings
* weist eine Kundenadresse ab), ist eine Prüfung an EINER Stelle keine
* Struktur, und genau diese Sorte Prüfung ist es, die beim nächsten Einstieg
* in die Tabelle vergessen wird.
*
* Derselbe Aufbau wie die Kundentabelle (E-Mail als Schlüssel, Token, Zeit):
* Laravels DatabaseTokenRepository erwartet genau diese drei Spalten, und eine
* abweichende Form wäre ein eigener Repository-Typ ohne einen Grund dafür.
*
* Nichts zu übernehmen: einen Weg, auf dem ein Betreiber-Token entstanden
* wäre, gab es vorher nicht die Tabelle startet leer.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('operator_password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
public function down(): void
{
Schema::dropIfExists('operator_password_reset_tokens');
}
};

View File

@ -26,11 +26,11 @@ return [
'staff_sub' => 'Mitarbeiter einladen und deren Rolle festlegen. Nur der Inhaber kann das Team verwalten.',
'role' => 'Rolle',
'invite' => 'Einladen',
'invite_hint' => 'Der Zugang wird angelegt; die Einladungs-/Passwort-Zustellung folgt mit der E-Mail-Anbindung.',
'staff_invited' => 'Mitarbeiter angelegt.',
'temp_title' => 'Zugangsdaten (einmalig sichtbar)',
'temp_hint' => 'Teilen Sie diese Daten sicher mit dem Mitarbeiter. Beim späteren E-Mail-Setup wird ein Einladungslink versendet.',
'temp_password' => 'Temporäres Passwort',
// Kein Passwort mehr auf dem Bildschirm: der Eingeladene vergibt sein
// eigenes am Link aus der Mail. Die Stundenzahl kommt aus der
// Broker-Einstellung, damit hier nicht eine zweite Frist behauptet wird.
'invite_hint' => 'Wir schicken eine Einladung an diese Adresse. Der Link darin gilt :hours Stunden; das Passwort vergibt der Mitarbeiter selbst — niemand hier bekommt es zu sehen.',
'staff_invited' => 'Einladung verschickt.',
'col_person' => 'Person',
'col_actions' => 'Aktionen',

View File

@ -76,6 +76,17 @@ return [
'reset_new' => 'Neues Passwort',
'reset_repeat' => 'Neues Passwort wiederholen',
'reset_save' => 'Passwort speichern',
// Die eingeladene Betreiberin vergibt ihr Passwort — eigene Seite der
// Konsole (R21), eigener Broker, eigene Tabelle. Sie leiht sich die Felder
// von der Rücksetzseite (reset_new, reset_repeat, reset_save), weil es
// dieselben drei Eingaben sind; die Überschriften sind eigene, weil hier
// niemand etwas zurücksetzt, sondern zum ersten Mal eines vergibt.
'console_invite_title' => 'Passwort vergeben',
'console_invite_subtitle' => 'Sie wurden in die CluPilot-Konsole eingeladen. Vergeben Sie hier Ihr Passwort — ausser Ihnen kennt es dann niemand.',
'console_invite_done_title' => 'Passwort gesetzt',
'console_invite_done_body' => 'Melden Sie sich jetzt damit an der Konsole an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.',
'console_invite_invalid' => 'Dieser Link gilt nicht mehr. Einladungslinks laufen ab und lassen sich nur einmal verwenden — bitten Sie um eine neue Einladung.',
'reset_done_title' => 'Passwort geändert',
'reset_done_body' => 'Alle anderen offenen Anmeldungen wurden beendet. Melden Sie sich jetzt mit dem neuen Passwort an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.',

View File

@ -53,6 +53,7 @@ return [
'ContactRequestMail' => 'Kontaktanfrage von der Website',
'OrderConfirmationMail' => 'Bestellbestätigung',
'OperatorMessageMail' => 'Nachricht aus der Konsole',
'OperatorInvitationMail' => 'Einladung in die Konsole',
'MaintenanceAnnouncementMail' => 'Wartungsfenster angekündigt',
'MaintenanceCancelledMail' => 'Wartungsfenster abgesagt',
'CloudSuspendedMail' => 'Cloud abgeschaltet',

View File

@ -0,0 +1,16 @@
<?php
// Die Einladung in die Betreiberkonsole. Sie trägt kein Passwort — der
// Eingeladene vergibt sein eigenes am Link. Siehe App\Mail\OperatorInvitationMail.
return [
'subject' => 'Ihr Zugang zur CluPilot-Konsole',
'preheader' => 'Vergeben Sie Ihr Passwort — der Link gilt :hours Stunden.',
'heading' => 'Willkommen in der Konsole',
'greeting' => 'Guten Tag :name,',
'intro' => ':inviter hat Ihnen einen Zugang zur CluPilot-Konsole eingerichtet. Ein Passwort haben wir Ihnen nicht vergeben — das machen Sie selbst, über den Knopf unten. So kennt es ausser Ihnen niemand.',
'action' => 'Passwort vergeben',
'expiry' => 'Der Link gilt :hours Stunden und lässt sich nur einmal verwenden.',
'fallback' => 'Falls der Knopf nicht funktioniert, kopieren Sie diese Adresse in Ihren Browser:',
'network' => 'Die Konsole ist nur aus dem Verwaltungsnetz erreichbar. Wenn die Seite gar nicht antwortet, sind Sie nicht am falschen Link — dann fehlt Ihnen der Netzzugang. Fragen Sie :inviter danach.',
'not_you' => 'Sie haben damit nichts zu tun? Dann ignorieren Sie diese Nachricht — ohne den Link passiert nichts. Wir fragen Sie nie per E-Mail nach Ihrem Passwort.',
];

View File

@ -26,11 +26,8 @@ return [
'staff_sub' => 'Invite staff and set their role. Only the Owner can manage the team.',
'role' => 'Role',
'invite' => 'Invite',
'invite_hint' => 'The account is created; invitation/password delivery follows with the email integration.',
'staff_invited' => 'Staff member created.',
'temp_title' => 'Credentials (shown once)',
'temp_hint' => 'Share these securely with the staff member. A proper invitation link follows with the email integration.',
'temp_password' => 'Temporary password',
'invite_hint' => 'We send an invitation to this address. The link in it is valid for :hours hours; the staff member sets their own password — nobody here gets to see it.',
'staff_invited' => 'Invitation sent.',
'col_person' => 'Person',
'col_actions' => 'Actions',

View File

@ -76,6 +76,12 @@ return [
'reset_new' => 'New password',
'reset_repeat' => 'Repeat the new password',
'reset_save' => 'Save the password',
'console_invite_title' => 'Set your password',
'console_invite_subtitle' => 'You have been invited to the CluPilot console. Set your password here — nobody but you will know it.',
'console_invite_done_title' => 'Password set',
'console_invite_done_body' => 'Sign in to the console with it now — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.',
'console_invite_invalid' => 'This link is no longer valid. Invitation links expire and can only be used once — ask for a new invitation.',
'reset_done_title' => 'Password changed',
'reset_done_body' => 'Every other open session has been ended. Sign in now with the new password — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.',

View File

@ -47,6 +47,7 @@ return [
'ContactRequestMail' => 'Contact request from the website',
'OrderConfirmationMail' => 'Order confirmation',
'OperatorMessageMail' => 'Message from the console',
'OperatorInvitationMail' => 'Invitation to the console',
'MaintenanceAnnouncementMail' => 'Maintenance window announced',
'MaintenanceCancelledMail' => 'Maintenance window cancelled',
'CloudSuspendedMail' => 'Cloud suspended',

View File

@ -0,0 +1,16 @@
<?php
// The invitation to the operator console. It carries no password — the invited
// person sets their own at the link. See App\Mail\OperatorInvitationMail.
return [
'subject' => 'Your access to the CluPilot console',
'preheader' => 'Set your password — the link is valid for :hours hours.',
'heading' => 'Welcome to the console',
'greeting' => 'Hello :name,',
'intro' => ':inviter has set up console access for you at CluPilot. We have not issued you a password — you set your own with the button below, so that nobody but you knows it.',
'action' => 'Set a password',
'expiry' => 'The link is valid for :hours hours and can be used once.',
'fallback' => 'If the button does not work, copy this address into your browser:',
'network' => 'The console can only be reached from the management network. If the page does not answer at all, the link is not the problem — you are missing network access. Ask :inviter about it.',
'not_you' => 'Nothing to do with you? Then ignore this message — without the link nothing happens. We never ask for your password by email.',
];

View File

@ -569,18 +569,14 @@
<x-ui.icon name="plus" class="size-4" />{{ __('admin_settings.invite') }}
</x-ui.button>
</form>
<p class="text-xs text-muted">{{ __('admin_settings.invite_hint') }}</p>
@if ($invitedPassword)
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
<p class="text-sm font-semibold text-ink">{{ __('admin_settings.temp_title') }}</p>
<p class="mt-1 text-xs text-body">{{ __('admin_settings.temp_hint') }}</p>
<dl class="mt-2 space-y-1 font-mono text-sm text-ink">
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.email') }}:</dt><dd>{{ $invitedEmail }}</dd></div>
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.temp_password') }}:</dt><dd class="select-all">{{ $invitedPassword }}</dd></div>
</dl>
</div>
@endif
{{-- Hier stand ein Kasten, der dem Inhaber das erzeugte Passwort
des neuen Mitarbeiters einmal anzeigte, damit er es „sicher
weitergibt". Ein Passwort, das zwei Menschen kennen, gehört
keinem von beiden erst recht nicht auf einer Konsole, die
die ganze Flotte verwaltet. Es gibt jetzt keines mehr
anzuzeigen: der Eingeladene vergibt sein eigenes über den
Link aus der Einladungsmail. --}}
<p class="text-xs text-muted">{{ __('admin_settings.invite_hint', ['hours' => $inviteHours]) }}</p>
{{-- Staff list --}}
<div class="overflow-hidden rounded-lg border border-line">

View File

@ -0,0 +1,29 @@
<div class="flex min-h-screen items-center justify-center bg-bg px-6 py-12">
<div class="w-full max-w-sm animate-rise">
<x-ui.brand size="md" class="mb-9" />
@if ($done)
<div class="grid size-11 place-items-center rounded-lg bg-success-bg">
<x-ui.icon name="check" class="size-5 text-success" />
</div>
<h1 class="mt-5 text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.console_invite_done_title') }}</h1>
{{-- Absichtlich nicht automatisch angemeldet: der Link kam per
E-Mail, und wer dieses Postfach lesen kann, bekäme sonst die
Sitzung in der Konsole gleich dazu. --}}
<p class="mt-3 text-sm leading-relaxed text-muted">{{ __('auth.console_invite_done_body') }}</p>
<x-ui.button href="{{ route('admin.login') }}" variant="primary" class="mt-8 w-full">{{ __('auth.sign_in') }}</x-ui.button>
@else
<h1 class="text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.console_invite_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.console_invite_subtitle') }}</p>
<form wire:submit="save" class="mt-8 space-y-5">
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="username" wire:model="email" />
<x-ui.input name="password" type="password" :label="__('auth.reset_new')" autocomplete="new-password" wire:model="password" />
<x-ui.input name="password_confirmation" type="password" :label="__('auth.reset_repeat')" autocomplete="new-password" wire:model="password_confirmation" />
<x-ui.button type="submit" variant="primary" size="md" class="w-full"
wire:loading.attr="disabled" wire:target="save">{{ __('auth.reset_save') }}</x-ui.button>
</form>
@endif
</div>
</div>

View File

@ -0,0 +1,54 @@
<x-mail.layout
:heading="__('operator_invitation.heading')"
:preheader="__('operator_invitation.preheader', ['hours' => $hours])"
:greeting="$name ? __('operator_invitation.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('operator_invitation.intro', ['inviter' => $invitedBy]) }}</p>
</td></tr>
<tr><td style="padding:0 24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
<a href="{{ $url }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('operator_invitation.action') }}</a>
</td></tr>
</table>
<p style="margin:14px 0 0 0;font-size:13px;line-height:20px;color:#6e6e7a;">{!! __('operator_invitation.expiry', ['hours' => '<strong style="color:#43434e;font-weight:600;">'.$hours.'</strong>']) !!}</p>
</td></tr>
{{-- Der Link auch als Text, aus demselben Grund wie bei der Bestätigungs- und
der Rücksetzmail: Mail-Gateways in Firmen schreiben Knopf-Links um, und
manche holen das Ziel vorher ab, um es zu prüfen bei einem Link, der sich
nur einmal einlösen lässt, ist er damit verbraucht, bevor ihn jemand
angeklickt hat. --}}
<tr><td style="padding:28px 24px 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
<tr><td style="padding:14px 16px;">
<p style="margin:0 0 6px 0;font-size:12px;line-height:16px;color:#6e6e7a;">{{ __('operator_invitation.fallback') }}</p>
<p style="margin:0;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:18px;color:#b8500a;word-break:break-all;">{{ $url }}</p>
</td></tr>
</table>
</td></tr>
{{-- Die Konsole ist netzbeschränkt (RestrictConsoleNetwork): wer nicht im
Verwaltungsnetz sitzt, bekommt auf diesen Link ein 404 und nicht etwa eine
Fehlermeldung, die den Grund nennt. Ohne diesen Satz sähe das wie ein
kaputter Link aus, und der Eingeladene würde ihn wieder und wieder
anklicken, statt den zu fragen, der ihn geschickt hat. --}}
<tr><td style="padding:24px 24px 0 24px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('operator_invitation.network', ['inviter' => $invitedBy]) }}</p>
</td></tr>
{{-- Wer das nicht erwartet hat, soll NICHTS tun. Eine Mail, die einen Zugang
zu einer Verwaltungskonsole anbietet, ist der Bauplan jedes Phishing-
Versuchs deshalb steht hier dieselbe Regel wie auf der Rücksetzmail. --}}
<tr><td style="padding:24px 24px 32px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('operator_invitation.not_you') }}</p>
</td></tr>
</table>
</td></tr>
</x-mail.layout>

View File

@ -1,6 +1,7 @@
<?php
use App\Livewire\Auth\OperatorLogin;
use App\Livewire\Auth\OperatorSetPassword;
use App\Livewire\Auth\OperatorTwoFactorChallenge;
use Illuminate\Support\Facades\Route;
@ -13,3 +14,19 @@ use Illuminate\Support\Facades\Route;
*/
Route::get('/login', OperatorLogin::class)->name('login');
Route::get('/two-factor', OperatorTwoFactorChallenge::class)->name('two-factor');
/*
| Die Einladung: hier vergibt ein neu angelegter Betreiber sein Passwort.
|
| In der GAST-Gruppe der Konsole und nicht in routes/admin.php die liegt
| hinter `auth:operator,web`, und wer diesen Link öffnet, hat noch gar keinen
| Zugang. Es bleibt trotzdem eine Route der Konsole und keine des Portals: sie
| heisst `admin.invitation`, sie liegt unter demselben Präfix beziehungsweise
| Hostnamen wie der Rest der Konsole, und sie durchläuft damit denselben
| `web`-Stapel also RestrictAdminHost (auf welchem Namen die Konsole
| antwortet) UND RestrictConsoleNetwork (wer sie überhaupt erreichen darf).
| Ein Einladungslink führt deshalb bewusst in ein Netz, das der Eingeladene
| erst haben muss; die Mail sagt das ausdrücklich, damit ein 404 nicht wie ein
| kaputter Link aussieht.
*/
Route::get('/invitation/{token}', OperatorSetPassword::class)->name('invitation');

View File

@ -4,7 +4,7 @@ use App\Livewire\Admin\Settings;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
// operator() helper defined in RbacTest.
@ -29,7 +29,14 @@ it('will not save an account email that collides with an orphaned portal login',
->assertHasErrors(['email']);
});
// Was beim Einladen mit dem PASSWORT passiert — es entsteht keines, das
// jemand weitergeben könnte, und der Link geht per Mail hinaus — steht in
// tests/Feature/Auth/OperatorInvitationTest.php. Hier bleibt nur, was diese
// Seite selbst zusichert: wer einladen darf, mit welcher Rolle, und gegen
// welche Adressen sie sich wehrt.
it('lets an Owner invite staff with a role', function () {
Mail::fake();
Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support')
->call('inviteStaff')->assertHasNoErrors();
@ -38,17 +45,6 @@ it('lets an Owner invite staff with a role', function () {
expect($sam)->not->toBeNull()->and($sam->hasRole('Support'))->toBeTrue();
});
it('surfaces a usable temporary password for the invited staff', function () {
$c = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support')
->call('inviteStaff');
$temp = $c->get('invitedPassword');
expect($temp)->not->toBeNull();
$ivy = Operator::query()->where('email', 'ivy@ops.test')->first();
expect(Hash::check($temp, $ivy->password))->toBeTrue();
});
it('forbids a non-Owner from managing staff', function () {
Livewire::actingAs(operator('Admin'), 'operator')->test(Settings::class)
->set('staffName', 'X')->set('staffEmail', 'x@ops.test')->set('staffRole', 'Support')

View File

@ -0,0 +1,384 @@
<?php
use App\Livewire\Admin\Settings;
use App\Livewire\Auth\OperatorLogin;
use App\Livewire\Auth\OperatorSetPassword;
use App\Livewire\Auth\ResetPassword;
use App\Mail\OperatorInvitationMail;
use App\Models\Operator;
use App\Models\User;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;
/**
* Ein eingeladener Betreiber vergibt sein Passwort selbst.
*
* Vorher erzeugte die Konsole eines und zeigte es dem Inhaber einmal an, damit
* er es „sicher weitergibt" — per Chat, per Zuruf — für einen Zugang, der die
* ganze Flotte und jede Kundeninstanz verwaltet. Jetzt geht eine Einladung
* ohne Passwort hinaus, und niemand kennt ein fremdes.
*
* Die tragende Regel dabei ist R21: Konsole und Portal teilen keine Identität,
* also auch keine Token-Tabelle, keinen Broker und keine Seite.
*/
/**
* Lädt jemanden ein und gibt das angelegte Konto zurück.
*
* Meldet den Inhaber danach ab. Das ist keine Kosmetik: alles, was auf eine
* Einladung folgt, passiert bei einem ANDEREN Menschen an einem anderen
* Rechner, und ein Test, der die Sitzung des Inhabers stehen lässt, prüft den
* Weg des Eingeladenen mit fremden Rechten.
*/
function invite(string $email = 'sam@ops.test', string $role = 'Support'): Operator
{
Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
->set('staffName', 'Sam')->set('staffEmail', $email)->set('staffRole', $role)
->call('inviteStaff')->assertHasNoErrors();
auth('operator')->logout();
return Operator::query()->where('email', $email)->firstOrFail();
}
// ---- Was das Konto beim Anlegen ist: noch niemandes ----
it('legt ein Konto an, in das sich niemand anmelden kann', function () {
Mail::fake();
$sam = invite();
// Die halbe Zusicherung, und sie muss halten: es gibt kein Passwort, das
// dieses Konto öffnet — auch keines, das jemand raten würde, weil er den
// Code gelesen hat.
foreach (['', 'password', 'Sam', 'sam@ops.test', 'clupilot'] as $versuch) {
expect(Hash::check($versuch, $sam->password))->toBeFalse("[{$versuch}] öffnet das Konto");
}
Livewire::test(OperatorLogin::class)
->set('email', 'sam@ops.test')->set('password', 'password')
->call('authenticate')->assertHasErrors('email');
expect(auth('operator')->check())->toBeFalse();
});
it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite', function () {
// Der eigentliche Fehler war nicht das Passwort, sondern dass es auf einem
// Bildschirm stand. Deshalb wird hier nicht auf die zwei gelöschten
// Eigenschaften geprüft (die kämen unter anderem Namen wieder), sondern
// darauf, dass KEINE öffentliche Eigenschaft dieser Seite das Passwort des
// eben angelegten Kontos ist.
Mail::fake();
$seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support')
->call('inviteStaff');
$ivy = Operator::query()->where('email', 'ivy@ops.test')->firstOrFail();
$instanz = $seite->instance();
foreach ((new ReflectionObject($instanz))->getProperties(ReflectionProperty::IS_PUBLIC) as $eigenschaft) {
$wert = $eigenschaft->getValue($instanz);
if (! is_string($wert) || $wert === '') {
continue;
}
expect(Hash::check($wert, $ivy->password))->toBeFalse(
"Die Eigenschaft {$eigenschaft->getName()} ist das Passwort des neuen Kontos.",
);
}
});
// ---- Was hinausgeht: ein Link, kein Passwort ----
it('schickt eine Einladung mit dem Link und dem Namen des Einladenden', function () {
Mail::fake();
$inhaber = operator('Owner');
Livewire::actingAs($inhaber, 'operator')->test(Settings::class)
->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support')
->call('inviteStaff')->assertHasNoErrors();
Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($inhaber) {
$html = $mail->render();
return $mail->hasTo('sam@ops.test')
// Der Link zeigt auf die Seite der KONSOLE, nicht auf die des
// Portals — das ist R21 im Ergebnis statt in der Absicht.
&& str_contains($mail->url, '/admin/invitation/')
&& str_contains($html, $mail->url)
// Wer eingeladen hat, steht darin: eine Mail, die einem Menschen
// einen Konsolenzugang anbietet, ist sonst von einem
// Phishing-Versuch nicht zu unterscheiden.
&& str_contains($html, $inhaber->name);
});
});
it('trägt kein Passwort in der Mail', function () {
Mail::fake();
$sam = invite();
Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($sam) {
// Jedes Wort ab acht Zeichen aus der fertigen Mail gegen den Hash des
// Kontos gehalten. Fiele irgendwo ein Passwort hinein — im Fliesstext,
// in einem Kasten, im Betreff — schlüge genau eines davon an.
preg_match_all('/[^\s<>"\']{8,}/', strip_tags($mail->render()), $treffer);
foreach ($treffer[0] as $wort) {
expect(Hash::check($wort, $sam->password))->toBeFalse("[{$wort}] ist das Passwort des Kontos");
}
return true;
});
});
// ---- R21: eigene Tabelle, eigener Broker, eigene Seite ----
it('legt den Token in die Tabelle der Betreiber und nicht in die der Kunden', function () {
Mail::fake();
invite();
expect(DB::table('operator_password_reset_tokens')->count())->toBe(1)
// Ein Token ist nur ein Zeilenschlüssel. Läge er in der Kundentabelle,
// wäre die Trennung der beiden Personengruppen genau hier aufgehoben.
->and(DB::table('password_reset_tokens')->count())->toBe(0);
});
it('löst einen Kunden-Token nicht auf der Konsolenseite ein', function () {
// Die Adressen dürfen sich heute nicht überschneiden — Admin\Settings weist
// eine Kundenadresse ab. Das ist eine Prüfung an EINER Stelle, keine
// Struktur, also wird sie hier absichtlich umgangen: beide Datensätze
// entstehen direkt.
$betreiber = Operator::factory()->role('Support')->create([
'email' => 'doppelt@ops.test', 'password' => 'passwort-des-betreibers',
]);
$kunde = User::factory()->create([
'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden',
]);
$kundenToken = Password::broker()->createToken($kunde);
Livewire::test(OperatorSetPassword::class, ['token' => $kundenToken])
->set('email', 'doppelt@ops.test')
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasErrors('email')
->assertSet('done', false);
expect(Hash::check('passwort-des-betreibers', $betreiber->fresh()->password))->toBeTrue()
->and(Hash::check('ein-langes-neues-passwort', $betreiber->fresh()->password))->toBeFalse();
});
it('löst einen Betreiber-Token nicht auf der Portalseite ein', function () {
// Die Gegenrichtung. Sie ist die gefährlichere: ein Token aus der Konsole,
// eingelöst im Portal, setzte das Passwort eines KUNDEN.
$betreiber = Operator::factory()->role('Support')->create(['email' => 'doppelt@ops.test']);
$kunde = User::factory()->create([
'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden',
]);
$betreiberToken = Password::broker('operators')->createToken($betreiber);
Livewire::test(ResetPassword::class, ['token' => $betreiberToken])
->set('email', 'doppelt@ops.test')
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasErrors('email')
->assertSet('done', false);
expect(Hash::check('passwort-des-kunden', $kunde->fresh()->password))->toBeTrue();
});
it('schickt einem Betreiber nicht Laravels eigene Rücksetzmail', function () {
// Die Vorgabe aus CanResetPassword verlinkt auf `password.reset` — die
// Seite des PORTALS. Ein Betreiber-Token landete damit auf der Kundenseite.
// Konkrete Klasse, nicht Throwable: eine Zusicherung auf eine Schnittstelle
// beweist nichts.
$betreiber = Operator::factory()->role('Support')->create();
expect(fn () => $betreiber->sendPasswordResetNotification('irgendein-token'))
->toThrow(RuntimeException::class);
});
// ---- Der Link: einlösen, verbrauchen, ablaufen ----
it('setzt das Passwort und lässt den Eingeladenen sich damit anmelden', function () {
Mail::fake();
$sam = invite();
$token = Password::broker('operators')->createToken($sam);
Livewire::test(OperatorSetPassword::class, ['token' => $token])
->set('email', 'sam@ops.test')
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasNoErrors()
->assertSet('done', true);
expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeTrue();
Livewire::test(OperatorLogin::class)
->set('email', 'sam@ops.test')->set('password', 'ein-langes-neues-passwort')
->call('authenticate')->assertHasNoErrors();
expect(auth('operator')->id())->toBe($sam->id);
});
it('meldet niemanden an, nur weil er den Link geöffnet hat', function () {
// Der Link kam per E-Mail. Wer dieses Postfach lesen kann, bekäme sonst die
// Sitzung in der Konsole gleich dazu.
Mail::fake();
$sam = invite();
Livewire::test(OperatorSetPassword::class, ['token' => Password::broker('operators')->createToken($sam)])
->set('email', 'sam@ops.test')
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save');
expect(auth('operator')->check())->toBeFalse();
});
it('verbraucht den Link, damit eine weitergeleitete Mail ihn nicht zweimal öffnet', function () {
Mail::fake();
$sam = invite();
$token = Password::broker('operators')->createToken($sam);
$einlösen = fn () => Livewire::test(OperatorSetPassword::class, ['token' => $token])
->set('email', 'sam@ops.test')
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save');
$einlösen()->assertSet('done', true);
$einlösen()->assertSet('done', false)->assertHasErrors('email');
});
it('lässt den Link ablaufen, statt ihn in einem alten Postfach liegen zu lassen', function () {
// Ein Einladungslink, der ein Jahr später noch gilt, ist ein Schlüssel zur
// Betreiberkonsole in einem Postfach, an das niemand mehr denkt.
Mail::fake();
$sam = invite();
$token = Password::broker('operators')->createToken($sam);
$stunden = OperatorInvitationMail::validHours();
// Eine Stunde vor Ablauf: gilt noch.
$this->travel($stunden - 1)->hours();
Livewire::test(OperatorSetPassword::class, ['token' => $token])
->set('email', 'sam@ops.test')
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertSet('done', true);
// Und derselbe Vorgang jenseits der Frist: ein frischer Token, eine Stunde
// nach Ablauf.
$frisch = Password::broker('operators')->createToken($sam);
$this->travel($stunden + 1)->hours();
Livewire::test(OperatorSetPassword::class, ['token' => $frisch])
->set('email', 'sam@ops.test')
->set('password', 'ein-noch-viel-neueres-passwort')
->set('password_confirmation', 'ein-noch-viel-neueres-passwort')
->call('save')
->assertSet('done', false)
->assertHasErrors('email');
expect(Hash::check('ein-noch-viel-neueres-passwort', $sam->fresh()->password))->toBeFalse();
});
it('hält die Frist in Tagen, nicht in Monaten', function () {
// Der Test darüber prüft den MECHANISMUS: was auch immer eingestellt ist,
// läuft ab. Er würde eine Frist von einem Jahr klaglos mittragen, weil er
// die Zahl aus derselben Einstellung liest. Die Grössenordnung ist aber
// keine Geschmacksfrage: nach oben ist ein Link, der Wochen später noch
// gilt, ein Schlüssel zur Betreiberkonsole in einem Postfach, an das
// niemand mehr denkt; nach unten wird eine Einladung, die vor dem
// Wochenende abläuft, nie geöffnet. Die Zahl dazwischen ist in
// config/auth.php begründet.
expect(OperatorInvitationMail::validHours())->toBeGreaterThanOrEqual(24)
->and(OperatorInvitationMail::validHours())->toBeLessThanOrEqual(7 * 24);
});
it('nennt keinen rohen Statusschlüssel, wenn der Link nicht gilt', function () {
// `passwords.token` hat in dieser Installation keine Übersetzungsdatei und
// stünde roh mitten auf der Seite.
Mail::fake();
invite();
Livewire::test(OperatorSetPassword::class, ['token' => 'gibt-es-nicht'])
->set('email', 'sam@ops.test')
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasErrors('email')
->assertSee(__('auth.console_invite_invalid'));
});
// ---- Wo der Link hinführt: in die Konsole, hinter ihre Schranken ----
it('legt die Einladungsseite hinter dieselbe Netzbeschränkung wie den Rest der Konsole', function () {
// Der Link führt in ein Netz, das der Eingeladene erreichen können muss —
// und ein Fremder eben nicht. 404, nie 403: dieselbe Haltung wie überall
// sonst in der Konsole.
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
AppSettings::set('console.network_restricted', true);
AppSettings::set('console.allowed_ips', []);
$ziel = route('admin.invitation', ['token' => 'beispiel-token', 'email' => 'sam@ops.test']);
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->get($ziel)->assertNotFound();
$this->withServerVariables(['REMOTE_ADDR' => '10.66.0.5'])->get($ziel)->assertOk();
});
it('führt die Einladung über eine Route der Konsole, nicht über eine des Portals', function () {
// Nicht der Pfad, sondern der NAME: er entscheidet in RestrictAdminHost,
// ob eine Route als Konsolenroute gilt.
$route = app('router')->getRoutes()->getByName('admin.invitation');
expect($route)->not->toBeNull()
->and($route->getName())->toStartWith('admin.')
// Im Gast-Teil: wer den Link öffnet, hat noch keinen Zugang, und
// routes/admin.php liegt hinter `auth:operator,web`.
->and($route->gatherMiddleware())->toContain('guest:operator')
->and($route->gatherMiddleware())->not->toContain('auth:operator,web');
});
// ---- Was die Seite nicht mehr anbietet ----
it('zeigt auf der Team-Seite keine Zugangsdaten mehr an', function () {
Mail::fake();
$html = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
->set('tab', 'team')
->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support')
->call('inviteStaff')
->html();
// Die drei Zeilen, die den Kasten beschriftet haben, gibt es nicht mehr —
// in keiner der beiden Sprachen.
foreach (['admin_settings.temp_title', 'admin_settings.temp_hint', 'admin_settings.temp_password'] as $schlüssel) {
expect(Lang::has($schlüssel, 'de'))->toBeFalse("{$schlüssel} steht noch in lang/de")
->and(Lang::has($schlüssel, 'en'))->toBeFalse("{$schlüssel} steht noch in lang/en");
}
expect($html)->toContain(e(__('admin_settings.invite_hint', ['hours' => OperatorInvitationMail::validHours()])));
});