diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index a0e6c086d..f7a1f7a21 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -128,6 +128,14 @@ class RegisterController extends Controller ]); if ($validator->fails()) { + $this->logRegistrationFailure( + $request, + 'validation_failed', + 'The registration request failed validation.', + 'warning', + ['validation_errors' => $validator->errors()->toArray()] + ); + return redirect()->back() ->withErrors($validator) ->withInput($request->except('password', 'password_confirmation')); @@ -146,7 +154,8 @@ class RegisterController extends Controller if ($existingUser && $existingUser->trashed()) { return $this->redirectBackWithRegistrationError( $request, - 'This email address belongs to a deactivated account. Please contact us if you need help reactivating it.' + 'This email address belongs to a deactivated account. Please contact us if you need help reactivating it.', + 'deactivated_account' ); } @@ -171,7 +180,8 @@ class RegisterController extends Controller if (! empty($invitation->email) && ! $this->emailsMatch($invitation->email, $email)) { return $this->redirectBackWithRegistrationError( $request, - 'The email address you entered does not match the invitation. Please use the invited email address to register.' + 'The email address you entered does not match the invitation. Please use the invited email address to register.', + 'invitation_email_mismatch' ); } } @@ -203,16 +213,15 @@ class RegisterController extends Controller } }); } catch (Throwable $exception) { - Log::error('User registration failed', [ - 'username' => $userName, - 'email' => $email, - 'ip' => $request->ip(), - 'error' => $exception->getMessage(), - ]); - return $this->redirectBackWithRegistrationError( $request, - 'We could not complete your registration right now. Please try again in a few minutes. If the problem continues, contact support.' + 'We could not complete your registration right now. Please try again in a few minutes. If the problem continues, contact support.', + 'unexpected_exception', + 'error', + [ + 'error' => $exception->getMessage(), + 'exception' => $exception::class, + ] ); } @@ -223,6 +232,11 @@ class RegisterController extends Controller } $error = 'Invalid or expired invitation token!'; + $this->logRegistrationFailure( + $request, + 'invalid_or_expired_invitation', + 'The registration attempt used an invalid or expired invitation token.' + ); break; case 'view': @@ -328,14 +342,47 @@ class RegisterController extends Controller return $invitation !== null; } - private function redirectBackWithRegistrationError(Request $request, string $message): RedirectResponse - { + /** + * @param array $context + */ + private function redirectBackWithRegistrationError( + Request $request, + string $message, + string $reason = 'registration_failed', + string $level = 'warning', + array $context = [] + ): RedirectResponse { + $this->logRegistrationFailure($request, $reason, $message, $level, $context); + return redirect() ->back() ->withErrors(['registration' => $message]) ->withInput($request->except('password', 'password_confirmation')); } + /** + * @param array $context + */ + private function logRegistrationFailure( + Request $request, + string $reason, + string $message, + string $level = 'warning', + array $context = [] + ): void { + $logContext = array_merge([ + 'reason' => $reason, + 'message' => $message, + 'username' => $request->input('username'), + 'email' => $request->input('email'), + 'ip' => $request->ip(), + 'registration_status' => Settings::settingValue('registerstatus'), + 'has_invite_code' => $request->filled('invitecode') || $request->filled('token'), + ], $context); + + Log::channel('registration')->{$level}("Registration attempt failed: {$reason}", $logContext); + } + private function emailsMatch(string $firstEmail, string $secondEmail): bool { return strcasecmp(trim($firstEmail), trim($secondEmail)) === 0; diff --git a/config/logging.php b/config/logging.php index 8b2138f31..89db30475 100644 --- a/config/logging.php +++ b/config/logging.php @@ -38,7 +38,7 @@ return [ 'papertrail' => [ 'driver' => 'monolog', 'level' => 'debug', - 'handler' => SyslogUdpHandler::class, + 'handler' => Monolog\Handler\SyslogUdpHandler::class, 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), @@ -46,7 +46,7 @@ return [ ], 'stderr' => [ 'driver' => 'monolog', - 'handler' => StreamHandler::class, + 'handler' => Monolog\Handler\StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), 'with' => [ 'stream' => 'php://stderr', @@ -87,6 +87,15 @@ return [ 'permission' => 0775, 'locking' => false, ], + 'registration' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/registration.log'), + 'level' => 'debug', + 'days' => 7, + 'bubble' => true, + 'permission' => 0775, + 'locking' => false, + ], 'crc_oso' => [ 'driver' => 'daily', 'path' => storage_path('logs/crc_oso.log'), diff --git a/tests/Feature/RegisterControllerTest.php b/tests/Feature/RegisterControllerTest.php index 3f19d116e..f13fd905f 100644 --- a/tests/Feature/RegisterControllerTest.php +++ b/tests/Feature/RegisterControllerTest.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\Validation\UncompromisedVerifier; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; +use Mockery; use Mockery\MockInterface; use Tests\TestCase; @@ -132,6 +134,26 @@ class RegisterControllerTest extends TestCase public function test_registration_failure_shows_explanation_for_deactivated_account(): void { + Log::partialMock(); + + $registrationLogger = Mockery::mock(); + Log::shouldReceive('channel') + ->once() + ->with('registration') + ->andReturn($registrationLogger); + + $registrationLogger->shouldReceive('warning') + ->once() + ->with( + 'Registration attempt failed: deactivated_account', + Mockery::on(function (array $context): bool { + return $context['reason'] === 'deactivated_account' + && $context['email'] === 'disabled@example.com' + && $context['username'] === 'freshmember' + && $context['registration_status'] === 0; + }) + ); + DB::table('users')->insert([ 'username' => 'oldmember', 'email' => 'disabled@example.com', @@ -158,4 +180,49 @@ class RegisterControllerTest extends TestCase 'registration' => 'This email address belongs to a deactivated account. Please contact us if you need help reactivating it.', ]); } + + public function test_registration_exception_is_logged_to_registration_channel(): void + { + Log::partialMock(); + + $registrationLogger = Mockery::mock(); + Log::shouldReceive('channel') + ->once() + ->with('registration') + ->andReturn($registrationLogger); + + $registrationLogger->shouldReceive('error') + ->once() + ->with( + 'Registration attempt failed: unexpected_exception', + Mockery::on(function (array $context): bool { + return $context['reason'] === 'unexpected_exception' + && $context['email'] === 'broken@example.com' + && $context['username'] === 'brokenmember' + && $context['exception'] === \RuntimeException::class + && $context['registration_status'] === 0; + }) + ); + + $this->partialMock(RegisterController::class, function (MockInterface $mock): void { + $mock->shouldAllowMockingProtectedMethods(); + $mock->shouldReceive('create') + ->once() + ->andThrow(new \RuntimeException('Simulated registration failure')); + }); + + $response = $this->from(route('register'))->post(route('register.post'), [ + 'action' => 'submit', + 'username' => 'brokenmember', + 'email' => 'broken@example.com', + 'password' => 'ValidPass1!', + 'password_confirmation' => 'ValidPass1!', + 'terms' => 'on', + ]); + + $response->assertRedirect(route('register')); + $response->assertSessionHasErrors([ + 'registration' => 'We could not complete your registration right now. Please try again in a few minutes. If the problem continues, contact support.', + ]); + } }