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:
parent
3dbc3fa673
commit
1ccb4a8479
29 changed files with 1287 additions and 73 deletions
224
app/Console/Commands/ReviewApiCompanyAssignments.php
Normal file
224
app/Console/Commands/ReviewApiCompanyAssignments.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
app/Http/Middleware/EnsureSessionIsNotRevoked.php
Normal file
52
app/Http/Middleware/EnsureSessionIsNotRevoked.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
98
app/Services/Auth/LoginCompletion.php
Normal file
98
app/Services/Auth/LoginCompletion.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 —
|
||||
|
|
|
|||
75
app/Services/Auth/SessionRevocation.php
Normal file
75
app/Services/Auth/SessionRevocation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
165
tests/Feature/Auth/LoginCompletionTest.php
Normal file
165
tests/Feature/Auth/LoginCompletionTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue