Skip to content
Merged
22 changes: 22 additions & 0 deletions app/Http/Controllers/Auth/AuthenticatedSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Illuminate\View\View;

class AuthenticatedSessionController extends Controller
{
/**
* Long-lived marker dropped at login so a later guest redirect can tell an
* expired session ("you were signed in") apart from a never-authenticated
* visit, and surface the expired-session banner only in the former case.
*/
public const RETURNING_COOKIE = 'cz_returning';

/**
* Display the login view.
*/
Expand All @@ -28,6 +36,16 @@ public function store(LoginRequest $request): RedirectResponse

$request->session()->regenerate();

// Outlives the session so a later expiry can be recognised as one.
Cookie::queue(Cookie::forever(self::RETURNING_COOKIE, '1'));

// A return-to-page target stashed by the 419 / session-expiry handler
// wins over first-login persona routing — the user was actively
// somewhere, so put them back there.
if ($request->session()->has('url.intended')) {
return redirect()->intended(route('dashboard', absolute: false));
}

$user = $request->user();
if ($user && $user->isSupportAgent()) {
return redirect()->route('support.dashboard');
Expand All @@ -50,6 +68,10 @@ public function destroy(Request $request): RedirectResponse

$request->session()->regenerateToken();

// Deliberate logout, not an expiry — drop the marker so the next guest
// redirect lands on a plain /login without the expired-session banner.
Cookie::queue(Cookie::forget(self::RETURNING_COOKIE));

return redirect('/');
}
}
78 changes: 78 additions & 0 deletions app/Support/ReturnToResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace App\Support;

use Illuminate\Http\Request;
use Illuminate\Routing\Exceptions\UrlGenerationException;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\HttpException;

/**
* Resolves the page a user should be returned to after re-authenticating
* following a 419 / session-expiry bounce.
*
* The candidate is read from a hidden `_return_to` field first (it rides in
* the POST body, so it survives even when the session and CSRF token are
* gone), then falls back to the Referer header. A candidate is only accepted
* when it points at a same-origin GET route — never the POST/PATCH/DELETE
* action that just failed, so the mutation is never implicitly replayed.
* Anything that fails validation returns null, and callers fall back to the
* dashboard.
*/
class ReturnToResolver
{
public static function resolve(Request $request): ?string
{
$candidate = $request->input('_return_to');

if (! is_string($candidate) || trim($candidate) === '') {
$candidate = $request->headers->get('referer');
}

if (! is_string($candidate) || trim($candidate) === '') {
return null;
}

$candidate = trim($candidate);

if (! self::isSameOrigin($request, $candidate)) {
return null;
}

if (! self::resolvesToGetRoute($candidate)) {
return null;
}

return $candidate;
}

private static function isSameOrigin(Request $request, string $candidate): bool
{
$parts = parse_url($candidate);

if ($parts === false || ! isset($parts['host'])) {
return false;
}

if (($parts['scheme'] ?? $request->getScheme()) !== $request->getScheme()) {
return false;
}

return strcasecmp($parts['host'], $request->getHost()) === 0;
}

private static function resolvesToGetRoute(string $candidate): bool
{
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';

$probe = Request::create($path, 'GET');

try {
$route = Route::getRoutes()->match($probe);
} catch (HttpException | UrlGenerationException) {
return false;
}

return in_array('GET', $route->methods(), true);
}
}
59 changes: 52 additions & 7 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@
$middleware->validateCsrfTokens(except: [
'webhooks/mailgun',
]);

// Non-sensitive boolean marker — left unencrypted so it reads as plain
// text in the guest-redirect check below (and in tests).
$middleware->encryptCookies(except: [
\App\Http\Controllers\Auth\AuthenticatedSessionController::RETURNING_COOKIE,
]);

// Guests bounced from a protected page get the expired-session banner
// only when they carry the returning-user marker (their session expired),
// not on a first, never-authenticated visit.
$middleware->redirectGuestsTo(function (Request $request) {
return $request->hasCookie(\App\Http\Controllers\Auth\AuthenticatedSessionController::RETURNING_COOKIE)
? route('login', ['expired' => 1])
: route('login');
});
})
->withExceptions(function (Exceptions $exceptions): void {
$renderForbidden = function (Request $request, ?string $details = null) {
Expand Down Expand Up @@ -71,13 +86,43 @@
return redirect('/');
};

$renderSessionExpired = function (Request $request) {
$returnTo = \App\Support\ReturnToResolver::resolve($request);

Auth::guard('web')->logout();

if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();

if ($returnTo !== null) {
$request->session()->put('url.intended', $returnTo);
}
}

$loginUrl = route('login', ['expired' => 1]);

if ($request->expectsJson()) {
return response()->json([
'message' => 'Your session has expired. Please sign in again.',
'redirect' => $loginUrl,
], 419);
}

return redirect()->to($loginUrl);
};

$exceptions->render(function (AuthorizationException $exception, Request $request) use ($renderForbidden) {
return $renderForbidden($request, $exception->getMessage());
});

$exceptions->render(function (HttpExceptionInterface $exception, Request $request) use ($renderForbidden, $renderLogoutRedirect) {
if ($exception->getStatusCode() === 419 && ($request->routeIs('logout') || $request->is('logout'))) {
return $renderLogoutRedirect($request);
$exceptions->render(function (HttpExceptionInterface $exception, Request $request) use ($renderForbidden, $renderLogoutRedirect, $renderSessionExpired) {
if ($exception->getStatusCode() === 419) {
if ($request->routeIs('logout') || $request->is('logout')) {
return $renderLogoutRedirect($request);
}

return $renderSessionExpired($request);
}

if ($exception->getStatusCode() !== 403) {
Expand All @@ -87,11 +132,11 @@
return $renderForbidden($request, $exception->getMessage());
});

$exceptions->render(function (TokenMismatchException $exception, Request $request) use ($renderLogoutRedirect) {
if (! $request->routeIs('logout') && ! $request->is('logout')) {
return null;
$exceptions->render(function (TokenMismatchException $exception, Request $request) use ($renderLogoutRedirect, $renderSessionExpired) {
if ($request->routeIs('logout') || $request->is('logout')) {
return $renderLogoutRedirect($request);
}

return $renderLogoutRedirect($request);
return $renderSessionExpired($request);
});
})->create();
10 changes: 6 additions & 4 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ services:
WWWGROUP: '${WWWGROUP}'
image: 'sail-8.4/app'
command: >
sh -c "php artisan schedule:work"
sh -c "until mysqladmin ping -hmysql -usail -ppassword --silent; do echo 'Waiting for MySQL for scheduler...'; sleep 2; done; echo 'MySQL is ready for scheduler.'; exec php artisan schedule:work"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use configured DB credentials in wait loops

When DB_PASSWORD is anything other than the literal password—including the checked-in .env.example, where it is empty—this new wait loop never exits even after depends_on: condition: service_healthy passes, so the scheduler stays stuck instead of starting; the queue worker has the same hard-coded -ppassword on line 55. The mysqladmin docs define -p[password] as the password value sent to the server, so this needs to use the Compose-provided DB password (or skip-password for the empty-password case) rather than a fixed value.

Useful? React with 👍 / 👎.

environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
Expand All @@ -41,7 +41,8 @@ services:
networks:
- sail
depends_on:
- mysql
mysql:
condition: service_healthy
restart: unless-stopped
queue:
build:
Expand All @@ -51,7 +52,7 @@ services:
WWWGROUP: '${WWWGROUP}'
image: 'sail-8.4/app'
command: >
sh -c "php artisan queue:work --tries=3 --timeout=90 --max-time=3600"
sh -c "until mysqladmin ping -hmysql -usail -ppassword --silent; do echo 'Waiting for MySQL for queue worker...'; sleep 2; done; echo 'MySQL is ready for queue worker.'; exec php artisan queue:work --tries=3 --timeout=90 --max-time=3600"
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
Expand All @@ -60,7 +61,8 @@ services:
networks:
- sail
depends_on:
- mysql
mysql:
condition: service_healthy
restart: unless-stopped
mysql:
image: 'mysql/mysql-server:8.0'
Expand Down
12 changes: 6 additions & 6 deletions docs/milestones/19_RC_HARDENING_OPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ Supporting ops doc: [`docs/ops/DOCS_DX.md`](../ops/DOCS_DX.md)
- **Findings tracking trial:** Through M19, new findings/bugs/todos go to GitHub Issues (closed via `Fixes #N` on the merging PR) instead of new `docs/qa/Finding*.md` docs. Existing finding docs stay put. M20 kickoff decides whether to keep, revert, or hybridize. See [`docs/DOC_ROLES.md`](../DOC_ROLES.md#findings-conventions).

## Current Focus
- Active phase: Phase 2 (auth hardening). Phase 1 complete; other phases pre-flight.
- Phases 1–2 complete; Phase 3+ pre-flight, next phase TBD.
- Phase 1: [`docs/strategies/x19.1_NOTIFICATION_COVERAGE_AUDIT.md`](../strategies/x19.1_NOTIFICATION_COVERAGE_AUDIT.md) ✓
- Phase 2: [`docs/strategies/19.2_AUTH_HARDENING.md`](../strategies/19.2_AUTH_HARDENING.md)
- Phase 2: [`docs/strategies/x19.2_AUTH_HARDENING.md`](../strategies/x19.2_AUTH_HARDENING.md) ✓
- Phase 3: [`docs/strategies/19.3_LLC_FORMATION.md`](../strategies/19.3_LLC_FORMATION.md)
- Phase 4: [`docs/strategies/19.4_VISUAL_IDENTITY_POLISH.md`](../strategies/19.4_VISUAL_IDENTITY_POLISH.md)
- Phase 5: [`docs/strategies/19.5_LEGAL_LAYER.md`](../strategies/19.5_LEGAL_LAYER.md)
Expand All @@ -37,8 +37,8 @@ Supporting ops doc: [`docs/ops/DOCS_DX.md`](../ops/DOCS_DX.md)
### [x] Phase 1 — Notification Coverage Audit & Verification
Document every outbound mail type — trigger, recipient, delivery-log behavior — so the full mail surface is explicitly accounted for before open beta. Includes end-to-end verification of every notice class in the running stack against realistic scenarios (time-advanced past-due flows included) and a stress-readiness check; catch-all alias stays flipped off so the rest of MS19 emits mail under prod-like routing. See [`x19.1_NOTIFICATION_COVERAGE_AUDIT.md`](../strategies/x19.1_NOTIFICATION_COVERAGE_AUDIT.md).

### [ ] Phase 2 — Auth/Password Policy Hardening
Implement 419-to-login redirect and site-wide session-expiry logout.
### [x] Phase 2 — Auth/Password Policy Hardening
Implement 419-to-login redirect and site-wide session-expiry logout, with return-to-page after re-auth. See [`x19.2_AUTH_HARDENING.md`](../strategies/x19.2_AUTH_HARDENING.md).

### [ ] Phase 3 — LLC Formation
Form a single-member LLC in Arizona; obtain EIN; open a business bank account; draft and sign an operating agreement; update CryptoZing references to reflect the entity. Provides the entity backing the Phase 5 legal-layer ToS protections need to actually shield the operator personally. Phase 3 (LLC) and Phase 5 (Legal Layer) run as **independent parallel tracks** — drafting/UI work in Phase 5 does not gate on LLC status; only the deploy-time entity-name swap at MS21 needs the formed entity. Both must land before MS21.
Expand All @@ -61,8 +61,8 @@ Add 2FA to the RC. Email-based 2FA as the baseline; TOTP / authenticator-app 2FA
## Exit Criteria

- [ ] Notification coverage documented AND verified end-to-end: every outbound mail type accounted for with intended trigger, recipient, and delivery-log behavior, and every notice class observed firing correctly in the running stack against realistic scenarios (including time-advanced past-due flows). Catch-all alias disabled; later MS19 phases run under prod-like mail routing.
- [ ] 419-to-login redirect implemented and tested.
- [ ] Site-wide session expiry logout implemented and tested.
- [x] 419-to-login redirect implemented and tested.
- [x] Site-wide session expiry logout implemented and tested.
- [ ] LLC formed in Arizona; EIN obtained; business bank account opened; operating agreement signed; CryptoZing references updated to reflect the entity.
- [ ] Favicon set generated and wired across all surfaces (Laravel app, marketing site, any other distinct surface) with theme-cohesive yet purpose-distinguishable per-surface variants.
- [ ] og:image / social-preview meta tags wired and validated against major platform preview tools.
Expand Down
48 changes: 0 additions & 48 deletions docs/strategies/19.2_AUTH_HARDENING.md

This file was deleted.

2 changes: 2 additions & 0 deletions docs/strategies/19.4_VISUAL_IDENTITY_POLISH.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Parent milestone doc: [`docs/milestones/19_RC_HARDENING_OPS.md`](../milestones/1

This phase has **no dependency on Phase 3 (LLC)** — it is pure visual/copy work. Slotted at Phase 4 so subsequent phases (Legal Layer, Content Promises, Contributor Docs) review their surfaces in their final visual state instead of pre-polish.

> **Candidate scope addition:** Branded custom error pages — 404/500/503/429 — are tracked in [#96](https://github.com/n8bar/CryptoZing/issues/96). Only `errors/403.blade.php` exists today; the rest fall through to Laravel's unbranded defaults. This is the natural home (branded chrome), but inclusion isn't yet folded into the section list below — confirm at flesh-out. (419 is out of scope here: handled by the Phase 2 auth-hardening redirect.)

## Decisions to confirm before flesh-out

- **Favicon design direction:** Lift existing CryptoZing brand colors/glyph and produce variants, or commission/draft a fresh icon system. _Answer:_ _(pending)_
Expand Down
Loading
Loading