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
parent
8174ad77cc
commit
7edd88b71c
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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> */
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()])));
|
||||
});
|
||||
Loading…
Reference in New Issue