auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)

- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner,
  Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit
  granted_via = api; Befehl company-memberships:api-assignments (Liste,
  --remove mit Vorschau)
- K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort,
  remember_token und Sanctum-Tokens; SessionRevocation + Middleware
  EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich)
- K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at
  und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google
  ohne erzwungenes remember, magic-login ohne guest für eigene Links
- B-10: eigener Magic-Link-Zweck image_confirmation (Migration)
- K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden
  wird abgelehnt, Gate-Abfragen je Request zwischengespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent 3dbc3fa673
commit 1ccb4a8479
29 changed files with 1287 additions and 73 deletions

View file

@ -0,0 +1,224 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Company\ApiNewsroomAssignment;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Bestandsprüfung der Mitgliedschaften aus der API-Zuordnung (Roadmap 1a.4,
* Umsetzungsaudit 29.09.2026, R-01).
*
* Bis zum Fix hängte `POST /service/users/me/companies` jeden Token-Nutzer
* mit passender Firmen-E-Mail und passendem Namen als `member` an eine
* bestehende Firma — also auch fremde Einlieferer. Grundlage der Liste sind
* die Aktivitäts-Einträge „API-Firma … zugeordnet" seit dem Deploy von 1a.4
* (15.09.2026) und alle Pivots mit `granted_via = api`. Als „über die
* API entstanden" gilt ein `member`-Pivot, der mit oder nach der ersten
* Zuordnung angelegt wurde; wer vorher schon Mitglied war, wird nicht
* angefasst.
*
* Standard ist die reine Liste. `--remove` entfernt nach Vorschau und
* Rückfrage die Pivots von Nutzern, die weder freigeschaltet
* (`newsroom.api_assignment.allowed_user_ids`) noch Owner der Firma sind.
* Die Meldungen, die sie dort angelegt haben, bleiben über die Autorenschaft
* erreichbar.
*/
class ReviewApiCompanyAssignments extends Command
{
protected $signature = 'company-memberships:api-assignments
{--since=2026-09-15 : Aktivitäts-Einträge der API-Zuordnung ab diesem Datum auswerten (Deploy von 1a.4)}
{--remove : Mitgliedschaften nicht freigeschalteter Einlieferer entfernen (mit Vorschau und Rückfrage)}
{--force : Rückfrage bei --remove überspringen (nicht interaktiv)}';
protected $description = 'Listet Firmen-Mitgliedschaften aus der API-Zuordnung und entfernt auf Wunsch die nicht freigeschalteter Einlieferer.';
/**
* Karenz zwischen Pivot und Aktivitäts-Eintrag: beide entstehen im selben
* Request, der Pivot wenige Millisekunden vorher.
*/
private const EVENT_TOLERANCE_SECONDS = 60;
public function handle(ActivityLog $activityLog): int
{
$rows = $this->assignmentRows(Carbon::parse((string) $this->option('since'))->startOfDay());
$foreign = $rows->reject(fn (object $row): bool => $row->allowed)->values();
$this->info(sprintf(
'%d über die API-Zuordnung entstandene Mitgliedschaft(en), davon %d ohne Freischaltung.',
$rows->count(),
$foreign->count(),
));
if ($rows->isNotEmpty()) {
$this->table(
['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'],
$rows->take(100)->map(fn (object $row): array => $this->tableRow($row))->all(),
);
}
if (! $this->option('remove')) {
if ($foreign->isNotEmpty()) {
$this->line('Entfernen mit --remove (zeigt vorher die betroffenen Zeilen).');
}
return self::SUCCESS;
}
if ($foreign->isEmpty()) {
$this->info('Nichts zu entfernen.');
return self::SUCCESS;
}
$this->warn(sprintf('Zu entfernen (%d):', $foreign->count()));
$this->table(
['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'],
$foreign->map(fn (object $row): array => $this->tableRow($row))->all(),
);
if (! $this->option('force') && ! $this->confirm(sprintf('%d Mitgliedschaft(en) entfernen?', $foreign->count()))) {
$this->line('Abgebrochen, nichts geändert.');
return self::SUCCESS;
}
$removed = 0;
foreach ($foreign as $row) {
$deleted = DB::table('company_user')
->where('company_id', $row->company_id)
->where('user_id', $row->user_id)
->where('role', 'member')
->delete();
if ($deleted === 0) {
continue;
}
$removed++;
$activityLog->record(
ActivityType::Correction,
sprintf('API-Mitgliedschaft von User #%d (%s) an Firma #%d „%s“ entfernt (Audit R-01)', $row->user_id, $row->email, $row->company_id, $row->company_name),
subject: Company::withoutGlobalScope(PortalScope::class)->find($row->company_id),
meta: ['api_assignment_removed' => true, 'user_id' => $row->user_id, 'granted_via' => $row->granted_via],
actorLabel: 'console',
);
}
$this->info(sprintf('%d Mitgliedschaft(en) entfernt.', $removed));
return self::SUCCESS;
}
/**
* Pivots aus der API-Zuordnung: aus dem Aktivitäts-Log (Altfälle ohne
* Herkunft) und mit `granted_via = api` (ab dem Fix).
*
* @return Collection<int, object>
*/
private function assignmentRows(Carbon $since): Collection
{
/** @var array<string, Carbon> $firstEvents "company:user" => erste Zuordnung */
$firstEvents = [];
ActivityEvent::query()
->where('type', ActivityType::Correction->value)
->where('subject_type', (new Company)->getMorphClass())
->where('description', 'like', 'API-Firma %')
->where('occurred_at', '>=', $since)
->whereNotNull('user_id')
->orderBy('id')
->select(['id', 'user_id', 'subject_id', 'occurred_at'])
->chunkById(1000, function (Collection $events) use (&$firstEvents): void {
foreach ($events as $event) {
$key = $event->subject_id.':'.$event->user_id;
$firstEvents[$key] ??= $event->occurred_at;
}
});
$pairs = collect(array_keys($firstEvents))->map(fn (string $key): array => array_map('intval', explode(':', $key)));
$rows = collect();
foreach ($pairs->chunk(500) as $chunk) {
$rows = $rows->merge($this->pivotQuery()
->where(function ($query) use ($chunk): void {
foreach ($chunk as [$companyId, $userId]) {
$query->orWhere(fn ($pair) => $pair->where('company_user.company_id', $companyId)->where('company_user.user_id', $userId));
}
})
->get());
}
$rows = $rows->merge($this->pivotQuery()->where('company_user.granted_via', ApiNewsroomAssignment::GRANTED_VIA)->get());
return $rows
->unique(fn (object $row): string => $row->company_id.':'.$row->user_id)
->filter(function (object $row) use ($firstEvents): bool {
if ((int) $row->owner_user_id === (int) $row->user_id) {
return false;
}
if ($row->granted_via === ApiNewsroomAssignment::GRANTED_VIA) {
return true;
}
// Schon vor der ersten Zuordnung Mitglied: nicht durch die API entstanden.
$first = $firstEvents[$row->company_id.':'.$row->user_id] ?? null;
return $first !== null
&& $row->created_at !== null
&& Carbon::parse($row->created_at)->greaterThanOrEqualTo($first->copy()->subSeconds(self::EVENT_TOLERANCE_SECONDS));
})
->map(function (object $row): object {
$row->allowed = ApiNewsroomAssignment::isAllowed((int) $row->user_id);
return $row;
})
->sortBy(['company_id', 'user_id'])
->values();
}
/**
* `member`-Pivots samt Firma und Nutzer — `responsible`/`owner` stammen
* nie aus der API-Zuordnung (eine Hochstufung hat eine E-Mail-Grundlage).
*/
private function pivotQuery(): Builder
{
return DB::table('company_user')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->join('users', 'users.id', '=', 'company_user.user_id')
->where('company_user.role', 'member')
->select([
'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at',
'companies.name as company_name', 'companies.owner_user_id', 'users.email',
]);
}
/**
* @return list<string>
*/
private function tableRow(object $row): array
{
return [
(string) $row->company_id,
mb_strimwidth((string) $row->company_name, 0, 40, '…'),
(string) $row->user_id,
(string) $row->email,
(string) $row->granted_via,
(string) $row->created_at,
$row->allowed ? 'ja' : 'nein',
];
}
}

View file

@ -3,12 +3,11 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Services\Auth\LoginCompletion;
use App\Services\Auth\SocialAuthService;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
/**
@ -23,7 +22,7 @@ class GoogleController extends Controller
return Socialite::driver('google')->redirect();
}
public function callback(Request $request, SocialAuthService $social): RedirectResponse
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse
{
try {
$googleUser = Socialite::driver('google')->user();
@ -49,25 +48,16 @@ class GoogleController extends Controller
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
// den Besitz des Postfachs, nicht den zweiten Faktor.
//
// Kein erzwungenes „Angemeldet bleiben" mehr (Audit K-08): wie beim
// Magic Link nur die Sitzung, siehe LoginCompletion.
if ($user->hasEnabledTwoFactorAuthentication()) {
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => true,
]);
$request->session()->regenerate();
$completion->handOffToTwoFactor($request, $user, 'google');
return redirect()->route('two-factor.challenge');
}
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'google');
Auth::login($user, remember: true);
$request->session()->regenerate();
$user->forceFill([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
])->save();
$completion->login($request, $user, 'google');
return redirect(LoginRedirect::safeTarget(
$user,

View file

@ -4,12 +4,14 @@ namespace App\Http\Controllers\Auth;
use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Auth\LoginCompletion;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -19,6 +21,7 @@ class MagicLinkConsumeController extends Controller
public function __construct(
private readonly ConversionRecorder $recorder,
private readonly CustomerCompanyContext $companies,
private readonly LoginCompletion $completion,
) {}
public function __invoke(Request $request, string $token): RedirectResponse
@ -26,9 +29,25 @@ class MagicLinkConsumeController extends Controller
$magicLink = MagicLink::query()
->with('user')
->where('token_hash', hash('sha256', $token))
->where('purpose', 'login')
// Login- und Bildbestätigungslinks melden gleich an (B-10).
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->first();
// Bereits angemeldet (Audit K-08): Die Route liegt nicht mehr hinter
// `guest`. Öffnet ein eingeloggter Kunde seinen EIGENEN Link (etwa die
// Bildbestätigung), landet er auf dem Ziel statt auf seinem Dashboard.
// Jeder andere Link verhält sich wie früher unter `guest`: zurück
// nach Hause, der Link bleibt unberührt.
$current = Auth::guard('web')->user();
if ($current instanceof User) {
if (! $magicLink || $magicLink->user_id !== $current->getKey()) {
return redirect(! $current->hasVerifiedEmail() ? route('verification.notice') : LoginRedirect::homeFor($current));
}
return $this->openOwnLinkWhileLoggedIn($request, $magicLink, $current);
}
if (! $magicLink || ! $magicLink->user) {
return redirect()->route('login')->with('status', __('The magic login link is invalid.'));
}
@ -71,30 +90,56 @@ class MagicLinkConsumeController extends Controller
// ersetzt nur das Passwort, nicht den zweiten Faktor. Der Link ist bereits
// atomar verbraucht; bricht der Nutzer die Challenge ab, fordert er einen
// neuen an.
//
// Das Landeziel geht als `url.intended` mit durch die Challenge
// (Audit K-08); RoleAwareLoginResponse übernimmt es nur, wenn der
// Nutzer es erreichen darf.
if ($user->hasEnabledTwoFactorAuthentication()) {
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => false,
]);
$request->session()->regenerate();
$this->completion->handOffToTwoFactor($request, $user, 'magic-link', intended: $this->landingFor($user, $magicLink));
return redirect()->route('two-factor.challenge');
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
]);
$this->completion->login($request, $user, 'magic-link');
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
Auth::guard('web')->login($user);
$request->session()->regenerate();
return redirect($this->landingFor($user, $magicLink));
}
// Rollensicherer Redirect ohne intended(): eine als Gast besuchte
// Admin-URL (z. B. /dashboard) darf einen Customer nach dem
// Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
/**
* Eigener Link bei bestehender Anmeldung: kein erneuter Login. Ein noch
* gültiger Link wird wie sonst verbraucht (Einmal-Nutzung), ein schon
* verbrauchter oder abgelaufener führt trotzdem zum Ziel — der Nutzer
* ist ja angemeldet.
*/
private function openOwnLinkWhileLoggedIn(Request $request, MagicLink $magicLink, User $user): RedirectResponse
{
if ($magicLink->consumed_at === null && ! $magicLink->expires_at->isPast()) {
$claimed = MagicLink::query()
->whereKey($magicLink->id)
->whereNull('consumed_at')
->update([
'consumed_at' => now(),
'ip_consumed' => $request->ip(),
]);
if ($claimed > 0) {
$this->recorder->record(
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
}
}
return redirect($this->landingFor($user, $magicLink));
}
/**
* Rollensicheres Landeziel ohne intended(): eine als Gast besuchte
* Admin-URL (z. B. /dashboard) darf einen Customer nach dem
* Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
*/
private function landingFor(User $user, MagicLink $magicLink): string
{
$home = $user->canAccessAdmin()
? route('dashboard', absolute: false)
: ($user->canAccessCustomer()
@ -122,6 +167,6 @@ class MagicLinkConsumeController extends Controller
}
}
return redirect($home);
return $home;
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Http\Middleware;
use App\Services\Auth\SessionRevocation;
use Closure;
use Illuminate\Auth\SessionGuard;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Meldet eine Session ab, die vor der letzten Sperre ihres Kontos entstanden
* ist (SessionRevocation, Audit K-04). Kostet je angemeldetem Request einen
* Cache-Zugriff; Gäste lösen keinen aus.
*/
class EnsureSessionIsNotRevoked
{
public function __construct(private readonly SessionRevocation $revocation) {}
public function handle(Request $request, Closure $next): Response
{
if (! $request->hasSession()) {
return $next($request);
}
/** @var SessionGuard $guard */
$guard = Auth::guard('web');
if (! $guard->hasUser() && ! $request->session()->has($guard->getName())) {
return $next($request);
}
$user = $guard->user();
if ($user === null || ! $this->revocation->isRevoked((int) $user->getAuthIdentifier(), $request->session())) {
return $next($request);
}
// Ohne logout(): das würde remember_token erneut rotieren und damit
// auch die gerade gültige Anmeldung des Kontoinhabers treffen.
$guard->logoutCurrentDevice();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->expectsJson()) {
abort(401);
}
return redirect()->guest(route('login'));
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Responses;
use App\Services\Auth\LoginCompletion;
use App\Support\LoginRedirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@ -49,6 +50,12 @@ class RoleAwareLoginResponse implements LoginResponseContract, TwoFactorLoginRes
: redirect()->route('verification.notice');
}
// Login-Abschluss auch nach der 2FA-Challenge (Audit K-08) — die
// direkten Wege setzen ihn in LoginCompletion::login().
if ($user) {
app(LoginCompletion::class)->recordLogin($user, $request);
}
if ($wantsJson) {
return new JsonResponse('', 204);
}

View file

@ -7,6 +7,7 @@ use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Auth\LoginCompletion;
use App\Services\Conversion\ConversionRecorder;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
@ -15,8 +16,9 @@ use Illuminate\Auth\Events\Registered;
* Protokolliert Registrierungen und Logins im Aktivitäts-Verlauf.
*
* Der Anmeldeweg kommt als Request-Attribut `auth_method` von der jeweils
* einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation);
* ohne Attribut ist es der Fortify-Passwort-Login.
* einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation), nach
* einer 2FA-Challenge aus der Session (LoginCompletion); ohne beides ist es
* der Passwort-Login.
*
* Die Methoden heißen bewusst NICHT `handle*`: Laravels Event-Discovery
* würde sie sonst zusätzlich zur manuellen Verdrahtung im
@ -67,7 +69,8 @@ class RecordAuthActivity
return;
}
$method = (string) request()->attributes->get(self::AUTH_METHOD_ATTRIBUTE, 'password');
// Auch nach der 2FA-Challenge der ursprüngliche Weg (Audit K-08).
$method = LoginCompletion::methodOf(request());
$this->activityLog->record(
ActivityType::UserLoggedIn,

View file

@ -3,11 +3,15 @@
namespace App\Livewire\Concerns;
use App\Enums\AiTextDeclaration;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Locked;
use RuntimeException;
/**
* KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026).
@ -35,20 +39,74 @@ trait InteractsWithAiDeclaration
* Fassungen (type => version), die beim letzten Rendern im Submit-Modal
* standen. Der Schalter bestätigt genau diese; wechselt zwischen Anzeige
* und Absenden die Fassung, wird die Annahme abgelehnt und der neue
* Wortlaut gezeigt (Umsetzungsaudit 25.09.2026, N06). Livewire signiert
* den Zustand, der Browser kann die Werte nicht verändern.
* Wortlaut gezeigt (Umsetzungsaudit 25.09.2026, N06). `#[Locked]`: der
* Browser kann die Werte nicht setzen (Umsetzungsaudit 29.09.2026, K-07).
*
* @var array<string, string>
*/
#[Locked]
public array $legalAcceptanceVersions = [];
/**
* Offene Fassungen für die Anzeige, einmal je Request ermittelt (OPT-7):
* Rendering-Hook und jedes Submit-Modal der Seite (die Liste hat eines
* je Zeile) teilen sich das Ergebnis. Nicht öffentlich, also nie Teil
* des Livewire-Zustands.
*
* @var Collection<int, LegalDocument>|null
*/
private ?Collection $legalDocumentsForDisplay = null;
/**
* Livewire-Trait-Hook: läuft nach der Aktion, unmittelbar vor dem
* Rendern — die Property trägt danach exakt die gezeigten Fassungen.
*/
public function renderingInteractsWithAiDeclaration(): void
{
$this->legalAcceptanceVersions = LegalAcceptanceGate::versionsOf(app(LegalAcceptanceGate::class)->requiredFor(auth()->user()));
$this->legalAcceptanceVersions = LegalAcceptanceGate::versionsOf($this->legalDocumentsToAccept());
}
/**
* Offene Fassungen, wie sie das Submit-Modal zeigt (Beschreibungsform
* aus LegalAcceptanceGate::describe()). Wird von den Seiten an
* `<x-press-release-submit-modal :legal-documents>` übergeben.
*
* @return Collection<int, array{type: string, version: string, label: string, url: string}>
*/
protected function legalDocumentsForSubmit(): Collection
{
$gate = app(LegalAcceptanceGate::class);
return $this->legalDocumentsToAccept()->map(fn (LegalDocument $document): array => $gate->describe($document))->values();
}
/**
* Ausgeschaltetes Gate: keine Abfrage. Fehlt bei aktivem Gate ein
* Pflichtdokument, zeigt die Seite trotzdem an (K-07) — die
* Konfigurationslücke wird gemeldet und erst das Absenden scheitert
* (validateAiDeclaration()).
*
* @return Collection<int, LegalDocument>
*/
private function legalDocumentsToAccept(): Collection
{
if ($this->legalDocumentsForDisplay !== null) {
return $this->legalDocumentsForDisplay;
}
$gate = app(LegalAcceptanceGate::class);
if (! $gate->isEnabled()) {
return $this->legalDocumentsForDisplay = collect();
}
try {
return $this->legalDocumentsForDisplay = $gate->requiredFor(auth()->user());
} catch (RuntimeException $exception) {
report($exception);
return $this->legalDocumentsForDisplay = collect();
}
}
/**
@ -66,7 +124,7 @@ trait InteractsWithAiDeclaration
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
];
$required = app(LegalAcceptanceGate::class)->requiredFor(auth()->user());
$required = $this->requiredLegalDocumentsForSubmit();
if ($required->isNotEmpty()) {
$rules['legalAcceptanceConfirmed'] = ['accepted'];
@ -87,20 +145,54 @@ trait InteractsWithAiDeclaration
/**
* Annahme protokollieren (nach validateAiDeclaration, vor dem Einreichen).
* Ohne Gate oder ohne offene Fassung passiert nichts.
*
* Wechselt die Fassung zwischen Validierung und Speichern, scheitert das
* Einreichen wie in validateAiDeclaration() — nicht still ohne Annahme
* weiter (K-07).
*/
protected function persistLegalAcceptances(): void
{
$gate = app(LegalAcceptanceGate::class);
$required = $gate->requiredFor(auth()->user());
$required = $this->requiredLegalDocumentsForSubmit();
if ($required->isEmpty() || ! $this->legalAcceptanceConfirmed || LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) {
if ($required->isEmpty()) {
return;
}
$gate->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent());
if (! $this->legalAcceptanceConfirmed || LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) {
$this->legalAcceptanceConfirmed = false;
$this->legalDocumentsForDisplay = null;
throw ValidationException::withMessages([
'legalAcceptanceConfirmed' => __('Die Bedingungen wurden zwischenzeitlich aktualisiert. Bitte lesen Sie die neue Fassung und nehmen Sie sie erneut an.'),
]);
}
app(LegalAcceptanceGate::class)->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent());
$this->legalAcceptanceConfirmed = false;
}
/**
* Verbindlicher, ungecachter Stand fürs Absenden. Ein fehlendes
* Pflichtdokument (Konfigurationsfehler) wird gemeldet und verhindert
* das Einreichen mit einer verständlichen Meldung statt eines 500ers.
*
* @return Collection<int, LegalDocument>
*
* @throws ValidationException
*/
private function requiredLegalDocumentsForSubmit(): Collection
{
try {
return app(LegalAcceptanceGate::class)->requiredFor(auth()->user());
} catch (RuntimeException $exception) {
report($exception);
throw ValidationException::withMessages([
'legalAcceptanceConfirmed' => __('Das Einreichen ist gerade nicht möglich, weil die aktuellen Bedingungen nicht geladen werden konnten. Bitte versuchen Sie es später erneut.'),
]);
}
}
/**
* Spaltenwerte für die PM-Persistenz. Der Bestätigungszeitpunkt wird nur
* gestempelt, wenn die Checkbox tatsächlich gesetzt wurde.

View file

@ -7,6 +7,22 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MagicLink extends Model
{
public const PURPOSE_LOGIN = 'login';
/**
* Link „Bild bestätigen" (Decision-Update E10): meldet an wie ein
* Login-Link, wird aber von späteren Login-Links nicht entwertet
* (Umsetzungsaudit 29.09.2026, B-10).
*/
public const PURPOSE_IMAGE_CONFIRMATION = 'image_confirmation';
/**
* Zwecke, die der Endpunkt magic-login einlöst.
*
* @var list<string>
*/
public const LOGIN_PURPOSES = [self::PURPOSE_LOGIN, self::PURPOSE_IMAGE_CONFIRMATION];
protected $fillable = [
'user_id',
'token_hash',

View file

@ -36,6 +36,7 @@ use App\Observers\EmailDomainObserver;
use App\Observers\ImageRightsConfirmationObserver;
use App\Observers\PressReleaseSearchObserver;
use App\Services\Admin\AdminRequestPerformanceMetrics;
use App\Services\Auth\SessionRevocation;
use App\Services\Mail\DnsResolver;
use App\Services\Mail\SystemDnsResolver;
use App\Services\Newsletter\NullNewsletterSyncClient;
@ -166,6 +167,10 @@ class AppServiceProvider extends ServiceProvider
Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']);
Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']);
// Anmeldezeitpunkt in der Session — Grundlage für das Beenden fremder
// Sessions nach einer Kontoübernahme (Audit K-04, SessionRevocation).
Event::listen(Login::class, [SessionRevocation::class, 'stampLogin']);
// Das eine Gate vor jeder ausgehenden Mail: Suppression, Return-Path,
// Circuit Breaker (Roadmap 1b.6). Gibt false zurück → Mail geht nicht raus.
Event::listen(MessageSending::class, [GateOutgoingMail::class, 'gate']);

View file

@ -116,7 +116,9 @@ class ContactAccessService
* auf alle Firmen-PMs. Eigene PMs bleiben über die Autorenschaft. Eine
* automatisch HOCHGESTUFTE manuelle Mitgliedschaft (`auto-upgrade`) fällt
* auf `member` zurück. Manuell oder aus dem Altbestand vergebene Rechte
* (`granted_via = manual`) bleiben.
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
* die Freischaltung des Einlieferers, nicht die E-Mail.
*/
public function syncMembershipsForEmail(User $user): void
{

View file

@ -0,0 +1,98 @@
<?php
namespace App\Services\Auth;
use App\Listeners\RecordAuthActivity;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
/**
* Gemeinsamer Abschluss der drei Anmeldewege Passwort, Magic Link und
* Google (Plan 1.8, Umsetzungsaudit 29.09.2026 K-08).
*
* - Direkter Login: Anmeldeweg fürs Aktivitäts-Log, Session-Login,
* Session-ID erneuern, `last_login_at`/`last_login_ip`.
* - Übergabe an die 2FA-Challenge: derselbe Session-Vertrag wie Fortify
* (`login.id`, `login.remember`), zusätzlich der Anmeldeweg und bei Bedarf
* das Landeziel (`url.intended`). Nach der Challenge liest
* RecordAuthActivity den Anmeldeweg, RoleAwareLoginResponse setzt
* `last_login_at` und folgt dem Landeziel.
*
* „Angemeldet bleiben" gibt es nur beim Passwort-Login als bewusste Wahl im
* Formular. Magic Link und Google setzen keinen Remember-Cookie: Beide
* beweisen den Besitz des Postfachs bzw. des Google-Kontos für diese eine
* Anmeldung, ein Jahres-Cookie folgt daraus nicht.
*/
class LoginCompletion
{
/**
* Anmeldeweg während einer offenen 2FA-Challenge.
*/
public const METHOD_SESSION_KEY = 'login.method';
public function login(Request $request, User $user, string $method, bool $remember = false): void
{
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, $method);
Session::forget(self::METHOD_SESSION_KEY);
Auth::guard('web')->login($user, $remember);
Session::regenerate();
$this->recordLogin($user, $request);
}
/**
* Übergabe an Fortifys 2FA-Challenge statt eines direkten Logins.
*
* @param string|null $intended Landeziel nach bestandener Challenge
* (wird von RoleAwareLoginResponse nur
* übernommen, wenn der Nutzer es
* erreichen darf).
*/
public function handOffToTwoFactor(Request $request, User $user, string $method, bool $remember = false, ?string $intended = null): void
{
// Session-Fassade statt $request->session(): auch aus Livewire-Aktionen
// (Volt-Login) nutzbar, die denselben Store verwenden.
Session::put([
'login.id' => $user->getKey(),
'login.remember' => $remember,
self::METHOD_SESSION_KEY => $method,
]);
if ($intended !== null) {
Session::put('url.intended', $intended);
}
Session::regenerate();
}
public function recordLogin(User $user, Request $request): void
{
$user->forceFill([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
])->save();
}
/**
* Anmeldeweg des gerade laufenden Logins: vom einloggenden Code gesetzt,
* beim Abschluss der 2FA-Challenge aus der Session, sonst Passwort
* (Fortify-/Volt-Login).
*/
public static function methodOf(Request $request): string
{
$method = $request->attributes->get(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE);
if (is_string($method) && $method !== '') {
return $method;
}
if ($request->routeIs('two-factor.login.store')) {
return (string) Session::pull(self::METHOD_SESSION_KEY, 'password');
}
return 'password';
}
}

View file

@ -54,6 +54,53 @@ class MagicLinkGenerator
];
}
/**
* Link „Bild bestätigen" für den Kontoinhaber (Decision-Update E10,
* Umsetzungsaudit 29.09.2026 B-10): meldet wie ein Login-Link an und
* führt auf die Meldung, hat aber einen eigenen Zweck. Spätere
* Login-Links entwerten ihn deshalb nicht; ein neuer Bestätigungslink
* für dasselbe Bild entwertet nur den vorherigen für dieses Bild.
*
* @param array<string, mixed> $payload Landeziel (`company_id`, `press_release_id`)
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
*/
public function createImageConfirmationLink(
User $user,
int $imageId,
array $payload,
?string $requestedIp,
int $ttlMinutes,
?ConversionSource $source = null,
): array {
$plainToken = Str::random(64);
$expiresAt = now()->addMinutes($ttlMinutes);
MagicLink::query()
->where('user_id', $user->id)
->where('purpose', MagicLink::PURPOSE_IMAGE_CONFIRMATION)
->whereNull('consumed_at')
->where('payload->image_confirmation', $imageId)
->update([
'consumed_at' => now(),
'ip_consumed' => $requestedIp,
]);
$magicLink = $user->magicLinks()->create([
'token_hash' => hash('sha256', $plainToken),
'purpose' => MagicLink::PURPOSE_IMAGE_CONFIRMATION,
'source' => $source?->value,
'payload' => ['image_confirmation' => $imageId] + $payload,
'expires_at' => $expiresAt,
'ip_requested' => $requestedIp,
]);
return [
'magic_link' => $magicLink,
'plain_token' => $plainToken,
'expires_at' => $expiresAt,
];
}
/**
* Meldeverfahren (Phase 3): Verifikations-Link an die hinterlegte
* Pressekontaktadresse. Wer den Link öffnet, ist strukturell verifiziert —

View file

@ -0,0 +1,75 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Cache;
/**
* Beendet alle offenen Browser-Sessions eines Kontos (Umsetzungsaudit
* 29.09.2026, K-04).
*
* Die Sessions liegen in Redis und lassen sich nicht je Nutzer aufzählen;
* Laravels `AuthenticateSession` greift nur bei gesetztem Passwort. Deshalb
* ein Zeitstempel je Konto: Jede Anmeldung schreibt ihren Zeitpunkt in die
* Session (`Login`-Event), `revokeAllFor()` merkt sich „ungültig vor jetzt",
* und die Middleware EnsureSessionIsNotRevoked meldet jede ältere Session
* beim nächsten Request ab. Die Marke lebt so lange wie eine Session
* (`session.lifetime`) — danach ist jede ältere Session ohnehin abgelaufen.
* Remember-Cookies deckt das Rotieren von `remember_token` ab, API-Tokens
* löscht der Aufrufer.
*/
class SessionRevocation
{
/**
* Anmeldezeitpunkt der Session in Millisekunden.
*/
public const SESSION_KEY = 'auth.logged_in_at_ms';
/**
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
*/
public function revokeAllFor(User $user): void
{
Cache::put(
$this->cacheKey((int) $user->getKey()),
now()->getTimestampMs(),
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5),
);
}
/**
* Listener für `Login`: Zeitpunkt der Anmeldung in der Session.
* Die Methode heißt bewusst nicht `handle*` (Event-Discovery).
*/
public function stampLogin(Login $event): void
{
if (! app()->bound('request') || ! request()->hasSession()) {
return;
}
request()->session()->put(self::SESSION_KEY, now()->getTimestampMs());
}
/**
* Session stammt aus der Zeit vor der letzten Sperre dieses Kontos.
* Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt.
*/
public function isRevoked(int $userId, Session $session): bool
{
$revokedAt = Cache::get($this->cacheKey($userId));
if ($revokedAt === null) {
return false;
}
return (int) $session->get(self::SESSION_KEY, 0) < (int) $revokedAt;
}
private function cacheKey(int $userId): string
{
return 'auth.sessions-revoked-at.'.$userId;
}
}

View file

@ -3,6 +3,7 @@
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Support\Str;
/**
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
@ -16,6 +17,7 @@ class SocialAuthService
public function __construct(
private readonly UserRolePermissionSyncService $roleSync,
private readonly CustomerAccountProvisioner $accountProvisioner,
private readonly SessionRevocation $sessions,
) {}
/**
@ -44,11 +46,23 @@ class SocialAuthService
// Noch nicht verifiziert (offener Selbst-Registrierer): der Provider
// bestätigt die E-Mail → Onboarding abschließen (aktiv + customer).
//
// Vorab-Übernahme (Audit K-04): Wer die Adresse unverifiziert
// registriert hat, war nicht zwingend der Inhaber des Postfachs. Alles,
// was er dabei gesetzt hat, verfällt: Passwort, Remember-Cookie,
// API-Tokens und offene Sessions. Der Inhaber meldet sich per Google
// oder Magic Link an und setzt bei Bedarf selbst ein Passwort.
if ($user->email_verified_at === null) {
$user->forceFill([
'email_verified_at' => now(),
'is_active' => true,
])->save();
'password' => null,
]);
$user->setRememberToken(Str::random(60));
$user->save();
$user->tokens()->delete();
$this->sessions->revokeAllFor($user);
if ($user->roles()->doesntExist()) {
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');

View file

@ -28,9 +28,21 @@ use App\Services\ActivityLog;
* Unter einer Sammeladresse (Agentur) wird nur die Firma mit passendem Namen
* wiederverwendet — ein neuer Kunde der Agentur bekommt wie bisher eine neue
* Firma. Unter einer nie-veröffentlicht-Adresse wird nie zugeordnet.
*
* Berechtigung (Umsetzungsaudit 29.09.2026, R-01): Firmenname und
* Kontaktadresse stehen öffentlich im Newsroom. Zugeordnet wird deshalb nur,
* wenn der Token-Nutzer der Firma schon angehört (Owner oder Pivot) oder als
* Einlieferer freigeschaltet ist (`newsroom.api_assignment.allowed_user_ids`).
* Sonst entsteht wie vor 1a.4 eine neue Firma — ein fremdes Token wird nie
* Mitglied eines fremden Newsrooms.
*/
class ApiNewsroomAssignment
{
/**
* Herkunft des Pivots (`company_user.granted_via`) einer API-Zuordnung.
*/
public const GRANTED_VIA = 'api';
public function __construct(
private readonly CompanyAddressClassifier $classifier,
private readonly ActivityLog $activityLog,
@ -40,10 +52,11 @@ class ApiNewsroomAssignment
* Bestehende Firma für diese Anfrage — oder null, dann legt der Aufrufer
* wie bisher eine neue an.
*
* Nebenwirkungen bei Treffer: Der API-Nutzer wird als Mitglied an die
* Firma gehängt (sonst dürfte er dort keine Meldung anlegen), und liegt
* die Firma auf dem anderen Portal, wird sie `both` — ihre Meldungen
* behalten ihr Portal, die neue bekommt das angefragte.
* Nebenwirkungen bei Treffer: Ein freigeschalteter API-Nutzer wird als
* Mitglied (`granted_via = api`) an die Firma gehängt (sonst dürfte er
* dort keine Meldung anlegen), und liegt die Firma auf dem anderen Portal,
* wird sie `both` — ihre Meldungen behalten ihr Portal, die neue bekommt
* das angefragte. Ohne Zugehörigkeit und ohne Freischaltung: null.
*/
public function assign(User $user, ?string $email, string $name, Portal $portal): ?Company
{
@ -81,6 +94,13 @@ class ApiNewsroomAssignment
$company = $matches->first(fn (Company $company): bool => $company->portal === $portal || $company->portal === Portal::Both)
?? $matches->first();
// R-01: vor jeder Nebenwirkung (Pivot, Portal-Erweiterung) prüfen.
$belongs = $company->owner_user_id === $user->id || $company->users()->whereKey($user->id)->exists();
if (! $belongs && ! self::isAllowed((int) $user->getKey())) {
return null;
}
if ($portal !== Portal::Both && $company->portal !== Portal::Both && $company->portal !== $portal) {
if ($company->slugTakenOnOtherPortals()) {
return null;
@ -89,8 +109,11 @@ class ApiNewsroomAssignment
$company->forceFill(['portal' => Portal::Both->value])->save();
}
if (! $company->users()->whereKey($user->id)->exists() && $company->owner_user_id !== $user->id) {
$company->users()->attach($user->id, ['role' => 'member']);
if (! $belongs) {
// Eigene Herkunft: der Login-Sync nimmt nur `auto`-Zuordnungen
// zurück, und die Bestandsprüfung (company-memberships:api-assignments)
// findet diese Pivots wieder.
$company->users()->attach($user->id, ['role' => 'member', 'granted_via' => self::GRANTED_VIA]);
}
$this->activityLog->record(
@ -104,4 +127,12 @@ class ApiNewsroomAssignment
return $company;
}
/**
* Freigeschalteter Einlieferer (Allowlist in config/newsroom.php).
*/
public static function isAllowed(int $userId): bool
{
return in_array($userId, array_map('intval', (array) config('newsroom.api_assignment.allowed_user_ids', [])), true);
}
}

View file

@ -141,7 +141,7 @@ class ConversionRecorder
try {
$source = $user === null ? null : MagicLink::query()
->where('user_id', $user->getKey())
->where('purpose', 'login')
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->whereNotNull('consumed_at')
->latest('consumed_at')
->value('source');

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\BasicAuthMiddleware;
use App\Http\Middleware\EnsureSessionIsNotRevoked;
use App\Http\Middleware\LogApiUsage;
use App\Http\Middleware\RecordMailLinkClick;
use App\Http\Middleware\RedirectPortalOnlyPagesFromHub;
@ -58,6 +59,11 @@ return Application::configure(basePath: dirname(__DIR__))
// Routing fest.
$middleware->web(append: [RedirectPortalOnlyPagesFromHub::class]);
// Gesperrte Sessions (Audit K-04): nach einer Kontoübernahme per
// OAuth-Verifizierung fliegen ältere Sessions beim nächsten Request
// raus — nach StartSession, deshalb in der web-Gruppe.
$middleware->web(append: [EnsureSessionIsNotRevoked::class]);
// Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt
// werden: rollen- und verifizierungsbewusst statt fix auf /dashboard,
// sonst landet ein Customer dort im 403 und sitzt fest.

View file

@ -31,11 +31,38 @@ return [
| claim_links_per_day: Anmeldelinks je Newsroom und Tag. Der Versand geht
| an die hinterlegte Firmen-E-Mail, nicht an eine vom Besucher eingegebene
| — er darf trotzdem kein Werkzeug werden, fremde Postfächer zu fluten.
| Gezählt werden nur versendete Links (R-09, 29.09.2026: 3 → 5, damit
| Dritte den Inhaber nicht mit wenigen Klicks für 24 h aussperren).
|
*/
'claim' => [
'links_per_day' => (int) env('NEWSROOM_CLAIM_LINKS_PER_DAY', 3),
'links_per_day' => (int) env('NEWSROOM_CLAIM_LINKS_PER_DAY', 5),
],
/*
|--------------------------------------------------------------------------
| API-Zuordnung zu bestehenden Newsrooms (Roadmap 1a.4, Audit R-01)
|--------------------------------------------------------------------------
|
| Der Alt-Vertrag `POST /service/users/me/companies` hängt eine neue
| API-Firma an den bestehenden Newsroom mit derselben Firmen-E-Mail und
| demselben Namen. Firmenname und Kontaktadresse stehen öffentlich — die
| Zuordnung (samt Mitgliedschaft mit Schreibrecht auf alle Firmen-PMs) gibt
| es deshalb nur für freigeschaltete Einlieferer (heute connektar) oder
| wenn der Token-Nutzer der Firma ohnehin schon angehört. Alle anderen
| bekommen wie vor 1a.4 eine neue Firma.
|
| allowed_user_ids: kommagetrennte User-IDs (NEWSROOM_API_ASSIGNMENT_USER_IDS).
| Standard leer.
|
*/
'api_assignment' => [
'allowed_user_ids' => array_values(array_unique(array_filter(
array_map('intval', explode(',', (string) env('NEWSROOM_API_ASSIGNMENT_USER_IDS', ''))),
fn (int $id): bool => $id > 0,
))),
],
];

View file

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Umsetzungsaudit 29.09.2026, B-10: eigener Zweck für den Link „Bild
* bestätigen". Ein späterer Login-Link entwertet nur Links mit Zweck
* `login`; der Bestätigungslink bleibt bis zu seinem Ablauf gültig.
* Der Wert wird ans Ende der Liste angehängt (MySQL: INPLACE, ohne Kopie).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('magic_links', function (Blueprint $table) {
$table->enum('purpose', [
'login',
'press_release_access',
'invoice_access',
'initial_setup',
'press_release_report',
'image_confirmation',
])->default('login')->change();
});
}
public function down(): void
{
// Links des entfallenden Zwecks zuerst räumen, sonst scheitert die
// Rückverengung des Enums an bestehenden Zeilen.
DB::table('magic_links')->where('purpose', 'image_confirmation')->delete();
Schema::table('magic_links', function (Blueprint $table) {
$table->enum('purpose', [
'login',
'press_release_access',
'invoice_access',
'initial_setup',
'press_release_report',
])->default('login')->change();
});
}
};

View file

@ -6,6 +6,7 @@
'quotaTotal' => null,
'quotaRemaining' => null,
'hasTitleImage' => true,
'legalDocuments' => null,
])
{{--
@ -35,8 +36,23 @@
$bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true);
// Zustimmungsgate (Bildkennzeichnung Block G): offene Fassungen von AGB
// und Richtlinien, die vor dem Einreichen aktiv angenommen werden müssen.
$legalGate = app(\App\Services\Legal\LegalAcceptanceGate::class);
$legalDocumentsToAccept = $legalGate->requiredFor(auth()->user())->map(fn ($document) => $legalGate->describe($document))->values();
// Die Seiten reichen das je Request einmal ermittelte Ergebnis durch
// (InteractsWithAiDeclaration::legalDocumentsForSubmit(), OPT-7) — die
// Liste hätte sonst je Zeile eigene Abfragen. Ohne Übergabe: selbst
// ermitteln, bei fehlendem Pflichtdokument aber nicht beim Rendern
// scheitern (K-07); das Absenden prüft verbindlich.
if ($legalDocuments === null) {
$legalGate = app(\App\Services\Legal\LegalAcceptanceGate::class);
try {
$legalDocuments = $legalGate->requiredFor(auth()->user())->map(fn ($document) => $legalGate->describe($document))->values();
} catch (\RuntimeException $exception) {
report($exception);
$legalDocuments = collect();
}
}
$legalDocumentsToAccept = collect($legalDocuments);
$needsLegalAcceptance = $legalDocumentsToAccept->isNotEmpty();
@endphp

View file

@ -1,6 +1,7 @@
<?php
use App\Models\User;
use App\Services\Auth\LoginCompletion;
use App\Support\LoginRedirect;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
@ -49,10 +50,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
// 2FA aktiv: nicht einloggen, sondern in Fortifys Challenge übergeben
// (Session-Vertrag login.id/login.remember wie RedirectIfTwoFactorAuthenticatable).
if ($user->hasEnabledTwoFactorAuthentication()) {
Session::put([
'login.id' => $user->getKey(),
'login.remember' => $this->remember,
]);
app(LoginCompletion::class)->handOffToTwoFactor(request(), $user, 'password', $this->remember);
$this->redirect(route('two-factor.challenge'));
@ -78,10 +76,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
]);
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => request()->ip(),
]);
app(LoginCompletion::class)->recordLogin($user, request());
Session::regenerate();

View file

@ -1095,6 +1095,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="false" />
:has-title-image="false"
:legal-documents="$this->legalDocumentsForSubmit()" />
@endif
</div>

View file

@ -1064,5 +1064,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut zur Prüfung freigeben') : __('Speichern & zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder" />
:has-title-image="! $coverIsPlaceholder"
:legal-documents="$this->legalDocumentsForSubmit()" />
</div>

View file

@ -494,7 +494,8 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
</flux:modal.trigger>
<x-press-release-submit-modal :name="'confirm-submit-' . $pr->id"
:action="'submitForReview(' . $pr->id . ')'"
:has-title-image="$pr->images_count > 0" />
:has-title-image="$pr->images_count > 0"
:legal-documents="$this->legalDocumentsForSubmit()" />
@endif
</div>
@if ($boostEndsAt = $boostEndsById->get($pr->id))

View file

@ -15,8 +15,11 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
->middleware(['guest:'.config('fortify.guard')])
->name('login');
// Ohne `guest` (Audit K-08): Ein eingeloggter Kunde, der seinen eigenen
// Link öffnet (z. B. Bildbestätigung), soll auf dem Ziel landen; fremde
// Links behandelt der Controller wie früher die guest-Middleware.
Route::get('/magic-login/{token}', MagicLinkConsumeController::class)
->middleware(['guest:'.config('fortify.guard'), 'throttle:10,1'])
->middleware(['throttle:10,1'])
->name('magic-links.consume');
// Anmeldung per E-Mail-Link (Magic-Link): zusammengeführte Seite für

View file

@ -8,6 +8,7 @@ use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
@ -18,7 +19,7 @@ use Tests\TestCase;
* Firmen-E-Mail (Roadmap 1a.4). connektar legt je Meldung eine Firma an —
* ohne die Zuordnung entstünde je Meldung ein neuer Newsroom.
*/
function assignmentApiUser(Portal $portal = Portal::Businessportal24): User
function assignmentApiUser(Portal $portal = Portal::Businessportal24, bool $allowlisted = true): User
{
test()->seed(RolesAndPermissionsSeeder::class);
config()->set('billing.enforce_booking', false);
@ -27,6 +28,11 @@ function assignmentApiUser(Portal $portal = Portal::Businessportal24): User
$user = User::factory()->create(['is_active' => true, 'portal' => $portal]);
$user->forceFill(['registration_type' => 'apiuser'])->save();
// Zuordnung zu bestehenden Firmen nur für freigeschaltete Einlieferer
// (Umsetzungsaudit 29.09.2026, R-01) — die Bestandstests laufen als
// freigeschalteter Einlieferer (connektar).
config()->set('newsroom.api_assignment.allowed_user_ids', $allowlisted ? [$user->id] : []);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
return $user;
@ -61,7 +67,7 @@ test('the same company under the same email is reused instead of created', funct
expect($id)->toBe($existing->id)
->and(Company::withoutGlobalScopes()->where('email', 'like', '%muster.de')->count())->toBe(1)
->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->exists())->toBeTrue()
->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->wherePivot('granted_via', 'api')->exists())->toBeTrue()
->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeTrue();
});
@ -162,3 +168,92 @@ test('without an email a company is created as before', function () {
expect(postApiCompany(['email' => null]))->not->toBe($existing->id);
});
/*
* R-01 (Umsetzungsaudit 29.09.2026): Firmenname und Kontaktadresse stehen
* öffentlich im Newsroom. Ohne Freischaltung darf ein fremdes Token darüber
* keine Mitgliedschaft (Schreibrecht auf alle Firmen-PMs) erlangen.
*/
test('a foreign token with matching email and name gets its own company and no membership', function () {
/** @var TestCase $this */
$user = assignmentApiUser(Portal::Presseecho, allowlisted: false);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
$id = postApiCompany();
expect($id)->not->toBe($existing->id)
->and($existing->users()->whereKey($user->id)->exists())->toBeFalse()
->and($existing->fresh()->portal)->toBe(Portal::Businessportal24)
->and(Company::withoutGlobalScopes()->find($id)->owner_user_id)->toBe($user->id)
->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeFalse();
});
test('a user who already belongs to the company is reused without allowlist and keeps his role', function () {
/** @var TestCase $this */
$user = assignmentApiUser(allowlisted: false);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
$existing->users()->attach($user->id, ['role' => 'responsible', 'granted_via' => 'manual']);
expect(postApiCompany())->toBe($existing->id)
->and($existing->users()->whereKey($user->id)->first()->pivot->role)->toBe('responsible')
->and($existing->users()->whereKey($user->id)->first()->pivot->granted_via)->toBe('manual');
});
test('the owner of the company is reused without allowlist and gets no extra pivot', function () {
/** @var TestCase $this */
$user = assignmentApiUser(allowlisted: false);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'owner_user_id' => $user->id]);
expect(postApiCompany())->toBe($existing->id)
->and($existing->users()->whereKey($user->id)->exists())->toBeFalse();
});
test('the login sync never revokes an api membership', function () {
/** @var TestCase $this */
$user = assignmentApiUser();
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
postApiCompany();
app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh());
expect($existing->users()->whereKey($user->id)->wherePivot('granted_via', 'api')->exists())->toBeTrue();
});
test('the review command lists api assignments and removes foreign ones only with --remove', function () {
/** @var TestCase $this */
$allowed = assignmentApiUser();
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
postApiCompany();
// Altfall vor dem Fix: ein fremdes Konto wurde per API-Zuordnung Mitglied.
$stranger = User::factory()->create();
$existing->users()->attach($stranger->id, ['role' => 'member', 'granted_via' => 'manual']);
ActivityEvent::query()->create([
'type' => 'correction', 'occurred_at' => now(), 'user_id' => $stranger->id, 'actor_label' => 'api',
'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id,
'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)',
'meta' => ['api_newsroom_assignment' => true],
]);
// Mitglied schon vor der Zuordnung: bleibt unangetastet.
$veteran = User::factory()->create();
$existing->users()->attach($veteran->id, ['role' => 'member', 'granted_via' => 'manual', 'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth()]);
ActivityEvent::query()->create([
'type' => 'correction', 'occurred_at' => now(), 'user_id' => $veteran->id, 'actor_label' => 'api',
'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id,
'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)',
'meta' => ['api_newsroom_assignment' => true],
]);
$this->artisan('company-memberships:api-assignments')
->expectsOutputToContain('2 über die API-Zuordnung entstandene Mitgliedschaft(en), davon 1 ohne Freischaltung')
->assertSuccessful();
expect($existing->users()->whereKey($stranger->id)->exists())->toBeTrue();
$this->artisan('company-memberships:api-assignments', ['--remove' => true, '--force' => true])->assertSuccessful();
expect($existing->users()->whereKey($stranger->id)->exists())->toBeFalse()
->and($existing->users()->whereKey($allowed->id)->exists())->toBeTrue()
->and($existing->users()->whereKey($veteran->id)->exists())->toBeTrue();
});

View file

@ -2,9 +2,12 @@
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use Livewire\Volt\Volt;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
@ -123,3 +126,55 @@ test('google login hands an account with confirmed two-factor to the challenge (
expect(session('login.id'))->toBe($user->id);
expect($user->fresh()->last_login_at)->toBeNull();
});
/*
* K-04 (Umsetzungsaudit 29.09.2026): Vorab-Übernahme. Ein Angreifer
* registriert die Adresse der echten Pressestelle mit eigenem Passwort; die
* Pressestelle meldet sich später mit Google an. Danach darf weder das
* Passwort noch der Remember-Cookie noch eine offene Session des Angreifers
* das Konto erreichen.
*/
test('a pre-registered account is no longer reachable with the old password, token or session after google verifies it', function () {
/** @var TestCase $this */
$pending = User::factory()->unverified()->create([
'email' => 'pressestelle@firma.test',
'password' => Hash::make('attacker-secret'),
'is_active' => false,
'remember_token' => 'attacker-remember-token',
]);
$pending->createToken('attacker', ['press-releases:read']);
// Offene Session des Angreifers (vor der Google-Anmeldung entstanden).
$this->actingAs($pending);
$this->flushSession();
fakeGoogleUser('g-takeover-1', 'pressestelle@firma.test', 'Pressestelle');
Auth::logout();
$this->get(route('oauth.google.callback'))
->assertRedirect(route('me.dashboard', absolute: false));
$pending->refresh();
expect($pending->password)->toBeNull()
->and($pending->remember_token)->not->toBe('attacker-remember-token')
->and($pending->tokens()->count())->toBe(0);
// Die neue Google-Session bleibt gültig.
$this->get(route('me.dashboard'))->assertOk();
$this->assertAuthenticatedAs($pending);
// Die Session des Angreifers (ohne Anmeldezeitpunkt nach der Übernahme) nicht.
Auth::logout();
$this->flushSession();
$this->actingAs($pending)->get(route('me.dashboard'))->assertRedirect(route('login'));
$this->assertGuest();
// Und das alte Passwort führt nicht mehr hinein.
Volt::test('auth.login')
->set('email', 'pressestelle@firma.test')
->set('password', 'attacker-secret')
->call('login')
->assertHasErrors(['email']);
$this->assertGuest();
});

View file

@ -0,0 +1,165 @@
<?php
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\Company;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use Livewire\Volt\Volt;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
/**
* Einheitliche Login-Abschlüsse (Plan 1.8, Umsetzungsaudit 29.09.2026 K-08):
* Passwort, Magic Link und Google setzen denselben Stand — auch nach der
* 2FA-Challenge —, und ein eingeloggter Kunde kann seinen eigenen Link öffnen.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
/**
* @return array{user: User, secret: string|null, company: Company, pr: PressRelease}
*/
function loginCompletionCustomer(bool $twoFactor = false): array
{
$user = User::factory()->create(['is_active' => true]);
$user->assignRole('customer');
$secret = null;
if ($twoFactor) {
$secret = app(Google2FA::class)->generateSecretKey();
$user->forceFill(['two_factor_secret' => encrypt($secret), 'two_factor_confirmed_at' => now()])->save();
}
$company = Company::factory()->presseecho()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
$pr = PressRelease::factory()->create([
'user_id' => $user->id,
'company_id' => $company->id,
'portal' => $company->portal->value,
'status' => 'draft',
]);
return compact('user', 'secret', 'company', 'pr');
}
function loginCompletionLink(User $user, string $token, array $payload = []): MagicLink
{
return MagicLink::query()->create([
'user_id' => $user->id,
'token_hash' => hash('sha256', $token),
'purpose' => 'login',
'payload' => $payload ?: null,
'expires_at' => now()->addMinutes(30),
]);
}
function loginCompletionMethod(User $user): ?string
{
return ActivityEvent::query()
->where('type', ActivityType::UserLoggedIn->value)
->where('user_id', $user->id)
->latest('id')
->first()?->meta['method'] ?? null;
}
test('the password login records the last login also after the two-factor challenge', function () {
/** @var TestCase $this */
['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true);
Volt::test('auth.login')
->set('email', $user->email)
->set('password', 'password')
->call('login')
->assertRedirect(route('two-factor.challenge'));
$this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)])
->assertRedirect(route('me.dashboard', absolute: false));
$this->assertAuthenticatedAs($user);
expect($user->fresh()->last_login_at)->not->toBeNull()
->and(loginCompletionMethod($user))->toBe('password');
});
test('a magic link keeps its landing target and its method through the two-factor challenge', function () {
/** @var TestCase $this */
['user' => $user, 'secret' => $secret, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer(twoFactor: true);
loginCompletionLink($user, 'landing-2fa', ['company_id' => $company->id, 'press_release_id' => $pr->id]);
$this->get(route('magic-links.consume', ['token' => 'landing-2fa']))
->assertRedirect(route('two-factor.challenge'));
$this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)])
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
expect($user->fresh()->last_login_at)->not->toBeNull()
->and(loginCompletionMethod($user))->toBe('magic-link');
});
test('google no longer forces a remember cookie, also not through the two-factor challenge', function () {
/** @var TestCase $this */
['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true);
$provider = Mockery::mock(Provider::class);
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-1', 'email' => $user->email, 'name' => 'Kunde']));
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
$this->get(route('oauth.google.callback'))->assertRedirect(route('two-factor.challenge'));
expect(session('login.remember'))->toBeFalse();
$response = $this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)]);
$response->assertRedirect(route('me.dashboard', absolute: false));
expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse()
->and(loginCompletionMethod($user))->toBe('google')
->and($user->fresh()->last_login_at)->not->toBeNull();
});
test('google without two-factor logs in without a remember cookie', function () {
/** @var TestCase $this */
['user' => $user] = loginCompletionCustomer();
$provider = Mockery::mock(Provider::class);
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-2', 'email' => $user->email, 'name' => 'Kunde']));
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
$response = $this->get(route('oauth.google.callback'));
$response->assertRedirect(route('me.dashboard', absolute: false));
$this->assertAuthenticatedAs($user);
expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse();
});
test('a logged-in customer opening his own magic link lands on its target', function () {
/** @var TestCase $this */
['user' => $user, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer();
$link = loginCompletionLink($user, 'own-link', ['company_id' => $company->id, 'press_release_id' => $pr->id]);
$this->actingAs($user)
->get(route('magic-links.consume', ['token' => 'own-link']))
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
$this->assertAuthenticatedAs($user);
expect($link->fresh()->consumed_at)->not->toBeNull();
});
test('a logged-in user opening a link of another account keeps the previous behaviour', function () {
/** @var TestCase $this */
['user' => $user] = loginCompletionCustomer();
['user' => $other] = loginCompletionCustomer();
$link = loginCompletionLink($other, 'foreign-link');
$this->actingAs($user)
->get(route('magic-links.consume', ['token' => 'foreign-link']))
->assertRedirect(route('me.dashboard'));
$this->assertAuthenticatedAs($user);
expect($link->fresh()->consumed_at)->toBeNull();
});

View file

@ -10,8 +10,11 @@ use App\Models\User;
use App\Services\Legal\LegalAcceptanceGate;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Mail;
use Laravel\Sanctum\Sanctum;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -179,3 +182,102 @@ test('the api answers 428 with the required documents until they are accepted',
$this->getJson('/api/v1/legal/acceptances')->assertSuccessful()->assertJsonPath('gate_enabled', true)->assertJsonCount(2, 'accepted');
});
/*
* K-07 / B-13 (Umsetzungsaudit 29.09.2026): Die gezeigten Fassungen sind
* gesperrt, ein Versionswechsel zwischen Prüfung und Speichern wird nicht
* still übergangen, und der Rendering-Hook fragt je Request höchstens einmal
* (bei ausgeschaltetem Gate gar nicht) und wirft nie beim Rendern.
*/
test('the shown versions cannot be changed from the browser', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
expect(fn () => LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->set('legalAcceptanceVersions', ['agb' => '9.9', 'richtlinien' => '9.9']))
->toThrow(CannotUpdateLockedPropertyException::class);
});
test('a version change between validation and saving is rejected instead of silently skipped', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
$current = app(LegalAcceptanceGate::class)->requiredFor($owner);
$newer = LegalDocument::factory()->agb()->make(['version' => '0.7']);
$submitCalls = null;
// Anzeige und Validierung sehen 0.6, beim Speichern gilt schon 0.7.
$this->partialMock(LegalAcceptanceGate::class)
->shouldReceive('requiredFor')
->andReturnUsing(function () use (&$submitCalls, $current, $newer) {
if ($submitCalls === null) {
return $current;
}
$submitCalls++;
return $submitCalls === 1 ? $current : collect([$newer]);
});
$component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->set('aiTextDeclaration', 'none')
->set('aiDeclarationConfirmed', true)
->set('legalAcceptanceConfirmed', true);
$submitCalls = 0;
$component->call('submitForReview')
->assertHasErrors(['legalAcceptanceConfirmed']);
expect(LegalDocumentAcceptance::query()->count())->toBe(0)
->and($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
});
test('with the gate off rendering the submit pages asks nothing about legal documents', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
DB::enableQueryLog();
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])->assertOk();
expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toBeEmpty();
});
test('with the gate on a render asks once per request, not once per modal', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
DB::enableQueryLog();
LivewireVolt::test('customer.press-releases.index')->assertOk();
// Eine Abfrage je Dokumenttyp (AGB, Richtlinien) — nicht zusätzlich je Modal.
expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toHaveCount(2);
});
test('a missing required document fails the submit, not the rendering of the page', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
LegalDocument::query()->where('type', 'richtlinien')->delete();
Exceptions::fake();
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertOk()
->set('aiTextDeclaration', 'none')
->set('aiDeclarationConfirmed', true)
->set('legalAcceptanceConfirmed', true)
->call('submitForReview')
->assertHasErrors(['legalAcceptanceConfirmed']);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
Exceptions::assertReported(RuntimeException::class);
});