Skip to content

Commit c1849c3

Browse files
committed
feat(auth): harden the signup/login flow (verification, anti-abuse, OAuth, sessions)
Cohesive hardening of the public auth surface, no new dependencies or tables: - Mandatory email verification with a magic link: a global `EnsureEmailVerified` middleware gates page navigation until the address is confirmed; the signed verification link verifies AND logs the user in, so it works when opened on another device. Social logins/invites are already verified; self-hosted skips. - Fix email-fixup: an unverified user who mistyped their email can correct it and resend the link (`UpdateUnverifiedEmailController`). - 45s resend cooldown with a countdown on the verify screen. - Anti-abuse on register: throttle, an autofill-proof honeypot, disposable-email blocking (`NotDisposableEmail`, config-extensible) and a per-IP daily quota. - Close OAuth account-takeover: only link/create by email when the PROVIDER confirmed it (Google `email_verified` claim; GitHub `/user/emails`). - Password reset/change now drops active DB sessions, so an attacker with an open session can't survive it. - Uniform forgot-password response to stop email enumeration (+ throttle). - `SecurityHeaders` middleware and a secure session cookie by default in prod. - Strict email validation (`Email::defaults` strict + native) everywhere a new email enters, so a@b / @localhost / whitespace are rejected before they bounce. - Real-time password-strength meter on register and reset. Covered by RegistrationAbuse, AuthHardening and OauthLinkingSecurity tests.
1 parent 665cc22 commit c1849c3

32 files changed

Lines changed: 1025 additions & 62 deletions

app/Http/Controllers/App/Settings/AuthenticationController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ public function updatePassword(AuthenticationPasswordRequest $request): Redirect
3737
'password' => $request->password,
3838
]);
3939

40+
// Changing the password drops the other sessions (keeps the current
41+
// one): if it was changed over a suspected compromise, nobody else
42+
// stays logged in.
43+
if (config('session.driver') === 'database') {
44+
DB::table(config('session.table', 'sessions'))
45+
->where('user_id', $request->user()->id)
46+
->where('id', '!=', $request->session()->getId())
47+
->delete();
48+
}
49+
4050
return back()->with('flash.success', __('settings.flash.password_updated'));
4151
}
4252

app/Http/Controllers/Auth/EmailVerificationPromptController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function __invoke(Request $request): RedirectResponse|Response
2121
? redirect()->intended(route('app.calendar'))
2222
: Inertia::render('auth/VerifyEmail', [
2323
'status' => session('status'),
24+
'email' => $request->user()->email,
2425
]);
2526
}
2627
}

app/Http/Controllers/Auth/GitHubController.php

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
use Illuminate\Http\RedirectResponse;
1313
use Illuminate\Http\Request;
1414
use Illuminate\Support\Facades\Auth;
15+
use Illuminate\Support\Facades\Http;
16+
use Laravel\Socialite\Contracts\User as SocialiteUser;
1517
use Laravel\Socialite\Facades\Socialite;
18+
use Throwable;
1619

1720
class GitHubController extends Controller
1821
{
@@ -42,21 +45,52 @@ public function callback(): RedirectResponse
4245
return $this->connectToCurrentUser(Auth::user(), (string) $githubUser->getId());
4346
}
4447

45-
$user = User::where('github_id', (string) $githubUser->getId())
46-
->when($githubUser->getEmail(), fn ($query, $email) => $query->orWhere('email', $email))
47-
->first();
48+
$user = User::where('github_id', (string) $githubUser->getId())->first();
49+
50+
// Linking an existing account by email (or creating an already-verified
51+
// account) requires GitHub to have confirmed that email — the profile's
52+
// public email arrives without a verification flag, so we check it
53+
// directly against the user's emails API.
54+
if (! $user) {
55+
if (! $githubUser->getEmail()) {
56+
return redirect()->route('login')->withErrors([
57+
'email' => __('auth.github_email_unavailable'),
58+
]);
59+
}
60+
61+
if (! $this->providerEmailIsVerified($githubUser)) {
62+
return redirect()->route('login')
63+
->with('flash.error', __('auth.social_email_unverified', ['provider' => 'GitHub']));
64+
}
65+
66+
$user = User::where('email', $githubUser->getEmail())->first();
67+
}
4868

4969
if ($user) {
5070
return $this->loginExistingUser($user, (string) $githubUser->getId());
5171
}
5272

53-
if (! $githubUser->getEmail()) {
54-
return redirect()->route('login')->withErrors([
55-
'email' => __('auth.github_email_unavailable'),
56-
]);
73+
return $this->registerNewUser($githubUser);
74+
}
75+
76+
private function providerEmailIsVerified(SocialiteUser $githubUser): bool
77+
{
78+
$email = (string) $githubUser->getEmail();
79+
80+
try {
81+
$emails = Http::withToken($githubUser->token)
82+
->acceptJson()
83+
->get(config('services.github.api').'/user/emails')
84+
->throw()
85+
->json();
86+
} catch (Throwable) {
87+
return false;
5788
}
5889

59-
return $this->registerNewUser($githubUser);
90+
return collect($emails)->contains(
91+
fn ($entry): bool => strcasecmp((string) data_get($entry, 'email'), $email) === 0
92+
&& (bool) data_get($entry, 'verified', false),
93+
);
6094
}
6195

6296
private function connectToCurrentUser(User $user, string $githubId): RedirectResponse
@@ -95,7 +129,7 @@ private function loginExistingUser(User $user, string $githubId): RedirectRespon
95129
return redirect()->route('app.home');
96130
}
97131

98-
private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): RedirectResponse
132+
private function registerNewUser(SocialiteUser $githubUser): RedirectResponse
99133
{
100134
$utmParameters = $this->retrieveUtmParameters();
101135

app/Http/Controllers/Auth/GoogleController.php

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Illuminate\Http\RedirectResponse;
1313
use Illuminate\Http\Request;
1414
use Illuminate\Support\Facades\Auth;
15+
use Laravel\Socialite\Contracts\User as SocialiteUser;
1516
use Laravel\Socialite\Facades\Socialite;
1617

1718
class GoogleController extends Controller
@@ -40,9 +41,20 @@ public function callback(): RedirectResponse
4041
return $this->connectToCurrentUser(Auth::user(), $googleUser->getId());
4142
}
4243

43-
$user = User::where('google_id', $googleUser->getId())
44-
->orWhere('email', $googleUser->getEmail())
45-
->first();
44+
$user = User::where('google_id', $googleUser->getId())->first();
45+
46+
// Linking an existing account by email (or creating an already-verified
47+
// account) requires the PROVIDER to have confirmed that email —
48+
// otherwise a Google account carrying someone else's unconfirmed email
49+
// would become an account takeover.
50+
if (! $user) {
51+
if (! $this->providerEmailIsVerified($googleUser)) {
52+
return redirect()->route('login')
53+
->with('flash.error', __('auth.social_email_unverified', ['provider' => 'Google']));
54+
}
55+
56+
$user = User::where('email', $googleUser->getEmail())->first();
57+
}
4658

4759
if ($user) {
4860
return $this->loginExistingUser($user, $googleUser->getId());
@@ -51,6 +63,14 @@ public function callback(): RedirectResponse
5163
return $this->registerNewUser($googleUser);
5264
}
5365

66+
/**
67+
* OIDC `email_verified` claim from Google's userinfo; absent = don't trust.
68+
*/
69+
private function providerEmailIsVerified(SocialiteUser $googleUser): bool
70+
{
71+
return (bool) data_get($googleUser->user, 'email_verified', false);
72+
}
73+
5474
private function connectToCurrentUser(User $user, string $googleId): RedirectResponse
5575
{
5676
$existing = User::where('google_id', $googleId)
@@ -87,7 +107,7 @@ private function loginExistingUser(User $user, string $googleId): RedirectRespon
87107
return redirect()->route('app.home');
88108
}
89109

90-
private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse
110+
private function registerNewUser(SocialiteUser $googleUser): RedirectResponse
91111
{
92112
$utmParameters = $this->retrieveUtmParameters();
93113

app/Http/Controllers/Auth/NewPasswordController.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Auth\Events\PasswordReset;
99
use Illuminate\Http\RedirectResponse;
1010
use Illuminate\Http\Request;
11+
use Illuminate\Support\Facades\DB;
1112
use Illuminate\Support\Facades\Hash;
1213
use Illuminate\Support\Facades\Password;
1314
use Illuminate\Support\Str;
@@ -35,7 +36,7 @@ public function store(Request $request): RedirectResponse
3536
{
3637
$request->validate([
3738
'token' => ['required'],
38-
'email' => ['required', 'email'],
39+
'email' => ['required', Rules\Email::default()],
3940
'password' => ['required', 'confirmed', Rules\Password::defaults()],
4041
]);
4142

@@ -47,6 +48,15 @@ function ($user) use ($request) {
4748
'remember_token' => Str::random(60),
4849
])->save();
4950

51+
// If the reset was triggered by an account compromise, an
52+
// attacker with an open session can't survive it: drop every
53+
// active session (the owner logs back in with the new password).
54+
if (config('session.driver') === 'database') {
55+
DB::table(config('session.table', 'sessions'))
56+
->where('user_id', $user->id)
57+
->delete();
58+
}
59+
5060
event(new PasswordReset($user));
5161
}
5262
);

app/Http/Controllers/Auth/PasswordResetLinkController.php

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Http\RedirectResponse;
99
use Illuminate\Http\Request;
1010
use Illuminate\Support\Facades\Password;
11+
use Illuminate\Validation\Rules\Email;
1112
use Inertia\Inertia;
1213
use Inertia\Response;
1314

@@ -29,16 +30,13 @@ public function create(): Response
2930
public function store(Request $request): RedirectResponse
3031
{
3132
$request->validate([
32-
'email' => ['required', 'email'],
33+
'email' => ['required', Email::default()],
3334
]);
3435

35-
$status = Password::sendResetLink(
36-
$request->only('email')
37-
);
36+
Password::sendResetLink($request->only('email'));
3837

39-
return $status == Password::RESET_LINK_SENT
40-
? back()->with('status', __($status))
41-
: back()->withInput($request->only('email'))
42-
->withErrors(['email' => __($status)]);
38+
// Uniform response whether or not the email exists: a different response
39+
// would let an attacker enumerate which emails have an account.
40+
return back()->with('status', __('passwords.sent_uniform'));
4341
}
4442
}

app/Http/Controllers/Auth/RegisteredUserController.php

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
99
use App\Http\Controllers\Controller;
1010
use App\Models\User;
11+
use App\Rules\NotDisposableEmail;
1112
use Illuminate\Auth\Events\Registered;
1213
use Illuminate\Http\RedirectResponse;
1314
use Illuminate\Http\Request;
1415
use Illuminate\Support\Facades\Auth;
16+
use Illuminate\Support\Facades\Log;
1517
use Illuminate\Validation\Rules;
18+
use Illuminate\Validation\Rules\Email;
19+
use Illuminate\Validation\ValidationException;
1620
use Inertia\Inertia;
1721
use Inertia\Response;
1822

@@ -32,12 +36,29 @@ public function create(Request $request): Response
3236

3337
public function store(Request $request): RedirectResponse
3438
{
39+
// Honeypot: a hidden field only bots fill in. The front-end clears it on
40+
// autofill, so a non-empty value here means an automated request. Reply
41+
// with a "success" redirect so the bot isn't told it was detected.
42+
if ($request->filled('contact_time')) {
43+
Log::info('Registration honeypot triggered', ['ip' => $request->ip()]);
44+
45+
return redirect()->route('login');
46+
}
47+
48+
$emailRules = ['required', 'string', 'lowercase', Email::default(), 'max:255', 'unique:'.User::class];
49+
50+
if (config('trypost.security.block_disposable_emails')) {
51+
$emailRules[] = new NotDisposableEmail;
52+
}
53+
3554
$request->validate([
3655
'name' => ['required', 'string', 'max:255'],
37-
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
56+
'email' => $emailRules,
3857
'password' => ['required', Rules\Password::defaults()],
3958
]);
4059

60+
$this->ensureIpRegistrationQuota($request);
61+
4162
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
4263

4364
$utmParameters = $this->retrieveUtmParameters();
@@ -66,4 +87,31 @@ public function store(Request $request): RedirectResponse
6687

6788
return redirect()->route('register.success', $utmParameters);
6889
}
90+
91+
/**
92+
* A free trial hands out AI credits, so N accounts from the same IP in one
93+
* day is the classic farming pattern. The error is intentionally generic on
94+
* the email field: it doesn't confirm to the attacker which limit was hit.
95+
*/
96+
private function ensureIpRegistrationQuota(Request $request): void
97+
{
98+
$limit = (int) config('trypost.security.max_registrations_per_ip_per_day', 0);
99+
100+
if ($limit <= 0) {
101+
return;
102+
}
103+
104+
$recent = User::query()
105+
->where('registration_ip', $request->ip())
106+
->where('created_at', '>=', now()->subDay())
107+
->count();
108+
109+
if ($recent >= $limit) {
110+
Log::warning('Registration per-IP quota reached', ['ip' => $request->ip(), 'count' => $recent]);
111+
112+
throw ValidationException::withMessages([
113+
'email' => __('auth.register.quota_reached'),
114+
]);
115+
}
116+
}
69117
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Auth;
6+
7+
use App\Http\Controllers\Controller;
8+
use App\Models\User;
9+
use App\Rules\NotDisposableEmail;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Validation\Rule;
13+
use Illuminate\Validation\Rules\Email;
14+
15+
class UpdateUnverifiedEmailController extends Controller
16+
{
17+
/**
18+
* Fix the email before verifying: a user who mistypes their address at
19+
* signup is stuck (the account exists, the link never arrives). This only
20+
* works while the email is unverified; a verified account changes its email
21+
* through the settings flow, with re-authentication.
22+
*/
23+
public function update(Request $request): RedirectResponse
24+
{
25+
$user = $request->user();
26+
27+
if ($user->hasVerifiedEmail()) {
28+
return redirect()->intended(route('app.calendar'));
29+
}
30+
31+
$rules = [
32+
'email' => [
33+
'required', 'string', 'lowercase', Email::default(), 'max:255',
34+
Rule::unique(User::class)->ignore($user->id),
35+
],
36+
];
37+
38+
if (config('trypost.security.block_disposable_emails')) {
39+
$rules['email'][] = new NotDisposableEmail;
40+
}
41+
42+
$validated = $request->validate($rules);
43+
44+
if ($validated['email'] !== $user->email) {
45+
$user->forceFill(['email' => $validated['email']])->save();
46+
}
47+
48+
$user->sendEmailVerificationNotification();
49+
50+
return back()->with('status', 'verification-link-sent');
51+
}
52+
}

app/Http/Controllers/Auth/VerifyEmailController.php

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,35 @@
55
namespace App\Http\Controllers\Auth;
66

77
use App\Http\Controllers\Controller;
8+
use App\Models\User;
89
use Illuminate\Auth\Events\Verified;
9-
use Illuminate\Foundation\Auth\EmailVerificationRequest;
1010
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\Auth;
13+
use Illuminate\Support\Str;
1114

1215
class VerifyEmailController extends Controller
1316
{
1417
/**
15-
* Mark the authenticated user's email address as verified.
18+
* Confirm the email from the signed link and double as a magic link: the
19+
* signed URL proves ownership of the email, so it also authenticates a user
20+
* who arrives logged out (email opened in another browser or device).
1621
*/
17-
public function __invoke(EmailVerificationRequest $request): RedirectResponse
22+
public function __invoke(Request $request, string $id, string $hash): RedirectResponse
1823
{
19-
if ($request->user()->hasVerifiedEmail()) {
20-
return redirect()->intended(route('app.calendar').'?verified=1');
24+
abort_unless(Str::isUuid($id), 404);
25+
26+
$user = User::findOrFail($id);
27+
28+
abort_unless(hash_equals(sha1($user->getEmailForVerification()), $hash), 403);
29+
30+
if (! $user->hasVerifiedEmail() && $user->markEmailAsVerified()) {
31+
event(new Verified($user));
2132
}
2233

23-
if ($request->user()->markEmailAsVerified()) {
24-
event(new Verified($request->user()));
34+
if (! $request->user()?->is($user)) {
35+
Auth::login($user);
36+
$request->session()->regenerate();
2537
}
2638

2739
return redirect()->intended(route('app.calendar').'?verified=1');

0 commit comments

Comments
 (0)