diff --git a/app/Http/Controllers/App/Settings/AuthenticationController.php b/app/Http/Controllers/App/Settings/AuthenticationController.php index cb7d29be..34489d2b 100644 --- a/app/Http/Controllers/App/Settings/AuthenticationController.php +++ b/app/Http/Controllers/App/Settings/AuthenticationController.php @@ -37,6 +37,16 @@ public function updatePassword(AuthenticationPasswordRequest $request): Redirect 'password' => $request->password, ]); + // Changing the password drops the other sessions (keeps the current + // one): if it was changed over a suspected compromise, nobody else + // stays logged in. + if (config('session.driver') === 'database') { + DB::table(config('session.table', 'sessions')) + ->where('user_id', $request->user()->id) + ->where('id', '!=', $request->session()->getId()) + ->delete(); + } + return back()->with('flash.success', __('settings.flash.password_updated')); } diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php index 01645504..dd598bb8 100644 --- a/app/Http/Controllers/Auth/EmailVerificationPromptController.php +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -21,6 +21,7 @@ public function __invoke(Request $request): RedirectResponse|Response ? redirect()->intended(route('app.calendar')) : Inertia::render('auth/VerifyEmail', [ 'status' => session('status'), + 'email' => $request->user()->email, ]); } } diff --git a/app/Http/Controllers/Auth/GitHubController.php b/app/Http/Controllers/Auth/GitHubController.php index 3b3f09ba..35904263 100644 --- a/app/Http/Controllers/Auth/GitHubController.php +++ b/app/Http/Controllers/Auth/GitHubController.php @@ -12,7 +12,10 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Http; +use Laravel\Socialite\Contracts\User as SocialiteUser; use Laravel\Socialite\Facades\Socialite; +use Throwable; class GitHubController extends Controller { @@ -42,21 +45,52 @@ public function callback(): RedirectResponse return $this->connectToCurrentUser(Auth::user(), (string) $githubUser->getId()); } - $user = User::where('github_id', (string) $githubUser->getId()) - ->when($githubUser->getEmail(), fn ($query, $email) => $query->orWhere('email', $email)) - ->first(); + $user = User::where('github_id', (string) $githubUser->getId())->first(); + + // Linking an existing account by email (or creating an already-verified + // account) requires GitHub to have confirmed that email — the profile's + // public email arrives without a verification flag, so we check it + // directly against the user's emails API. + if (! $user) { + if (! $githubUser->getEmail()) { + return redirect()->route('login')->withErrors([ + 'email' => __('auth.github_email_unavailable'), + ]); + } + + if (! $this->providerEmailIsVerified($githubUser)) { + return redirect()->route('login') + ->with('flash.error', __('auth.social_email_unverified', ['provider' => 'GitHub'])); + } + + $user = User::where('email', $githubUser->getEmail())->first(); + } if ($user) { return $this->loginExistingUser($user, (string) $githubUser->getId()); } - if (! $githubUser->getEmail()) { - return redirect()->route('login')->withErrors([ - 'email' => __('auth.github_email_unavailable'), - ]); + return $this->registerNewUser($githubUser); + } + + private function providerEmailIsVerified(SocialiteUser $githubUser): bool + { + $email = (string) $githubUser->getEmail(); + + try { + $emails = Http::withToken($githubUser->token) + ->acceptJson() + ->get(config('services.github.api').'/user/emails') + ->throw() + ->json(); + } catch (Throwable) { + return false; } - return $this->registerNewUser($githubUser); + return collect($emails)->contains( + fn ($entry): bool => strcasecmp((string) data_get($entry, 'email'), $email) === 0 + && (bool) data_get($entry, 'verified', false), + ); } private function connectToCurrentUser(User $user, string $githubId): RedirectResponse @@ -95,7 +129,7 @@ private function loginExistingUser(User $user, string $githubId): RedirectRespon return redirect()->route('app.home'); } - private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): RedirectResponse + private function registerNewUser(SocialiteUser $githubUser): RedirectResponse { $utmParameters = $this->retrieveUtmParameters(); diff --git a/app/Http/Controllers/Auth/GoogleController.php b/app/Http/Controllers/Auth/GoogleController.php index f7e1b5da..e2f8b6ca 100644 --- a/app/Http/Controllers/Auth/GoogleController.php +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -12,6 +12,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Laravel\Socialite\Contracts\User as SocialiteUser; use Laravel\Socialite\Facades\Socialite; class GoogleController extends Controller @@ -40,9 +41,20 @@ public function callback(): RedirectResponse return $this->connectToCurrentUser(Auth::user(), $googleUser->getId()); } - $user = User::where('google_id', $googleUser->getId()) - ->orWhere('email', $googleUser->getEmail()) - ->first(); + $user = User::where('google_id', $googleUser->getId())->first(); + + // Linking an existing account by email (or creating an already-verified + // account) requires the PROVIDER to have confirmed that email — + // otherwise a Google account carrying someone else's unconfirmed email + // would become an account takeover. + if (! $user) { + if (! $this->providerEmailIsVerified($googleUser)) { + return redirect()->route('login') + ->with('flash.error', __('auth.social_email_unverified', ['provider' => 'Google'])); + } + + $user = User::where('email', $googleUser->getEmail())->first(); + } if ($user) { return $this->loginExistingUser($user, $googleUser->getId()); @@ -51,6 +63,14 @@ public function callback(): RedirectResponse return $this->registerNewUser($googleUser); } + /** + * OIDC `email_verified` claim from Google's userinfo; absent = don't trust. + */ + private function providerEmailIsVerified(SocialiteUser $googleUser): bool + { + return (bool) data_get($googleUser->user, 'email_verified', false); + } + private function connectToCurrentUser(User $user, string $googleId): RedirectResponse { $existing = User::where('google_id', $googleId) @@ -87,7 +107,7 @@ private function loginExistingUser(User $user, string $googleId): RedirectRespon return redirect()->route('app.home'); } - private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse + private function registerNewUser(SocialiteUser $googleUser): RedirectResponse { $utmParameters = $this->retrieveUtmParameters(); diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php index 7d60f33f..e39aa9f3 100644 --- a/app/Http/Controllers/Auth/NewPasswordController.php +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -8,6 +8,7 @@ use Illuminate\Auth\Events\PasswordReset; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; @@ -35,7 +36,7 @@ public function store(Request $request): RedirectResponse { $request->validate([ 'token' => ['required'], - 'email' => ['required', 'email'], + 'email' => ['required', Rules\Email::default()], 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); @@ -47,6 +48,15 @@ function ($user) use ($request) { 'remember_token' => Str::random(60), ])->save(); + // If the reset was triggered by an account compromise, an + // attacker with an open session can't survive it: drop every + // active session (the owner logs back in with the new password). + if (config('session.driver') === 'database') { + DB::table(config('session.table', 'sessions')) + ->where('user_id', $user->id) + ->delete(); + } + event(new PasswordReset($user)); } ); diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php index d51d543e..da54d5fb 100644 --- a/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -8,6 +8,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Password; +use Illuminate\Validation\Rules\Email; use Inertia\Inertia; use Inertia\Response; @@ -29,16 +30,13 @@ public function create(): Response public function store(Request $request): RedirectResponse { $request->validate([ - 'email' => ['required', 'email'], + 'email' => ['required', Email::default()], ]); - $status = Password::sendResetLink( - $request->only('email') - ); + Password::sendResetLink($request->only('email')); - return $status == Password::RESET_LINK_SENT - ? back()->with('status', __($status)) - : back()->withInput($request->only('email')) - ->withErrors(['email' => __($status)]); + // Uniform response whether or not the email exists: a different response + // would let an attacker enumerate which emails have an account. + return back()->with('status', __('passwords.sent_uniform')); } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index a22c77e8..76091711 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -8,11 +8,15 @@ use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters; use App\Http\Controllers\Controller; use App\Models\User; +use App\Rules\NotDisposableEmail; use Illuminate\Auth\Events\Registered; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rules; +use Illuminate\Validation\Rules\Email; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; @@ -32,12 +36,29 @@ public function create(Request $request): Response public function store(Request $request): RedirectResponse { + // Honeypot: a hidden field only bots fill in. The front-end clears it on + // autofill, so a non-empty value here means an automated request. Reply + // with a "success" redirect so the bot isn't told it was detected. + if ($request->filled('contact_time')) { + Log::info('Registration honeypot triggered', ['ip' => $request->ip()]); + + return redirect()->route('login'); + } + + $emailRules = ['required', 'string', 'lowercase', Email::default(), 'max:255', 'unique:'.User::class]; + + if (config('trypost.security.block_disposable_emails')) { + $emailRules[] = new NotDisposableEmail; + } + $request->validate([ 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], + 'email' => $emailRules, 'password' => ['required', Rules\Password::defaults()], ]); + $this->ensureIpRegistrationQuota($request); + $isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/'); $utmParameters = $this->retrieveUtmParameters(); @@ -66,4 +87,31 @@ public function store(Request $request): RedirectResponse return redirect()->route('register.success', $utmParameters); } + + /** + * A free trial hands out AI credits, so N accounts from the same IP in one + * day is the classic farming pattern. The error is intentionally generic on + * the email field: it doesn't confirm to the attacker which limit was hit. + */ + private function ensureIpRegistrationQuota(Request $request): void + { + $limit = (int) config('trypost.security.max_registrations_per_ip_per_day', 0); + + if ($limit <= 0) { + return; + } + + $recent = User::query() + ->where('registration_ip', $request->ip()) + ->where('created_at', '>=', now()->subDay()) + ->count(); + + if ($recent >= $limit) { + Log::warning('Registration per-IP quota reached', ['ip' => $request->ip(), 'count' => $recent]); + + throw ValidationException::withMessages([ + 'email' => __('auth.register.quota_reached'), + ]); + } + } } diff --git a/app/Http/Controllers/Auth/UpdateUnverifiedEmailController.php b/app/Http/Controllers/Auth/UpdateUnverifiedEmailController.php new file mode 100644 index 00000000..9ee895a6 --- /dev/null +++ b/app/Http/Controllers/Auth/UpdateUnverifiedEmailController.php @@ -0,0 +1,52 @@ +user(); + + if ($user->hasVerifiedEmail()) { + return redirect()->intended(route('app.calendar')); + } + + $rules = [ + 'email' => [ + 'required', 'string', 'lowercase', Email::default(), 'max:255', + Rule::unique(User::class)->ignore($user->id), + ], + ]; + + if (config('trypost.security.block_disposable_emails')) { + $rules['email'][] = new NotDisposableEmail; + } + + $validated = $request->validate($rules); + + if ($validated['email'] !== $user->email) { + $user->forceFill(['email' => $validated['email']])->save(); + } + + $user->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php index aa17b64e..794b19dd 100644 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -5,23 +5,35 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Models\User; use Illuminate\Auth\Events\Verified; -use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; class VerifyEmailController extends Controller { /** - * Mark the authenticated user's email address as verified. + * Confirm the email from the signed link and double as a magic link: the + * signed URL proves ownership of the email, so it also authenticates a user + * who arrives logged out (email opened in another browser or device). */ - public function __invoke(EmailVerificationRequest $request): RedirectResponse + public function __invoke(Request $request, string $id, string $hash): RedirectResponse { - if ($request->user()->hasVerifiedEmail()) { - return redirect()->intended(route('app.calendar').'?verified=1'); + abort_unless(Str::isUuid($id), 404); + + $user = User::findOrFail($id); + + abort_unless(hash_equals(sha1($user->getEmailForVerification()), $hash), 403); + + if (! $user->hasVerifiedEmail() && $user->markEmailAsVerified()) { + event(new Verified($user)); } - if ($request->user()->markEmailAsVerified()) { - event(new Verified($request->user())); + if (! $request->user()?->is($user)) { + Auth::login($user); + $request->session()->regenerate(); } return redirect()->intended(route('app.calendar').'?verified=1'); diff --git a/app/Http/Middleware/App/EnsureEmailVerified.php b/app/Http/Middleware/App/EnsureEmailVerified.php new file mode 100644 index 00000000..a53df356 --- /dev/null +++ b/app/Http/Middleware/App/EnsureEmailVerified.php @@ -0,0 +1,46 @@ +user(); + + if (! $user || $user->hasVerifiedEmail()) { + return $next($request); + } + + if ($request->routeIs('verification.*', 'logout', 'register.success')) { + return $next($request); + } + + if (! $request->isMethod('GET') || $request->expectsJson()) { + return $next($request); + } + + return redirect()->route('verification.notice'); + } +} diff --git a/app/Http/Middleware/App/SecurityHeaders.php b/app/Http/Middleware/App/SecurityHeaders.php new file mode 100644 index 00000000..8fed2cc9 --- /dev/null +++ b/app/Http/Middleware/App/SecurityHeaders.php @@ -0,0 +1,34 @@ +headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + if ($request->secure() && app()->isProduction()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php b/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php index 4c27759a..c72010a2 100644 --- a/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php +++ b/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Email; class StoreWorkspaceInviteRequest extends FormRequest { @@ -27,7 +28,7 @@ public function authorize(): bool public function rules(): array { return [ - 'email' => ['required', 'email', 'max:255'], + 'email' => ['required', Email::default(), 'max:255'], 'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))], ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 960c0244..520bd109 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -48,6 +48,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; +use Illuminate\Validation\Rules\Email; use Illuminate\Validation\Rules\Password; use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookReceived; @@ -217,6 +218,13 @@ protected function configureDefaults(): void : null ); + // Strict email by default: Laravel's plain RFCValidation accepts + // whitespace, no TLD (a@b) and @localhost — all undeliverable, which + // turn into bounces and hurt the sending domain's reputation. strict + + // native (filter_var) blocks those and still accepts real emails. + // Applies to every rule using Email::default() (register, reset, invite). + Email::defaults(fn (): Email => (new Email)->strict()->withNativeValidation()); + Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) { return in_array($cacheEvent->key, [ 'illuminate:foundation:down', diff --git a/app/Rules/NotDisposableEmail.php b/app/Rules/NotDisposableEmail.php new file mode 100644 index 00000000..33107d53 --- /dev/null +++ b/app/Rules/NotDisposableEmail.php @@ -0,0 +1,99 @@ + + */ + private const DISPOSABLE_DOMAINS = [ + '10minutemail.com', + '10minutemail.net', + '33mail.com', + 'anonaddy.me', + 'burnermail.io', + 'byom.de', + 'dispostable.com', + 'dropmail.me', + 'emailondeck.com', + 'fakeinbox.com', + 'fakemail.net', + 'getairmail.com', + 'getnada.com', + 'guerrillamail.biz', + 'guerrillamail.com', + 'guerrillamail.de', + 'guerrillamail.info', + 'guerrillamail.net', + 'guerrillamail.org', + 'inboxkitten.com', + 'jetable.org', + 'linshiyouxiang.net', + 'mail-temp.com', + 'mail.tm', + 'mail7.io', + 'mailcatch.com', + 'maildrop.cc', + 'mailinator.com', + 'mailnesia.com', + 'mailsac.com', + 'mintemail.com', + 'mohmal.com', + 'moakt.com', + 'mytemp.email', + 'nada.email', + 'onetimemail.org', + 'sharklasers.com', + 'spam4.me', + 'spamgourmet.com', + 'temp-mail.io', + 'temp-mail.org', + 'tempail.com', + 'tempinbox.com', + 'tempmail.dev', + 'tempmail.plus', + 'tempmailo.com', + 'tempr.email', + 'throwawaymail.com', + 'tmail.io', + 'tmpmail.net', + 'trash-mail.com', + 'trashmail.com', + 'trashmail.de', + 'yopmail.com', + 'yopmail.fr', + 'yopmail.net', + 'zohomail.wtf', + ]; + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $email = strtolower(trim((string) $value)); + + if (! str_contains($email, '@')) { + return; + } + + $domain = Str::afterLast($email, '@'); + + $blocked = array_merge( + self::DISPOSABLE_DOMAINS, + (array) config('trypost.security.extra_disposable_domains', []), + ); + + if (in_array($domain, $blocked, true)) { + $fail(__('auth.register.disposable_email')); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index abff5dd7..a792dd2d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,8 +3,10 @@ declare(strict_types=1); use App\Http\Middleware\Api\LoadWorkspaceFromToken; +use App\Http\Middleware\App\EnsureEmailVerified; use App\Http\Middleware\App\EnsureRegistrationEnabled; use App\Http\Middleware\App\HandleInertiaRequests; +use App\Http\Middleware\App\SecurityHeaders; use App\Http\Middleware\App\SetLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -29,8 +31,10 @@ $middleware->web(append: [ SetLocale::class, + EnsureEmailVerified::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, + SecurityHeaders::class, ]); $middleware->alias([ diff --git a/config/services.php b/config/services.php index 4b5d5690..1632ec7c 100644 --- a/config/services.php +++ b/config/services.php @@ -80,6 +80,7 @@ 'client_id' => env('GITHUB_CLIENT_ID'), 'client_secret' => env('GITHUB_CLIENT_SECRET'), 'redirect' => env('GITHUB_AUTH_CALLBACK'), + 'api' => env('GITHUB_API', 'https://api.github.com'), ], // Facebook Pages diff --git a/config/session.php b/config/session.php index d0c30db8..df299741 100644 --- a/config/session.php +++ b/config/session.php @@ -166,7 +166,7 @@ | */ - 'secure' => env('SESSION_SECURE_COOKIE'), + 'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV') === 'production'), /* |-------------------------------------------------------------------------- diff --git a/config/trypost.php b/config/trypost.php index 1b204b7e..8e9faebd 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -16,6 +16,21 @@ 'self_hosted' => env('SELF_HOSTED', true), + /* + |-------------------------------------------------------------------------- + | Signup security + |-------------------------------------------------------------------------- + | + | Anti-abuse controls for the public registration flow. + | + */ + + 'security' => [ + 'max_registrations_per_ip_per_day' => (int) env('MAX_REGISTRATIONS_PER_IP_PER_DAY', 3), + 'block_disposable_emails' => env('BLOCK_DISPOSABLE_EMAILS', true), + 'extra_disposable_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EXTRA_DISPOSABLE_DOMAINS', ''))))), + ], + /* |-------------------------------------------------------------------------- | Billing diff --git a/lang/en/auth.php b/lang/en/auth.php index 18dd53c1..41ad439d 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -2,6 +2,17 @@ return [ + 'password_strength' => [ + 'length' => 'At least 12 characters', + 'case' => 'Uppercase and lowercase letters', + 'number' => 'At least one number', + 'symbol' => 'At least one symbol', + 'weak' => 'Weak', + 'fair' => 'Fair', + 'good' => 'Good', + 'strong' => 'Strong', + ], + /* |-------------------------------------------------------------------------- | Authentication Language Lines @@ -58,6 +69,7 @@ 'github_login' => 'Log in with GitHub', 'github_signup' => 'Sign up with GitHub', 'github_email_unavailable' => 'Unable to retrieve your email from GitHub. Make your GitHub email public or grant the email scope, then try again.', + 'social_email_unverified' => 'We could not confirm your email with :provider. Verify it there or sign in with email and password.', 'signup_success' => [ 'page_title' => 'Welcome', @@ -92,6 +104,8 @@ 'submit' => 'Create account', 'has_account' => 'Already have an account?', 'log_in' => 'Log in', + 'disposable_email' => 'Please use a permanent email address. Temporary addresses are not accepted.', + 'quota_reached' => 'We could not create the account right now. Please try again later.', ], 'forgot_password' => [ @@ -122,6 +136,12 @@ 'link_sent' => 'A new verification link has been sent to the email address you provided during registration.', 'resend' => 'Resend verification email', 'log_out' => 'Log out', + 'sent_to' => 'Email sent to', + 'instructions' => 'Check your inbox (and spam, just in case). You need to confirm your email to continue.', + 'wrong_email' => 'Wrong email? Fix it', + 'new_email_label' => 'New email', + 'update_email' => 'Save and resend', + 'cancel' => 'Cancel', ], 'accept_invite' => [ diff --git a/lang/en/passwords.php b/lang/en/passwords.php index fad3a7d7..592b05cd 100644 --- a/lang/en/passwords.php +++ b/lang/en/passwords.php @@ -15,6 +15,7 @@ 'reset' => 'Your password has been reset.', 'sent' => 'We have emailed your password reset link.', + 'sent_uniform' => 'If an account exists for this email, a reset link has been sent.', 'throttled' => 'Please wait before retrying.', 'token' => 'This password reset token is invalid.', 'user' => "We can't find a user with that email address.", diff --git a/lang/es/auth.php b/lang/es/auth.php index 4acdad96..77704bbc 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -1,6 +1,17 @@ [ + 'length' => 'Al menos 12 caracteres', + 'case' => 'Mayúsculas y minúsculas', + 'number' => 'Al menos un número', + 'symbol' => 'Al menos un símbolo', + 'weak' => 'Débil', + 'fair' => 'Aceptable', + 'good' => 'Buena', + 'strong' => 'Fuerte', + ], 'failed' => 'Estas credenciales no coinciden con nuestros registros.', 'password' => 'La contraseña proporcionada es incorrecta.', 'throttle' => 'Demasiados intentos de inicio de sesión. Inténtalo de nuevo en :seconds segundos.', @@ -46,6 +57,7 @@ 'github_login' => 'Iniciar sesión con GitHub', 'github_signup' => 'Registrarse con GitHub', 'github_email_unavailable' => 'No fue posible obtener tu correo de GitHub. Haz tu correo público en GitHub o concede el permiso de correo y vuelve a intentar.', + 'social_email_unverified' => 'No pudimos confirmar tu correo con :provider. Verifícalo allí o entra con correo y contraseña.', 'signup_success' => [ 'page_title' => 'Bienvenido', @@ -80,6 +92,8 @@ 'submit' => 'Crear cuenta', 'has_account' => '¿Ya tienes una cuenta?', 'log_in' => 'Iniciar sesión', + 'disposable_email' => 'Usa un correo permanente. No se aceptan direcciones temporales.', + 'quota_reached' => 'No pudimos crear la cuenta ahora. Inténtalo de nuevo más tarde.', ], 'forgot_password' => [ @@ -110,6 +124,12 @@ 'link_sent' => 'Se ha enviado un nuevo enlace de verificación al correo electrónico proporcionado durante el registro.', 'resend' => 'Reenviar correo de verificación', 'log_out' => 'Cerrar sesión', + 'sent_to' => 'Correo enviado a', + 'instructions' => 'Revisa tu bandeja de entrada (y el spam, por si acaso). Necesitas confirmar tu correo para continuar.', + 'wrong_email' => '¿Correo equivocado? Corregir', + 'new_email_label' => 'Nuevo correo', + 'update_email' => 'Guardar y reenviar', + 'cancel' => 'Cancelar', ], 'accept_invite' => [ diff --git a/lang/es/passwords.php b/lang/es/passwords.php index 66292cb3..0ae55efb 100644 --- a/lang/es/passwords.php +++ b/lang/es/passwords.php @@ -3,6 +3,7 @@ return [ 'reset' => 'Tu contraseña ha sido restablecida.', 'sent' => 'Te hemos enviado un enlace para restablecer tu contraseña.', + 'sent_uniform' => 'Si existe una cuenta con este correo, se envió el enlace de restablecimiento.', 'throttled' => 'Espera antes de intentarlo de nuevo.', 'token' => 'Este token de restablecimiento de contraseña no es válido.', 'user' => 'No encontramos un usuario con ese correo electrónico.', diff --git a/lang/pt-BR/auth.php b/lang/pt-BR/auth.php index 6ce4dcb4..b75fb8a1 100644 --- a/lang/pt-BR/auth.php +++ b/lang/pt-BR/auth.php @@ -2,6 +2,17 @@ return [ + 'password_strength' => [ + 'length' => 'Pelo menos 12 caracteres', + 'case' => 'Letras maiúsculas e minúsculas', + 'number' => 'Ao menos um número', + 'symbol' => 'Ao menos um símbolo', + 'weak' => 'Fraca', + 'fair' => 'Razoável', + 'good' => 'Boa', + 'strong' => 'Forte', + ], + /* |-------------------------------------------------------------------------- | Authentication Language Lines @@ -58,6 +69,7 @@ 'github_login' => 'Entrar com GitHub', 'github_signup' => 'Cadastrar com GitHub', 'github_email_unavailable' => 'Não foi possível obter seu e-mail do GitHub. Torne seu e-mail público ou conceda a permissão de e-mail e tente novamente.', + 'social_email_unverified' => 'Não conseguimos confirmar seu e-mail no :provider. Confirme o e-mail lá ou entre com e-mail e senha.', 'signup_success' => [ 'page_title' => 'Bem-vindo', @@ -92,6 +104,8 @@ 'submit' => 'Criar conta', 'has_account' => 'Já tem uma conta?', 'log_in' => 'Entrar', + 'disposable_email' => 'Use um e-mail permanente. Endereços temporários não são aceitos.', + 'quota_reached' => 'Não foi possível criar a conta agora. Tente novamente mais tarde.', ], 'forgot_password' => [ @@ -122,6 +136,12 @@ 'link_sent' => 'Um novo link de verificação foi enviado para o endereço de email que você forneceu durante o cadastro.', 'resend' => 'Reenviar email de verificação', 'log_out' => 'Sair', + 'sent_to' => 'Email enviado para', + 'instructions' => 'Verifique sua caixa de entrada (e também o spam, por garantia). Você precisa confirmar o email para continuar.', + 'wrong_email' => 'Digitou o e-mail errado? Corrigir', + 'new_email_label' => 'Novo e-mail', + 'update_email' => 'Salvar e reenviar', + 'cancel' => 'Cancelar', ], 'accept_invite' => [ diff --git a/lang/pt-BR/passwords.php b/lang/pt-BR/passwords.php index 600b63b8..1728e330 100644 --- a/lang/pt-BR/passwords.php +++ b/lang/pt-BR/passwords.php @@ -15,6 +15,7 @@ 'reset' => 'Sua senha foi redefinida.', 'sent' => 'Enviamos o link de redefinição de senha por e-mail.', + 'sent_uniform' => 'Se existir uma conta com este e-mail, o link de redefinição foi enviado.', 'throttled' => 'Por favor, aguarde antes de tentar novamente.', 'token' => 'Este token de redefinição de senha é inválido.', 'user' => 'Não conseguimos encontrar um usuário com esse endereço de e-mail.', diff --git a/resources/js/components/auth/PasswordStrength.vue b/resources/js/components/auth/PasswordStrength.vue new file mode 100644 index 00000000..04cf0060 --- /dev/null +++ b/resources/js/components/auth/PasswordStrength.vue @@ -0,0 +1,80 @@ + + + + + + + + + {{ label }} + + + + + + + {{ requirement.label }} + + + + diff --git a/resources/js/pages/auth/Register.vue b/resources/js/pages/auth/Register.vue index 30c30dcf..c2c8ef35 100644 --- a/resources/js/pages/auth/Register.vue +++ b/resources/js/pages/auth/Register.vue @@ -1,8 +1,9 @@ @@ -50,6 +52,7 @@ const inputEmail = ref(props.email); {{ $t('auth.reset_password.password') }} + diff --git a/resources/js/pages/auth/VerifyEmail.vue b/resources/js/pages/auth/VerifyEmail.vue index c6cb1578..f70c3f25 100644 --- a/resources/js/pages/auth/VerifyEmail.vue +++ b/resources/js/pages/auth/VerifyEmail.vue @@ -1,16 +1,51 @@ @@ -20,30 +55,115 @@ defineProps<{ > - - {{ $t('auth.verify_email.link_sent') }} - + + + + - - - - {{ $t('auth.verify_email.resend') }} - - - + + {{ $t('auth.verify_email.sent_to') }} + + + {{ email }} + + + + + {{ $t('auth.verify_email.instructions') }} + + + + {{ $t('auth.verify_email.link_sent') }} + + + - {{ $t('auth.verify_email.log_out') }} - - + + + + + {{ $t('auth.verify_email.resend') + }} + ({{ secondsLeft }}s) + + + + + {{ $t('auth.verify_email.log_out') }} + + + + + + {{ $t('auth.verify_email.wrong_email') }} + + + + + {{ $t('auth.verify_email.new_email_label') }} + + + + + + + {{ $t('auth.verify_email.update_email') }} + + + {{ $t('auth.verify_email.cancel') }} + + + + + diff --git a/routes/auth.php b/routes/auth.php index f50f4a3a..54c5ddff 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Auth\PasswordResetLinkController; use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\Auth\SignupSuccessController; +use App\Http\Controllers\Auth\UpdateUnverifiedEmailController; use App\Http\Controllers\Auth\VerifyEmailController; use Illuminate\Support\Facades\Route; @@ -20,14 +21,18 @@ Route::middleware(['guest'])->group(function () { Route::middleware('registration.enabled')->group(function () { Route::get('/register', [RegisteredUserController::class, 'create'])->name('register'); - Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store'); + Route::post('/register', [RegisteredUserController::class, 'store']) + ->middleware('throttle:5,1') + ->name('register.store'); }); Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); Route::post('/login', [AuthenticatedSessionController::class, 'store'])->name('login.store'); Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])->name('password.request'); - Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])->name('password.email'); + Route::post('/forgot-password', [PasswordResetLinkController::class, 'store']) + ->middleware('throttle:5,1') + ->name('password.email'); Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset'); Route::post('/reset-password', [NewPasswordController::class, 'store'])->name('password.store'); @@ -43,19 +48,26 @@ Route::get('/auth/google/callback', [GoogleController::class, 'callback'])->name('auth.google.callback'); Route::get('/auth/github/callback', [GitHubController::class, 'callback'])->name('auth.github.callback'); +// Signed magic link: the signature itself proves ownership of the email, so it +// works even when the user is logged out (link opened on another device). Kept +// outside the `auth` group for that reason. +Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + Route::middleware(['auth'])->group(function () { Route::get('/register/success', SignupSuccessController::class)->name('register.success'); Route::get('/verify-email', EmailVerificationPromptController::class)->name('verification.notice'); - Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class) - ->middleware(['signed', 'throttle:6,1']) - ->name('verification.verify'); - Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store']) ->middleware('throttle:6,1') ->name('verification.send'); + Route::post('/email/update', [UpdateUnverifiedEmailController::class, 'update']) + ->middleware('throttle:6,1') + ->name('verification.email.update'); + Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout'); Route::post('/invites/{invite}/accept', [AcceptInviteController::class, 'accept'])->name('app.invites.accept'); diff --git a/tests/Feature/Auth/AuthHardeningTest.php b/tests/Feature/Auth/AuthHardeningTest.php new file mode 100644 index 00000000..9b6dbf23 --- /dev/null +++ b/tests/Feature/Auth/AuthHardeningTest.php @@ -0,0 +1,72 @@ + false]); + + $user = User::factory()->create(['email_verified_at' => null]); + + $this->actingAs($user) + ->get(route('app.calendar')) + ->assertRedirect(route('verification.notice')); +}); + +test('a verified user passes the gate', function () { + config(['trypost.self_hosted' => false]); + + $user = User::factory()->create(['email_verified_at' => now()]); + + $this->actingAs($user) + ->get(route('verification.notice')) + ->assertRedirect(route('app.calendar')); +}); + +test('the verification prompt itself renders without a redirect loop', function () { + config(['trypost.self_hosted' => false]); + + $user = User::factory()->create(['email_verified_at' => null]); + + $this->actingAs($user) + ->get(route('verification.notice')) + ->assertOk(); +}); + +test('forgot-password responds uniformly whether or not the email exists', function () { + $existing = $this->post(route('password.email'), ['email' => User::factory()->create()->email]); + $missing = $this->post(route('password.email'), ['email' => 'nobody@example.com']); + + $existing->assertSessionHasNoErrors()->assertSessionHas('status'); + $missing->assertSessionHasNoErrors()->assertSessionHas('status'); + + expect(session('status'))->toBe(__('passwords.sent_uniform')); +}); + +test('resetting the password wipes every active database session', function () { + config(['session.driver' => 'database']); + + $user = User::factory()->create(); + $token = Password::createToken($user); + + DB::table(config('session.table', 'sessions'))->insert([ + 'id' => 'attacker-session', + 'user_id' => $user->id, + 'ip_address' => '10.0.0.1', + 'user_agent' => 'evil', + 'payload' => '', + 'last_activity' => now()->timestamp, + ]); + + $this->post(route('password.store'), [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'Password!1234', + 'password_confirmation' => 'Password!1234', + ])->assertRedirect(route('login')); + + expect(DB::table(config('session.table', 'sessions'))->where('id', 'attacker-session')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Auth/OauthLinkingSecurityTest.php b/tests/Feature/Auth/OauthLinkingSecurityTest.php new file mode 100644 index 00000000..7cf82f25 --- /dev/null +++ b/tests/Feature/Auth/OauthLinkingSecurityTest.php @@ -0,0 +1,65 @@ +map([ + 'id' => 'google-123', + 'name' => 'OAuth User', + 'email' => $email, + ]); + $socialUser->user = [ + 'sub' => 'google-123', + 'email' => $email, + 'email_verified' => $verified, + ]; + + return $socialUser; +} + +function mockGoogleCallback(SocialiteUser $socialUser): void +{ + $driver = Mockery::mock(AbstractProvider::class); + $driver->shouldReceive('user')->andReturn($socialUser); + Socialite::shouldReceive('driver')->with('google-auth')->andReturn($driver); +} + +test('google login with unverified provider email cannot link an existing account', function () { + $victim = User::factory()->create(['email' => 'victim@example.com']); + + mockGoogleCallback(fakeGoogleUser('victim@example.com', verified: false)); + + $response = $this->get(route('auth.google.callback')); + + $response->assertRedirect(route('login')); + $this->assertGuest(); + expect($victim->fresh()->google_id)->toBeNull(); +}); + +test('google login with verified provider email links the matching account', function () { + $user = User::factory()->create(['email' => 'owner@example.com']); + + mockGoogleCallback(fakeGoogleUser('owner@example.com', verified: true)); + + $this->get(route('auth.google.callback')); + + $this->assertAuthenticatedAs($user->fresh()); + expect($user->fresh()->google_id)->toBe('google-123'); +}); + +test('an already linked google account logs in regardless of the claim', function () { + $user = User::factory()->create(['google_id' => 'google-123']); + + mockGoogleCallback(fakeGoogleUser($user->email, verified: false)); + + $this->get(route('auth.google.callback')); + + $this->assertAuthenticatedAs($user->fresh()); +}); diff --git a/tests/Feature/Auth/RegistrationAbuseTest.php b/tests/Feature/Auth/RegistrationAbuseTest.php new file mode 100644 index 00000000..d7ec6beb --- /dev/null +++ b/tests/Feature/Auth/RegistrationAbuseTest.php @@ -0,0 +1,120 @@ + config()->set('trypost.self_hosted', false)); + +test('honeypot field silently rejects the registration', function () { + $response = $this->post(route('register.store'), [ + 'name' => 'Bot', + 'email' => 'bot@example.com', + 'password' => 'Password!123', + 'contact_time' => 'http://spam.example', + ]); + + $response->assertRedirect(route('login')); + expect(User::where('email', 'bot@example.com')->exists())->toBeFalse(); +}); + +test('an empty honeypot lets a real registration through', function () { + config(['trypost.security.max_registrations_per_ip_per_day' => 0]); + + // Humans: the front-end clears the honeypot, so it arrives empty (or absent). + $this->post(route('register.store'), [ + 'name' => 'Jane', + 'email' => 'jane@example.com', + 'password' => 'Password!123', + 'contact_time' => '', + ])->assertRedirect(route('register.success')); + + expect(User::where('email', 'jane@example.com')->exists())->toBeTrue(); +}); + +test('disposable email domains are rejected', function () { + config(['trypost.security.block_disposable_emails' => true]); + + $this->post(route('register.store'), [ + 'name' => 'Farmer', + 'email' => 'x@mailinator.com', + 'password' => 'Password!123', + ])->assertSessionHasErrors('email'); + + expect(User::where('email', 'x@mailinator.com')->exists())->toBeFalse(); +}); + +test('extra disposable domains from config are also rejected', function () { + config([ + 'trypost.security.block_disposable_emails' => true, + 'trypost.security.extra_disposable_domains' => ['spamcustom.dev'], + ]); + + $this->post(route('register.store'), [ + 'name' => 'Farmer', + 'email' => 'x@spamcustom.dev', + 'password' => 'Password!123', + ])->assertSessionHasErrors('email'); +}); + +test('registrations beyond the per-ip daily quota are rejected', function () { + config(['trypost.security.max_registrations_per_ip_per_day' => 2]); + + User::factory()->count(2)->create(['registration_ip' => '127.0.0.1']); + + $this->post(route('register.store'), [ + 'name' => 'Third Account', + 'email' => 'third@example.com', + 'password' => 'Password!123', + ])->assertSessionHasErrors('email'); + + expect(User::where('email', 'third@example.com')->exists())->toBeFalse(); +}); + +test('the per-ip quota can be disabled with zero', function () { + config(['trypost.security.max_registrations_per_ip_per_day' => 0]); + + User::factory()->count(5)->create(['registration_ip' => '127.0.0.1']); + + $this->post(route('register.store'), [ + 'name' => 'Free Account', + 'email' => 'free@example.com', + 'password' => 'Password!123', + ])->assertRedirect(route('register.success')); +}); + +test('the register route is throttled', function () { + for ($i = 0; $i < 5; $i++) { + $this->post(route('register.store'), ['name' => '', 'email' => '', 'password' => '']); + } + + $this->post(route('register.store'), [ + 'name' => 'Someone', + 'email' => 'someone@example.com', + 'password' => 'Password!123', + ])->assertStatus(429); +}); + +test('malformed emails are rejected at registration', function () { + foreach (['noatsign', 'space @b.com', 'a@b', 'user@localhost'] as $bad) { + $this->post(route('register.store'), [ + 'name' => 'John', + 'email' => $bad, + 'password' => 'Password!123', + ])->assertSessionHasErrors('email'); + } + + expect(User::count())->toBe(0); +}); + +test('a real email passes the strict rule', function () { + config(['trypost.security.max_registrations_per_ip_per_day' => 0]); + + $this->post(route('register.store'), [ + 'name' => 'Real User', + 'email' => 'real@company.example', + 'password' => 'Password!123', + ])->assertRedirect(route('register.success')); + + expect(User::where('email', 'real@company.example')->exists())->toBeTrue(); +});
+ {{ $t('auth.verify_email.sent_to') }} +
+ {{ email }} +
+ {{ $t('auth.verify_email.instructions') }} +