Merge pull request #5160 from Roardom/registration

(Refactor) Swap fortify for native laravel registration
This commit is contained in:
Roardom
2026-01-30 00:26:25 +00:00
committed by GitHub
13 changed files with 146 additions and 111 deletions

View File

@@ -2,5 +2,6 @@ asciify
dontBackupDatabases
dontBackupFilesystem
lexify
regexify
swal
CURLOPT

View File

@@ -25,6 +25,7 @@ enum GlobalRateLimit: string
case EMAIL_VERIFICATION = 'email-verification';
case FORGOT_PASSWORD = 'forgot-password';
case IGDB = 'igdb';
case REGISTER = 'register';
case RESET_PASSWORD = 'reset-password';
case RSS = 'rss';
case SEARCH = 'search';

View File

@@ -10,70 +10,49 @@ declare(strict_types=1);
*
* @project UNIT3D Community Edition
*
* @author HDVinnie <hdinnovations@protonmail.com>
* @author Roardom <roardom@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Actions\Fortify;
namespace App\Http\Controllers\Auth;
use App\Models\Chatroom;
use App\Models\ChatStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\StoreRegisteredUserRequest;
use App\Models\Group;
use App\Models\Invite;
use App\Models\User;
use App\Rules\EmailBlacklist;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
class RegisteredUserController extends Controller
{
use PasswordValidationRules;
/**
* Show registration form.
*/
public function create(Request $request): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
if ($request->missing('code')) {
return view('auth.register');
}
return view('auth.register', ['code' => $request->query('code')]);
}
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
* @throws ValidationException
* @throws Exception
* Receive registration form.
*/
public function create(array $input): RedirectResponse | User
public function store(StoreRegisteredUserRequest $request): \Illuminate\Http\RedirectResponse
{
Validator::make($input, [
'username' => 'required|alpha_dash|string|between:3,25|unique:users',
'password' => [
'required',
'confirmed',
$this->passwordRules(),
],
'email' => [
'required',
'string',
'email:rfc,dns',
'max:70',
'unique:users',
Rule::when(config('email-blacklist.enabled') === true, fn () => new EmailBlacklist()),
],
'captcha' => [
Rule::excludeIf(config('captcha.enabled') === false),
Rule::when(config('captcha.enabled') === true, 'hiddencaptcha'),
],
'code' => [
Rule::when(config('other.invite-only') === true, [
'required',
Rule::exists('invites', 'code')->withoutTrashed()->whereNull('accepted_by'),
]),
]
])->validate();
$request->validated();
$user = User::query()->create([
'username' => $input['username'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'username' => $request->username,
'email' => $request->email,
'password' => Hash::make($request->password),
'passkey' => md5(random_bytes(60)),
'rsskey' => md5(random_bytes(60)),
'uploaded' => config('other.default_upload'),
@@ -96,7 +75,7 @@ class CreateNewUser implements CreatesNewUsers
$user->emailUpdates()->create();
if (config('other.invite-only') === true) {
$invite = Invite::query()->where('code', '=', $input['code'])->first();
$invite = Invite::query()->where('code', '=', $request->code)->first();
$invite->update([
'accepted_by' => $user->id,
'accepted_at' => now(),
@@ -110,6 +89,14 @@ class CreateNewUser implements CreatesNewUsers
}
}
return $user;
event(new Registered($user));
Auth::login($user);
if ($request->hasSession()) {
$request->session()->regenerate();
}
return to_route('verification.notice');
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Roardom <roardom@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Requests\Auth;
use App\Rules\EmailBlacklist;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class StoreRegisteredUserRequest extends FormRequest
{
/**
* Indicates if the validator should stop on the first rule failure.
*
* @var bool
*/
protected $stopOnFirstFailure = true;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, \Illuminate\Validation\ConditionalRules|\Illuminate\Validation\Rules\ExcludeIf|Password|string>|\Illuminate\Validation\ConditionalRules|string>
*/
public function rules(): array
{
return [
'code' => [
Rule::when(config('other.invite-only') === true, [
'required',
Rule::exists('invites', 'code')->withoutTrashed()->whereNull('accepted_by'),
]),
],
'password' => [
'required',
'string',
'confirmed',
Password::min(12)->mixedCase()->letters()->numbers()->uncompromised(),
],
'captcha' => [
Rule::excludeIf(config('captcha.enabled') === false),
Rule::when(config('captcha.enabled') === true, 'hiddencaptcha'),
],
'username' => 'required|alpha_dash|string|between:3,25|unique:users',
'email' => [
'required',
'string',
'email:rfc,dns',
'max:70',
'unique:users',
Rule::when(config('email-blacklist.enabled') === true, fn () => new EmailBlacklist()),
],
];
}
}

View File

@@ -16,7 +16,6 @@ declare(strict_types=1);
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\BlockedIp;
@@ -34,7 +33,6 @@ use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\RegisterViewResponse;
use Laravel\Fortify\Fortify;
use function Illuminate\Support\defer;
@@ -90,18 +88,6 @@ class FortifyServiceProvider extends ServiceProvider
->with('success', trans('auth.welcome'));
}
});
// Handle redirects before the registration form is shown
$this->app->instance(RegisterViewResponse::class, new class () implements RegisterViewResponse {
public function toResponse($request): \Illuminate\Http\RedirectResponse|\Illuminate\View\View
{
if ($request->missing('code')) {
return view('auth.register');
}
return view('auth.register', ['code' => $request->query('code')]);
}
});
}
/**
@@ -111,15 +97,12 @@ class FortifyServiceProvider extends ServiceProvider
{
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)->by('fortify-login'.$request->ip()));
RateLimiter::for('fortify-login-get', fn (Request $request) => Limit::perMinute(5)->by('fortify-login'.$request->ip()));
RateLimiter::for('fortify-register-get', fn (Request $request) => Limit::perMinute(5)->by('fortify-register'.$request->ip()));
RateLimiter::for('fortify-register-post', fn (Request $request) => Limit::perMinute(5)->by('fortify-register'.$request->ip()));
RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by('fortify-two-factor'.$request->session()->get('login.id')));
Fortify::loginView(fn () => view('auth.login'));
Fortify::confirmPasswordView(fn () => view('auth.confirm-password'));
Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge'));
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);

View File

@@ -98,6 +98,7 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for(GlobalRateLimit::IGDB, fn (): Limit => Limit::perSecond(2));
RateLimiter::for(GlobalRateLimit::FORGOT_PASSWORD, fn (Request $request) => Limit::perMinute(5)->by('forgot-password'.$request->ip()));
RateLimiter::for(GlobalRateLimit::RESET_PASSWORD, fn (Request $request) => Limit::perMinute(5)->by('reset-password'.$request->ip()));
RateLimiter::for(GlobalRateLimit::REGISTER, fn (Request $request) => Limit::perMinute(5)->by('register'.$request->ip()));
RateLimiter::for(GlobalRateLimit::EMAIL_VERIFICATION, fn (Request $request) => Limit::perMinute(5)->by('email-verification'.$request->user()->id));
}

View File

@@ -106,11 +106,9 @@ return [
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'fortify-login-get' => 'fortify-login-get',
'fortify-register-get' => 'fortify-register-get',
'fortify-register-post' => 'fortify-register-post',
'login' => 'login',
'two-factor' => 'two-factor',
'fortify-login-get' => 'fortify-login-get',
],
/*
@@ -138,7 +136,6 @@ return [
*/
'features' => [
Features::registration(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([

View File

@@ -1,11 +1,5 @@
parameters:
ignoreErrors:
-
message: '#^Call to function is_int\(\) with string\|null will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: app/Actions/Fortify/CreateNewUser.php
-
message: '#^Instanceof between App\\Models\\User and Illuminate\\Contracts\\Auth\\MustVerifyEmail will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
@@ -252,6 +246,12 @@ parameters:
count: 1
path: app/Http/Controllers/API/TorrentController.php
-
message: '#^Call to function is_int\(\) with string\|null will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: app/Http/Controllers/Auth/RegisteredUserController.php
-
message: '#^Method App\\Http\\Controllers\\AnnounceController\:\:index\(\) never returns null so it can be removed from the return type\.$#'
identifier: return.unusedType
@@ -535,17 +535,11 @@ parameters:
path: app/Providers/AppServiceProvider.php
-
message: '#^Method Laravel\\Fortify\\Contracts\\LoginResponse@anonymous/app/Providers/FortifyServiceProvider\.php\:50\:\:toResponse\(\) should return Illuminate\\Http\\RedirectResponse but returns Illuminate\\Http\\RedirectResponse\|true\.$#'
message: '#^Method Laravel\\Fortify\\Contracts\\LoginResponse@anonymous/app/Providers/FortifyServiceProvider\.php\:48\:\:toResponse\(\) should return Illuminate\\Http\\RedirectResponse but returns Illuminate\\Http\\RedirectResponse\|true\.$#'
identifier: return.type
count: 1
path: app/Providers/FortifyServiceProvider.php
-
message: '#^Method Laravel\\Fortify\\Contracts\\RegisterViewResponse@anonymous/app/Providers/FortifyServiceProvider\.php\:95\:\:toResponse\(\) never returns Illuminate\\Http\\RedirectResponse so it can be removed from the return type\.$#'
identifier: return.unusedType
count: 1
path: app/Providers/FortifyServiceProvider.php
-
message: '#^Parameter \#1 \$string of function rtrim expects string, true given\.$#'
identifier: argument.type

View File

@@ -111,7 +111,10 @@
</form>
<footer class="auth-form__footer">
@if (! config('other.invite-only'))
<a class="auth-form__footer-item" href="{{ route('register') }}">
<a
class="auth-form__footer-item"
href="{{ route('registration.create') }}"
>
{{ __('auth.signup') }}
</a>
@elseif (config('other.application_signups'))

View File

@@ -28,7 +28,7 @@
<form
class="auth-form__form"
method="POST"
action="{{ route('register', ['code' => request()->query('code')]) }}"
action="{{ route('registration.store', ['code' => request()->query('code')]) }}"
>
@csrf
<a class="auth-form__branding" href="{{ route('home.index') }}">

View File

@@ -1,9 +1,9 @@
@component('mail::message')
# {{ __('email.invite-header') }} {{ config('other.title') }} !
**{{ __('email.invite-message') }}:** {{ __('email.invite-invited') }} {{ config('other.title') }}. {{ $invite->custom }}
@component('mail::button', ['url' => route('register', ['code' => $invite->code]), 'color' => 'blue'])
@component('mail::button', ['url' => route('registration.create', ['code' => $invite->code]), 'color' => 'blue'])
{{ __('email.invite-signup') }}
@endcomponent
<p>{{ __('email.register-footer') }}</p>
<p style="word-wrap: break-word; overflow-wrap: break-word; word-break: break-word;">{{ route('register', ['code' => $invite->code]) }}</p>
<p style="word-wrap: break-word; overflow-wrap: break-word; word-break: break-word;">{{ route('registration.create', ['code' => $invite->code]) }}</p>
@endcomponent

View File

@@ -18,7 +18,6 @@ use App\Enums\GlobalRateLimit;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
use Laravel\Fortify\RoutePath;
/*
@@ -50,13 +49,6 @@ Route::middleware('language')->group(function (): void {
Route::get(RoutePath::for('login', '/login'), [AuthenticatedSessionController::class, 'create'])
->middleware(['throttle:'.config('fortify.limiters.fortify-login-get')])
->name('login');
Route::get(RoutePath::for('register', '/register'), [RegisteredUserController::class, 'create'])
->middleware(['throttle:'.config('fortify.limiters.fortify-register-get')])
->name('register');
Route::post(RoutePath::for('register', '/register'), [RegisteredUserController::class, 'store'])
->middleware(['throttle:'.config('fortify.limiters.fortify-register-post')]);
});
/*
@@ -75,9 +67,12 @@ Route::middleware('language')->group(function (): void {
Route::get('/reset-password/{token}', [App\Http\Controllers\Auth\NewPasswordController::class, 'create'])->middleware('throttle:'.GlobalRateLimit::RESET_PASSWORD->value)->name('password.reset');
Route::post('/reset-password', [App\Http\Controllers\Auth\NewPasswordController::class, 'store'])->middleware('throttle:'.GlobalRateLimit::RESET_PASSWORD->value)->name('password.update');
// Registration
Route::get('/register', [App\Http\Controllers\Auth\RegisteredUserController::class, 'create'])->middleware('throttle:'.GlobalRateLimit::REGISTER->value)->name('registration.create');
Route::post('/register', [App\Http\Controllers\Auth\RegisteredUserController::class, 'store'])->middleware('throttle:'.GlobalRateLimit::REGISTER->value)->name('registration.store');
// This redirect must be kept until all invite emails that use the old syntax have expired
// Hack so that Fortify can be used (allows query parameters but not route parameters)
Route::get('/register/{code?}', fn (string $code) => to_route('register', ['code' => $code]));
Route::get('/register/{code?}', fn (string $code) => to_route('registration.create', ['code' => $code]));
});
Route::middleware(['auth', 'banned'])->group(function (): void {

View File

@@ -50,6 +50,7 @@ test('user registration is available when enabled', function (): void {
]);
$email = fake()->freeEmail;
$password = fake()->regexify('[A-Z]{5}[a-z]{5}[0-9]{4}!');
$this->get('/register')
->assertOk()
@@ -58,9 +59,9 @@ test('user registration is available when enabled', function (): void {
$this->post('/register', [
'username' => 'testuser',
'email' => $email,
'password' => 'password',
'password_confirmation' => 'password',
])->assertRedirectToRoute('home.index');
'password' => $password,
'password_confirmation' => $password,
])->assertRedirectToRoute('verification.notice');
assertDatabaseHas('users', [
'username' => 'testuser',
@@ -97,6 +98,7 @@ test('user can register using invite code', function (): void {
]);
$email = fake()->freeEmail;
$password = fake()->regexify('[A-Z]{5}[a-z]{5}[0-9]{4}!');
$this->get('/register?code=testcode')
->assertOk()
@@ -105,11 +107,11 @@ test('user can register using invite code', function (): void {
$this->post('/register?code=testcode', [
'username' => 'testuser',
'email' => $email,
'password' => 'password',
'password_confirmation' => 'password',
'password' => $password,
'password_confirmation' => $password,
])
->assertSessionHasNoErrors()
->assertRedirectToRoute('home.index');
->assertRedirectToRoute('verification.notice');
assertDatabaseHas('users', [
'username' => 'testuser',
@@ -139,6 +141,7 @@ test('user cannot register using invalid invite code', function (): void {
]);
$email = fake()->freeEmail;
$password = fake()->regexify('[A-Z]{5}[a-z]{5}[0-9]{4}!');
$this->get('/register?code=testcode')
->assertOk()
@@ -147,8 +150,8 @@ test('user cannot register using invalid invite code', function (): void {
$this->post('/register?code=testcode', [
'username' => 'testuser',
'email' => $email,
'password' => 'password',
'password_confirmation' => 'password',
'password' => $password,
'password_confirmation' => $password,
])
->assertSessionHasErrors('code')
->assertRedirectToRoute('home.index');
@@ -175,6 +178,7 @@ test('user cannot confirm email using invalid hash', function (): void {
]);
$email = fake()->freeEmail;
$password = fake()->regexify('[A-Z]{5}[a-z]{5}[0-9]{4}!');
$this->get('/register?code=testcode')
->assertOk()
@@ -183,11 +187,11 @@ test('user cannot confirm email using invalid hash', function (): void {
$this->post('/register?code=testcode', [
'username' => 'testuser',
'email' => $email,
'password' => 'password',
'password_confirmation' => 'password',
'password' => $password,
'password_confirmation' => $password,
])
->assertSessionHasNoErrors()
->assertRedirectToRoute('home.index');
->assertRedirectToRoute('verification.notice');
assertDatabaseHas('users', [
'username' => 'testuser',
@@ -225,6 +229,7 @@ test('user can register using invite code with internal note assigned', function
]);
$email = fake()->freeEmail;
$password = fake()->regexify('[A-Z]{5}[a-z]{5}[0-9]{4}!');
$this->get('/register?code=testcode')
->assertOk()
@@ -233,11 +238,11 @@ test('user can register using invite code with internal note assigned', function
$this->post('/register?code=testcode', [
'username' => 'testuser',
'email' => $email,
'password' => 'password',
'password_confirmation' => 'password',
'password' => $password,
'password_confirmation' => $password,
])
->assertSessionHasNoErrors()
->assertRedirectToRoute('home.index');
->assertRedirectToRoute('verification.notice');
assertDatabaseHas('users', [
'username' => 'testuser',