diff --git a/.gitignore b/.gitignore index f823126..9ee74e9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ coverage* .env.*.local var/ + +# Runtime directories (logs, cache, temporary files) +storage/ +resources/storage/ diff --git a/config/neuron.yaml b/config/neuron.yaml index e8a072c..976d62d 100644 --- a/config/neuron.yaml +++ b/config/neuron.yaml @@ -3,7 +3,7 @@ system: timezone: UTC - base_path: resources + base_path: . routes_path: resources/config logging: diff --git a/resources/views/http_codes/404.php b/resources/views/http_codes/404.php index 3ba231e..0d98690 100644 --- a/resources/views/http_codes/404.php +++ b/resources/views/http_codes/404.php @@ -1,6 +1,11 @@
-

The page you are looking for does not exist.

+

404

+
+
+
+
+

The page you are looking for does not exist.

diff --git a/resources/views/layouts/default.php b/resources/views/layouts/default.php index 72c37fa..7763371 100644 --- a/resources/views/layouts/default.php +++ b/resources/views/layouts/default.php @@ -51,7 +51,7 @@
- Powered by NeuronPHP. + Powered by NeuronCMS.
diff --git a/resources/views/layouts/member.php b/resources/views/layouts/member.php index d7ee1c8..7c4d6e1 100644 --- a/resources/views/layouts/member.php +++ b/resources/views/layouts/member.php @@ -63,7 +63,7 @@
- Powered by NeuronPHP. + Powered by NeuronCMS.
diff --git a/src/Cms/Cli/Commands/User/ResetPasswordCommand.php b/src/Cms/Cli/Commands/User/ResetPasswordCommand.php new file mode 100644 index 0000000..9d06a95 --- /dev/null +++ b/src/Cms/Cli/Commands/User/ResetPasswordCommand.php @@ -0,0 +1,252 @@ +addOption( 'username', 'u', true, 'Username of the user' ); + $this->addOption( 'email', 'e', true, 'Email of the user' ); + } + + /** + * Execute the command + */ + public function execute( array $parameters = [] ): int + { + $this->output->writeln( "\n╔═══════════════════════════════════════╗" ); + $this->output->writeln( "║ Neuron CMS - Reset User Password ║" ); + $this->output->writeln( "╚═══════════════════════════════════════╝\n" ); + + // Load database configuration + $repository = $this->getUserRepository(); + if( !$repository ) + { + return 1; + } + + $hasher = new PasswordHasher(); + + // Load password policy from configuration + try + { + $settings = Registry::getInstance()->get( RegistryKeys::SETTINGS ); + + if( $settings ) + { + // Read password policy from auth.passwords section + $minLength = $settings->get( 'auth', 'passwords', 'min_length' ); + $requireUppercase = $settings->get( 'auth', 'passwords', 'require_uppercase' ); + $requireLowercase = $settings->get( 'auth', 'passwords', 'require_lowercase' ); + $requireNumbers = $settings->get( 'auth', 'passwords', 'require_numbers' ); + $requireSpecialChars = $settings->get( 'auth', 'passwords', 'require_special_chars' ); + + // Configure hasher with policy + if( $minLength !== null ) + { + $hasher->setMinLength( (int)$minLength ); + } + + if( $requireUppercase !== null ) + { + $hasher->setRequireUppercase( (bool)$requireUppercase ); + } + + if( $requireLowercase !== null ) + { + $hasher->setRequireLowercase( (bool)$requireLowercase ); + } + + if( $requireNumbers !== null ) + { + $hasher->setRequireNumbers( (bool)$requireNumbers ); + } + + if( $requireSpecialChars !== null ) + { + $hasher->setRequireSpecialChars( (bool)$requireSpecialChars ); + } + } + } + catch( \Exception $e ) + { + // Fall back to defaults on any exception + } + + // Get username or email from options or prompt + $username = $this->input->getOption( 'username' ); + $email = $this->input->getOption( 'email' ); + + // If neither provided, prompt for identifier + if( !$username && !$email ) + { + $identifier = $this->prompt( "Enter username or email: " ); + $identifier = trim( $identifier ); + + if( empty( $identifier ) ) + { + $this->output->error( "Username or email is required!" ); + return 1; + } + + // Determine if it's an email or username + if( filter_var( $identifier, FILTER_VALIDATE_EMAIL ) ) + { + $email = $identifier; + } + else + { + $username = $identifier; + } + } + + // Find the user + $user = null; + if( $username ) + { + $user = $repository->findByUsername( $username ); + if( !$user ) + { + $this->output->error( "User '$username' not found!" ); + return 1; + } + } + elseif( $email ) + { + $user = $repository->findByEmail( $email ); + if( !$user ) + { + $this->output->error( "User with email '$email' not found!" ); + return 1; + } + } + + // Display user info + $this->output->writeln( "User found:" ); + $this->output->writeln( " ID: " . $user->getId() ); + $this->output->writeln( " Username: " . $user->getUsername() ); + $this->output->writeln( " Email: " . $user->getEmail() ); + $this->output->writeln( " Role: " . $user->getRole() ); + $this->output->writeln( "" ); + + // Confirm action + $confirm = $this->prompt( "Reset password for this user? (yes/no) [no]: " ); + if( strtolower( trim( $confirm ) ) !== 'yes' ) + { + $this->output->warning( "Password reset cancelled." ); + return 0; + } + + // Get new password + $password = $this->secret( "\nEnter new password: " ); + + // Validate password against configured policy + if( !$hasher->meetsRequirements( $password ) ) + { + $this->output->error( "Password does not meet requirements:" ); + + foreach( $hasher->getValidationErrors( $password ) as $error ) + { + $this->output->writeln( " - $error" ); + } + + $this->output->writeln( "" ); + return 1; + } + + // Confirm password + $confirmPassword = $this->secret( "Confirm new password: " ); + + if( $password !== $confirmPassword ) + { + $this->output->error( "Passwords do not match!" ); + return 1; + } + + // Update password + try + { + $user->setPasswordHash( $hasher->hash( $password ) ); + $user->setUpdatedAt( new \DateTimeImmutable() ); + + // Clear any lockout + $user->setFailedLoginAttempts( 0 ); + $user->setLockedUntil( null ); + + $success = $repository->update( $user ); + + if( !$success ) + { + $this->output->error( "Failed to update password in database" ); + return 1; + } + + $this->output->success( "Password reset successfully for user: " . $user->getUsername() ); + $this->output->writeln( "" ); + + return 0; + } + catch( \Exception $e ) + { + $this->output->error( "Error resetting password: " . $e->getMessage() ); + return 1; + } + } + + /** + * Get user repository + * + * Protected to allow mocking in tests + */ + protected function getUserRepository(): ?DatabaseUserRepository + { + try + { + $settings = Registry::getInstance()->get( RegistryKeys::SETTINGS ); + + if( !$settings ) + { + $this->output->error( "Application not initialized: Settings not found in Registry" ); + $this->output->writeln( "This is a configuration error - the application should load settings into the Registry" ); + return null; + } + + return new DatabaseUserRepository( $settings ); + } + catch( \Exception $e ) + { + $this->output->error( "Database connection failed: " . $e->getMessage() ); + return null; + } + } +} diff --git a/src/Cms/Cli/Provider.php b/src/Cms/Cli/Provider.php index 1ab31c5..eb42d02 100644 --- a/src/Cms/Cli/Provider.php +++ b/src/Cms/Cli/Provider.php @@ -45,6 +45,11 @@ public static function register( Registry $registry ): void 'Neuron\\Cms\\Cli\\Commands\\User\\DeleteCommand' ); + $registry->register( + 'cms:user:reset-password', + 'Neuron\\Cms\\Cli\\Commands\\User\\ResetPasswordCommand' + ); + // Maintenance mode commands $registry->register( 'cms:maintenance:enable', diff --git a/tests/Unit/Cms/Cli/Commands/User/ResetPasswordCommandTest.php b/tests/Unit/Cms/Cli/Commands/User/ResetPasswordCommandTest.php new file mode 100644 index 0000000..159da08 --- /dev/null +++ b/tests/Unit/Cms/Cli/Commands/User/ResetPasswordCommandTest.php @@ -0,0 +1,567 @@ +root = vfsStream::setup('test'); + + // Create config directory + vfsStream::newDirectory('config')->at($this->root); + + // Create test config + $configContent = <<at($this->root) + ->setContent($configContent); + + // Set up test dependencies + $this->inputReader = new TestInputReader(); + $this->output = new Output(false); // No colors in tests + $this->input = new Input([]); + } + + protected function tearDown(): void + { + // Clean up Registry + Registry::getInstance()->reset(); + } + + public function testConfigureSetupCommandMetadata(): void + { + $command = new ResetPasswordCommand(); + + // Use reflection to access protected method + $reflection = new \ReflectionClass($command); + $configureMethod = $reflection->getMethod('configure'); + $configureMethod->setAccessible(true); + $configureMethod->invoke($command); + + // Verify command metadata is set + $this->assertEquals('cms:user:reset-password', $command->getName()); + $this->assertNotEmpty($command->getDescription()); + } + + public function testGetUserRepositoryWithMissingConfig(): void + { + // Clear Registry to simulate missing Settings + \Neuron\Patterns\Registry::getInstance()->reset(); + + // Create command instance + $command = new ResetPasswordCommand(); + + // Mock the output to capture error messages + $output = $this->createMock(\Neuron\Cli\Console\Output::class); + $output->expects($this->once()) + ->method('error') + ->with('Application not initialized: Settings not found in Registry'); + $output->expects($this->once()) + ->method('writeln') + ->with('This is a configuration error - the application should load settings into the Registry'); + + $command->setOutput($output); + + // Use reflection to test private getUserRepository method + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('getUserRepository'); + $method->setAccessible(true); + + // Call the method and verify it returns null + $result = $method->invoke($command); + $this->assertNull($result); + } + + public function testExecuteResetsPasswordSuccessfully(): void + { + // Set up test input responses + $this->inputReader->addResponses([ + 'testuser', // username/email + 'yes', // confirm reset + 'NewSecurePass123!', // new password + 'NewSecurePass123!' // confirm password + ]); + + // Create mock user + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + $user->setPasswordHash('old_hash'); + + // Mock repository + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->with('testuser') + ->willReturn($user); + + $repository->expects($this->once()) + ->method('update') + ->willReturnCallback(function($updatedUser) { + $this->assertNotEquals('old_hash', $updatedUser->getPasswordHash()); + return true; + }); + + // Set up Registry with settings + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + // Create command with mocked repository + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + // Execute command + $exitCode = $command->execute(); + + // Verify success + $this->assertEquals(0, $exitCode); + + // Verify all prompts were shown + $prompts = $this->inputReader->getPromptHistory(); + $this->assertCount(4, $prompts); + $this->assertStringContainsString('username or email', $prompts[0]); + $this->assertStringContainsString('Reset password', $prompts[1]); + $this->assertStringContainsString('new password', $prompts[2]); + $this->assertStringContainsString('Confirm', $prompts[3]); + } + + public function testExecuteResetsPasswordWithEmailSuccessfully(): void + { + // Set up test input responses + $this->inputReader->addResponses([ + 'test@example.com', // email + 'yes', // confirm reset + 'NewSecurePass123!', // new password + 'NewSecurePass123!' // confirm password + ]); + + // Create mock user + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + $user->setPasswordHash('old_hash'); + + // Mock repository + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByEmail') + ->with('test@example.com') + ->willReturn($user); + + $repository->expects($this->once()) + ->method('update') + ->willReturn(true); + + // Set up Registry with settings + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + // Create command with mocked repository + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + // Execute command + $exitCode = $command->execute(); + + // Verify success + $this->assertEquals(0, $exitCode); + } + + public function testExecuteFailsWhenUserNotFound(): void + { + $this->inputReader->addResponse('nonexistent'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->with('nonexistent') + ->willReturn(null); + + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + $this->assertEquals(1, $exitCode); + } + + public function testExecuteCancelsWhenUserDeclines(): void + { + $this->inputReader->addResponses([ + 'testuser', + 'no' // Decline reset + ]); + + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->willReturn($user); + + // Ensure update is never called + $repository->expects($this->never()) + ->method('update'); + + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + $this->assertEquals(0, $exitCode); + } + + public function testExecuteFailsWhenPasswordTooShort(): void + { + $this->inputReader->addResponses([ + 'testuser', + 'yes', + 'short' // Password too short + ]); + + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->willReturn($user); + + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + $this->assertEquals(1, $exitCode); + } + + public function testExecuteFailsWhenPasswordsDoNotMatch(): void + { + $this->inputReader->addResponses([ + 'testuser', + 'yes', + 'NewSecurePass123!', + 'DifferentPassword123!' // Different password + ]); + + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->willReturn($user); + + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + $this->assertEquals(1, $exitCode); + } + + public function testExecuteWithUsernameOption(): void + { + // Create input with --username option + $input = new Input(['--username=testuser']); + + $this->inputReader->addResponses([ + 'yes', // confirm reset + 'NewSecurePass123!', // new password + 'NewSecurePass123!' // confirm password + ]); + + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->with('testuser') + ->willReturn($user); + + $repository->expects($this->once()) + ->method('update') + ->willReturn(true); + + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + $this->assertEquals(0, $exitCode); + + // Verify username prompt was not shown (only 3 prompts) + $prompts = $this->inputReader->getPromptHistory(); + $this->assertCount(3, $prompts); + } + + public function testExecuteWithEmailOption(): void + { + // Create input with --email option + $input = new Input(['--email=test@example.com']); + + $this->inputReader->addResponses([ + 'yes', // confirm reset + 'NewSecurePass123!', // new password + 'NewSecurePass123!' // confirm password + ]); + + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByEmail') + ->with('test@example.com') + ->willReturn($user); + + $repository->expects($this->once()) + ->method('update') + ->willReturn(true); + + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + $this->assertEquals(0, $exitCode); + + // Verify email prompt was not shown (only 3 prompts) + $prompts = $this->inputReader->getPromptHistory(); + $this->assertCount(3, $prompts); + } + + public function testExecuteFailsWhenDatabaseUpdateFails(): void + { + $this->inputReader->addResponses([ + 'testuser', + 'yes', + 'NewSecurePass123!', + 'NewSecurePass123!' + ]); + + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->willReturn($user); + + // update() returns false, simulating database failure + $repository->expects($this->once()) + ->method('update') + ->willReturn(false); + + $settings = $this->createMock(SettingManager::class); + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + // Should fail with exit code 1 + $this->assertEquals(1, $exitCode); + } + + public function testExecuteLoadsPasswordPolicyFromConfiguration(): void + { + $this->inputReader->addResponses([ + 'testuser', + 'yes', + 'abcdefghij', // 10 chars, all lowercase, no numbers - should fail with default policy + 'abcdefghij' + ]); + + $user = new User(); + $user->setId(1); + $user->setUsername('testuser'); + $user->setEmail('test@example.com'); + $user->setRole('admin'); + + $repository = $this->createMock(DatabaseUserRepository::class); + $repository->expects($this->once()) + ->method('findByUsername') + ->willReturn($user); + + // Mock settings to return custom password policy + $settings = $this->createMock(SettingManager::class); + $settings->method('get') + ->willReturnCallback(function(...$args) { + if ($args === ['auth', 'passwords', 'min_length']) { + return 8; + } + if ($args === ['auth', 'passwords', 'require_uppercase']) { + return true; // Require uppercase - should fail + } + if ($args === ['auth', 'passwords', 'require_lowercase']) { + return true; + } + if ($args === ['auth', 'passwords', 'require_numbers']) { + return true; // Require numbers - should fail + } + if ($args === ['auth', 'passwords', 'require_special_chars']) { + return false; + } + return null; + }); + + Registry::getInstance()->set('Settings', $settings); + + $command = $this->getMockBuilder(ResetPasswordCommand::class) + ->onlyMethods(['getUserRepository']) + ->getMock(); + $command->expects($this->once()) + ->method('getUserRepository') + ->willReturn($repository); + + $command->setInput($this->input); + $command->setOutput($this->output); + $command->setInputReader($this->inputReader); + + $exitCode = $command->execute(); + + // Should fail because password doesn't meet configured requirements + $this->assertEquals(1, $exitCode); + } +} diff --git a/versionlog.md b/versionlog.md index 5a7c40e..a248f34 100644 --- a/versionlog.md +++ b/versionlog.md @@ -1,4 +1,5 @@ ## 0.8.42 +* Adds cms:user:reset-passwordLe command to reset a user's password from the CLI. ## 0.8.41 2026-01-14 * Fix for site name in error pages.