Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ Authentication, registration, magic link (passwordless), password reset, email v
| Models | `SocialAccount` (provider, tokens, avatar, last_login_at), `MagicLinkToken` (hashed token, expires_at, used_at) |
| Service | `SocialiteService` — all OAuth logic (find/create user, link/disconnect accounts) |
| Requests | `LoginRequest` (credential validation + rate limiting), `RegisterRequest` (password hashing in `passedValidation`) |
| Exceptions | `AuthException` (credentials, throttle), `SocialiteException` (disconnect, account linking, provider validation) |
| Exceptions | `AuthException` (credentials, throttle), `SocialiteException` (disconnect, account linking, provider validation, registration disabled) |
| Listeners | `AssignUserRole` (Registered), `UpdateUserLastLogin` (Login), `Impersonation` (TakeImpersonation — session history) |
| Notifications | `WelcomeNotification` (Registered), `MagicLinkNotification` (passwordless login link with configured expiry) |
| Settings | `AuthSettings` (`magic_link_enabled`, `magic_link_expiry`) |
| Settings | `AuthSettings` (`registration_enabled`, `magic_link_enabled`, `magic_link_expiry`, `login_notification_enabled`, `enabled_socialite_providers`) |
| Trait | `Sociable` — added to User model (socialAccounts relation, connected_providers, disconnect) |
| Filament | `AuthPlugin`, `AuthenticationSettings`, `UserResource` (list, create, view, edit), `UserForm`, `UsersTable` |
| Pages | `Login`, `Register`, `ForgotPassword`, `ResetPassword`, `VerifyEmail`, `MagicLink` |
Expand Down Expand Up @@ -102,3 +102,4 @@ npx playwright test --project="@auth*" # E2E
- Filament UserResource enforces single role (maxItems: 1) despite multi-select UI
- Magic link authenticate route is outside both guest and auth middleware groups (link is clicked from email client)
- `MagicLinkToken::isValid()` checks both `expires_at->isFuture()` and `used_at === null`
- `registration_enabled` closes both signup paths: `EnsureRegistrationEnabled` 404s the register routes, and `SocialiteService::handleCallback()` throws `registrationDisabled()` rather than creating a new user (existing users still sign in). Login is deliberately not toggleable — disabling it would lock out admins.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

use Spatie\LaravelSettings\Migrations\SettingsMigration;

return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->add('auth.registration_enabled', true);
}
};
6 changes: 6 additions & 0 deletions lang/en/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
'verification-link-sent' => 'A fresh verification link has been sent to your email address.',
'magic-link-sent' => "If an account with that email exists, we've sent a magic login link.",
'magic-link-expired' => 'This magic link has expired or has already been used.',
'registration' => [
'title' => 'Registration',
'description' => 'Control whether visitors can create new accounts.',
'enabled' => 'Allow new registrations',
'help' => 'When disabled, the sign-up page returns 404 and social login cannot create new accounts.',
],
'notifications' => [
'title' => 'Notifications',
'description' => 'Configure security notifications sent to users.',
Expand Down
1 change: 1 addition & 0 deletions lang/en/socialite.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@
'missing_social_accounts_relation' => 'The User model is missing the socialAccounts relationship required for social authentication',
'account_already_linked' => 'This :provider account is already linked to another user',
'unsupported_provider' => 'The social provider :provider is not supported',
'registration_disabled' => 'New account registration is currently disabled',
];
6 changes: 6 additions & 0 deletions lang/pt_BR/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
'password' => 'A senha fornecida está incorreta.',
'throttle' => 'Muitas tentativas de login. Tente novamente em :seconds segundos.',
'verification-link-sent' => 'Um novo link de verificação foi enviado para seu endereço de email.',
'registration' => [
'title' => 'Cadastro',
'description' => 'Controle se visitantes podem criar novas contas.',
'enabled' => 'Permitir novos cadastros',
'help' => 'Quando desativado, a página de cadastro retorna 404 e o login social não cria novas contas.',
],
'notifications' => [
'title' => 'Notificações',
'description' => 'Configure as notificações de segurança enviadas aos usuários.',
Expand Down
1 change: 1 addition & 0 deletions lang/pt_BR/socialite.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@
'missing_social_accounts_relation' => 'O modelo User está faltando o relacionamento socialAccounts necessário para autenticação social',
'account_already_linked' => 'Esta conta :provider já está vinculada a outro usuário',
'unsupported_provider' => 'O provedor social :provider não é suportado',
'registration_disabled' => 'O cadastro de novas contas está desativado no momento',
];
30 changes: 19 additions & 11 deletions resources/js/react/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ import { useState } from 'react';
import SocialiteProviders from '../components/SocialiteProviders';
import AuthCardLayout from '../layouts/AuthCardLayout';

type AuthProps = {
magic_link_enabled?: boolean;
registration_enabled?: boolean;
};

export default function Login() {
const t = useT();
const page = usePage();
const auth = (page.props.auth as AuthProps) ?? {};
const [email, setEmail] = useState('');
const [showPassword, setShowPassword] = useState(false);

Expand Down Expand Up @@ -142,7 +148,7 @@ export default function Login() {
</Button>

<p className="mt-2 text-center text-sm">
{(page.props.auth as any)?.magic_link_enabled && (
{auth.magic_link_enabled && (
<Link
href={route('magic-link.create')}
className="text-primary font-medium underline-offset-4 hover:underline"
Expand All @@ -153,16 +159,18 @@ export default function Login() {
)}
</p>

<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{t("Don't have an account?")}{' '}
<Link
href={route('register')}
className="text-primary font-medium underline-offset-4 hover:underline"
data-testid="sign-up-link"
>
{t('Sign up')}
</Link>
</p>
{auth.registration_enabled && (
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{t("Don't have an account?")}{' '}
<Link
href={route('register')}
className="text-primary font-medium underline-offset-4 hover:underline"
data-testid="sign-up-link"
>
{t('Sign up')}
</Link>
</p>
)}
</form>
</AuthCardLayout>
);
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/page-props.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ declare module '@inertiajs/core' {
user: User | null;
last_social_provider?: string | null;
magic_link_enabled?: boolean;
registration_enabled?: boolean;
socialite_providers?: Array<{
name: string;
label: string;
Expand Down
1 change: 1 addition & 0 deletions resources/js/vue/pages/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const forgotUrl = computed(() =>
</p>

<p
v-if="$page.props.auth.registration_enabled"
class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"
>
{{ $t("Don't have an account?") }}
Expand Down
9 changes: 6 additions & 3 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Modules\Auth\Http\Controllers\ResetPasswordController;
use Modules\Auth\Http\Controllers\SocialiteController;
use Modules\Auth\Http\Controllers\VerifyEmailController;
use Modules\Auth\Http\Middleware\EnsureRegistrationEnabled;
use Modules\Auth\Http\Middleware\EnsureSocialiteProviderEnabled;

Route::middleware('web')->group(function (): void {
Expand All @@ -23,10 +24,12 @@

Route::post('login', [LoginController::class, 'store']);

Route::get('register', [RegisterController::class, 'create'])
->name('register');
Route::middleware(EnsureRegistrationEnabled::class)->group(function (): void {
Route::get('register', [RegisterController::class, 'create'])
->name('register');

Route::post('register', [RegisterController::class, 'store']);
Route::post('register', [RegisterController::class, 'store']);
});

Route::get('forgot-password', [ForgotPasswordController::class, 'create'])
->name('password.request');
Expand Down
5 changes: 5 additions & 0 deletions src/Exceptions/SocialiteException.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public static function accountAlreadyLinked(string $provider): self
return new self(trans('auth::socialite.account_already_linked', ['provider' => ucfirst($provider)]));
}

public static function registrationDisabled(): self
{
return new self(trans('auth::socialite.registration_disabled'));
}

public static function unsupportedProvider(string $provider): self
{
return new self(trans('auth::socialite.unsupported_provider', ['provider' => ucfirst($provider)]));
Expand Down
10 changes: 10 additions & 0 deletions src/Filament/Pages/AuthenticationSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ public function getTitle(): string
public function form(Schema $schema): Schema
{
return $schema->columns(1)->components([
Section::make(__('auth::auth.registration.title'))
->description(__('auth::auth.registration.description'))
->icon(Heroicon::OutlinedUserPlus)
->schema([
Toggle::make('registration_enabled')
->label(__('auth::auth.registration.enabled'))
->helperText(__('auth::auth.registration.help'))
->extraAttributes(['data-testid' => 'admin-registration-enabled']),
])
->columns(1),
Section::make(__('Social Login'))
->description(__('Choose which social providers visitors can use to sign in or create an account.'))
->icon(Heroicon::OutlinedShare)
Expand Down
8 changes: 7 additions & 1 deletion src/Http/Controllers/SocialiteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ public function callback(Request $request, string $provider): RedirectResponse
}

// Guest user - login/registration flow
$user = $this->socialiteService->handleCallback($provider);
try {
$user = $this->socialiteService->handleCallback($provider);
} catch (SocialiteException $e) {
Toast::error($e->getMessage());

return redirect()->route('login');
}

Auth::login($user);

Expand Down
19 changes: 19 additions & 0 deletions src/Http/Middleware/EnsureRegistrationEnabled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Modules\Auth\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Modules\Auth\Settings\AuthSettings;

class EnsureRegistrationEnabled
{
public function __construct(private readonly AuthSettings $settings) {}

public function handle(Request $request, Closure $next): mixed
{
abort_unless($this->settings->registration_enabled, 404);

return $next($request);
}
}
4 changes: 4 additions & 0 deletions src/Providers/AuthServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ protected function shareInertiaData(): void
'auth.socialite_providers',
fn (): array => $this->app->make(SocialiteService::class)->enabledProviders(),
);
Inertia::share(
'auth.registration_enabled',
fn (): bool => $this->app->make(AuthSettings::class)->registration_enabled,
);
Inertia::share(
'auth.magic_link_enabled',
fn (): bool => $this->app->make(AuthSettings::class)->magic_link_enabled,
Expand Down
4 changes: 4 additions & 0 deletions src/Services/SocialiteService.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ public function handleCallback(string $provider): User
->first();

if (! $user) {
if (! $this->settings->registration_enabled) {
throw SocialiteException::registrationDisabled();
}

$user = $this->createNewUser($socialiteUser, $avatarUrl);
} else {
$this->updateUserAvatar($user, $avatarUrl);
Expand Down
2 changes: 2 additions & 0 deletions src/Settings/AuthSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class AuthSettings extends Settings
/** @var list<string> */
public array $enabled_socialite_providers;

public bool $registration_enabled;

public bool $magic_link_enabled;

public int $magic_link_expiry;
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/AuthSettingsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public function test_fresh_install_has_authentication_defaults(): void
$settings = app(AuthSettings::class);

$this->assertSame([], $settings->enabled_socialite_providers);
$this->assertTrue($settings->registration_enabled);
$this->assertTrue($settings->magic_link_enabled);
$this->assertSame(15, $settings->magic_link_expiry);
$this->assertFalse($settings->login_notification_enabled);
Expand Down
29 changes: 29 additions & 0 deletions tests/Feature/RegisterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ public function test_register_page_renders_for_guests(): void
$response->assertStatus(200);
}

public function test_register_page_is_not_found_when_registration_is_disabled(): void
{
$this->disableRegistration();

$this->get(route('register'))->assertNotFound();
}

public function test_user_cannot_register_when_registration_is_disabled(): void
{
$this->disableRegistration();

$this->post(route('register'), [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
'terms' => true,
])->assertNotFound();

$this->assertDatabaseMissing('users', ['email' => 'test@example.com']);
}

private function disableRegistration(): void
{
$settings = app(AuthSettings::class);
$settings->registration_enabled = false;
$settings->save();
}

public function test_user_can_register_with_valid_data(): void
{
Notification::fake();
Expand Down
29 changes: 29 additions & 0 deletions tests/Feature/SocialiteCallbackTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ private function mockSocialiteDriver(SocialiteUser $socialiteUser): void
);
}

public function test_social_callback_does_not_create_user_when_registration_is_disabled(): void
{
$settings = $this->enableGithub();
$settings->registration_enabled = false;
$settings->save();

$this->mockSocialiteDriver($this->makeSocialiteUser());

$response = $this->get(route('auth.socialite.callback', ['provider' => 'github']));

$response->assertRedirect(route('login'));
$this->assertGuest();
$this->assertDatabaseMissing('users', ['email' => 'socialuser@example.com']);
}

public function test_social_callback_still_logs_in_existing_user_when_registration_is_disabled(): void
{
$settings = $this->enableGithub();
$settings->registration_enabled = false;
$settings->save();

$user = $this->createUser();
$this->mockSocialiteDriver($this->makeSocialiteUser(email: $user->email));

$this->get(route('auth.socialite.callback', ['provider' => 'github']));

$this->assertAuthenticatedAs($user);
}

public function test_callback_sets_last_social_provider_cookie(): void
{
$this->enableGithub();
Expand Down