From c1849c327e23e49f2aea2c724eead630fec5d149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20S=C3=A9rgio=20Dantas?= Date: Fri, 3 Jul 2026 17:56:15 -0300 Subject: [PATCH] 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. --- .../App/Settings/AuthenticationController.php | 10 ++ .../EmailVerificationPromptController.php | 1 + .../Controllers/Auth/GitHubController.php | 52 +++++- .../Controllers/Auth/GoogleController.php | 28 ++- .../Auth/NewPasswordController.php | 12 +- .../Auth/PasswordResetLinkController.php | 14 +- .../Auth/RegisteredUserController.php | 50 +++++- .../Auth/UpdateUnverifiedEmailController.php | 52 ++++++ .../Auth/VerifyEmailController.php | 26 ++- .../Middleware/App/EnsureEmailVerified.php | 46 +++++ app/Http/Middleware/App/SecurityHeaders.php | 34 ++++ .../Invite/StoreWorkspaceInviteRequest.php | 3 +- app/Providers/AppServiceProvider.php | 8 + app/Rules/NotDisposableEmail.php | 99 +++++++++++ bootstrap/app.php | 4 + config/services.php | 1 + config/session.php | 2 +- config/trypost.php | 15 ++ lang/en/auth.php | 20 +++ lang/en/passwords.php | 1 + lang/es/auth.php | 20 +++ lang/es/passwords.php | 1 + lang/pt-BR/auth.php | 20 +++ lang/pt-BR/passwords.php | 1 + .../js/components/auth/PasswordStrength.vue | 80 +++++++++ resources/js/pages/auth/Register.vue | 36 +++- resources/js/pages/auth/ResetPassword.vue | 4 + resources/js/pages/auth/VerifyEmail.vue | 166 +++++++++++++++--- routes/auth.php | 24 ++- tests/Feature/Auth/AuthHardeningTest.php | 72 ++++++++ .../Feature/Auth/OauthLinkingSecurityTest.php | 65 +++++++ tests/Feature/Auth/RegistrationAbuseTest.php | 120 +++++++++++++ 32 files changed, 1025 insertions(+), 62 deletions(-) create mode 100644 app/Http/Controllers/Auth/UpdateUnverifiedEmailController.php create mode 100644 app/Http/Middleware/App/EnsureEmailVerified.php create mode 100644 app/Http/Middleware/App/SecurityHeaders.php create mode 100644 app/Rules/NotDisposableEmail.php create mode 100644 resources/js/components/auth/PasswordStrength.vue create mode 100644 tests/Feature/Auth/AuthHardeningTest.php create mode 100644 tests/Feature/Auth/OauthLinkingSecurityTest.php create mode 100644 tests/Feature/Auth/RegistrationAbuseTest.php 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 @@ + + + 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 @@