diff --git a/composer.json b/composer.json index 0433b96..f223058 100644 --- a/composer.json +++ b/composer.json @@ -12,10 +12,11 @@ "require": { "ext-curl": "*", "ext-json": "*", - "neuron-php/mvc": "0.9.*", + "neuron-php/mvc": "^0.9.5", "neuron-php/cli": "0.8.*", "neuron-php/jobs": "0.2.*", "neuron-php/orm": "0.1.*", + "neuron-php/dto": "0.0.*", "phpmailer/phpmailer": "^6.9" }, "require-dev": { diff --git a/resources/app/Initializers/AuthInitializer.php b/resources/app/Initializers/AuthInitializer.php index 8cfb18f..48e70af 100644 --- a/resources/app/Initializers/AuthInitializer.php +++ b/resources/app/Initializers/AuthInitializer.php @@ -2,7 +2,7 @@ namespace App\Initializers; -use Neuron\Cms\Auth\AuthManager; +use Neuron\Cms\Services\Auth\Authentication; use Neuron\Cms\Auth\Filters\AuthenticationFilter; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Auth\SessionManager; @@ -55,16 +55,16 @@ public function run( array $argv = [] ): mixed $userRepository = new DatabaseUserRepository( $settings ); $sessionManager = new SessionManager(); $passwordHasher = new PasswordHasher(); - $authManager = new AuthManager( $userRepository, $sessionManager, $passwordHasher ); + $authentication = new Authentication( $userRepository, $sessionManager, $passwordHasher ); // Create authentication filter - $authFilter = new AuthenticationFilter( $authManager, '/login' ); + $authFilter = new AuthenticationFilter( $authentication, '/login' ); // Register the auth filter with the Router $app->getRouter()->registerFilter( 'auth', $authFilter ); - // Store AuthManager in Registry for easy access - Registry::getInstance()->set( 'AuthManager', $authManager ); + // Store Authentication in Registry for easy access + Registry::getInstance()->set( 'Authentication', $authentication ); } } catch( \Exception $e ) diff --git a/resources/app/Initializers/PasswordResetInitializer.php b/resources/app/Initializers/PasswordResetInitializer.php index 50d61ce..ab38cea 100644 --- a/resources/app/Initializers/PasswordResetInitializer.php +++ b/resources/app/Initializers/PasswordResetInitializer.php @@ -3,7 +3,7 @@ namespace App\Initializers; use Neuron\Cms\Auth\PasswordHasher; -use Neuron\Cms\Auth\PasswordResetManager; +use Neuron\Cms\Services\Auth\PasswordResetter; use Neuron\Cms\Repositories\DatabasePasswordResetTokenRepository; use Neuron\Cms\Repositories\DatabaseUserRepository; use Neuron\Data\Setting\SettingManager; @@ -14,7 +14,7 @@ /** * Initialize the password reset system * - * Registers the PasswordResetManager in the Registry + * Registers the PasswordResetter in the Registry */ class PasswordResetInitializer implements IRunnable { @@ -59,8 +59,8 @@ public function run( array $argv = [] ): mixed // Create password reset URL $resetUrl = rtrim( $siteUrl, '/' ) . '/reset-password'; - // Create password reset manager - $resetManager = new PasswordResetManager( + // Create password reset service + $passwordResetter = new PasswordResetter( $tokenRepository, $userRepository, $passwordHasher, @@ -70,10 +70,10 @@ public function run( array $argv = [] ): mixed ); // Set token expiration if configured - $resetManager->setTokenExpirationMinutes( (int)$tokenExpiration ); + $passwordResetter->setTokenExpirationMinutes( (int)$tokenExpiration ); - // Store PasswordResetManager in Registry for easy access - Registry::getInstance()->set( 'PasswordResetManager', $resetManager ); + // Store PasswordResetter in Registry for easy access + Registry::getInstance()->set( 'PasswordResetter', $passwordResetter ); } } catch( \Exception $e ) diff --git a/resources/app/Initializers/RegistrationInitializer.php b/resources/app/Initializers/RegistrationInitializer.php index faeeddc..17ad73a 100644 --- a/resources/app/Initializers/RegistrationInitializer.php +++ b/resources/app/Initializers/RegistrationInitializer.php @@ -2,9 +2,9 @@ namespace App\Initializers; -use Neuron\Cms\Auth\EmailVerificationManager; +use Neuron\Cms\Services\Auth\EmailVerifier; use Neuron\Cms\Auth\Filters\MemberAuthenticationFilter; -use Neuron\Cms\Auth\AuthManager; +use Neuron\Cms\Services\Auth\Authentication; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Repositories\DatabaseEmailVerificationTokenRepository; use Neuron\Cms\Repositories\DatabaseUserRepository; @@ -46,12 +46,12 @@ public function run( array $argv = [] ): mixed return null; } - // Get AuthManager from Registry (must be initialized first by AuthInitializer) - $authManager = Registry::getInstance()->get( 'AuthManager' ); + // Get Authentication from Registry (must be initialized first by AuthInitializer) + $authentication = Registry::getInstance()->get( 'Authentication' ); - if( !$authManager ) + if( !$authentication ) { - Log::error( "AuthManager not found in Registry, skipping registration initialization" ); + Log::error( "Authentication not found in Registry, skipping registration initialization" ); return null; } @@ -75,8 +75,8 @@ public function run( array $argv = [] ): mixed $verificationPath = $settings->get( 'member', 'verification_url' ) ?? '/verify-email'; $verificationUrl = rtrim( $siteUrl, '/' ) . '/' . ltrim( $verificationPath, '/' ); - // Create EmailVerificationManager - $verificationManager = new EmailVerificationManager( + // Create EmailVerifier + $emailVerifier = new EmailVerifier( $tokenRepository, $userRepository, $settings, @@ -86,7 +86,7 @@ public function run( array $argv = [] ): mixed // Set token expiration from settings $tokenExpiration = $settings->get( 'member', 'verification_token_expiration_minutes' ) ?? 60; - $verificationManager->setTokenExpirationMinutes( $tokenExpiration ); + $emailVerifier->setTokenExpirationMinutes( $tokenExpiration ); // Get event emitter if available $emitter = Registry::getInstance()->get( 'EventEmitter' ); @@ -95,7 +95,7 @@ public function run( array $argv = [] ): mixed $registrationService = new RegistrationService( $userRepository, $passwordHasher, - $verificationManager, + $emailVerifier, $settings, $emitter ); @@ -103,7 +103,7 @@ public function run( array $argv = [] ): mixed // Create member authentication filter $requireVerification = $settings->get( 'member', 'require_email_verification' ) ?? true; $memberFilter = new MemberAuthenticationFilter( - $authManager, + $authentication, '/login', $requireVerification ); @@ -112,10 +112,8 @@ public function run( array $argv = [] ): mixed $app->getRouter()->registerFilter( 'member', $memberFilter ); // Store services in Registry for easy access - Registry::getInstance()->set( 'EmailVerificationManager', $verificationManager ); + Registry::getInstance()->set( 'EmailVerifier', $emailVerifier ); Registry::getInstance()->set( 'RegistrationService', $registrationService ); - - Log::info( "Registration system initialized successfully" ); } } catch( \Exception $e ) diff --git a/resources/app/Initializers/ViewDataInitializer.php b/resources/app/Initializers/ViewDataInitializer.php index d9b5765..ae9afa1 100644 --- a/resources/app/Initializers/ViewDataInitializer.php +++ b/resources/app/Initializers/ViewDataInitializer.php @@ -65,8 +65,8 @@ public function run( array $argv = [] ): mixed // Check if user is authenticated (convenience helper) $provider->share( 'isAuthenticated', function() use ( $registry ) { - $authManager = $registry->get( 'AuthManager' ); - return $authManager && $authManager->check(); + $authentication = $registry->get( 'Authentication' ); + return $authentication && $authentication->check(); }); return null; diff --git a/resources/views/admin/profile/edit.php b/resources/views/admin/profile/edit.php index ab75205..e1cf95a 100644 --- a/resources/views/admin/profile/edit.php +++ b/resources/views/admin/profile/edit.php @@ -49,42 +49,15 @@
All dates and times will be displayed in your selected timezone
@@ -110,8 +83,6 @@ - -
diff --git a/resources/views/layouts/member.php b/resources/views/layouts/member.php index 05c1e36..d7ee1c8 100644 --- a/resources/views/layouts/member.php +++ b/resources/views/layouts/member.php @@ -11,8 +11,7 @@ - - +
- -
-
-
-
-
- Profile -
-

Manage your account settings and personal information.

- - Edit Profile - -
-
-
- -
-
-
-
- Account Status -
-

- Username: getUsername() ) ?>
- Email: getEmail() ) ?>
- Status: - isEmailVerified() ): ?> - Verified - - Unverified - -

-
-
-
- -
-
-
-
- Account Info -
-

- Member Since: - getCreatedAt() ? $User->getCreatedAt()->format('F j, Y') : 'N/A' ?>
- Last Login: - getLastLoginAt() ? $User->getLastLoginAt()->format('F j, Y g:i A') : 'Never' ?> -

-
-
-
-
diff --git a/resources/views/member/profile/edit.php b/resources/views/member/profile/edit.php index d13c0fc..aba26da 100644 --- a/resources/views/member/profile/edit.php +++ b/resources/views/member/profile/edit.php @@ -1,115 +1,107 @@
-
-
-

Edit Profile

+

My Profile

- -
- - -
- + +
+ - -
- - -
- + +
+ +
+
+
+ Profile Picture +
Profile Picture
+

+ Your profile picture is provided by Gravatar and linked to your email address. +

+ Change Avatar at Gravatar.com +
+
+
+
+ +
+
+
+
+
Account Information
+
- +
- + Username cannot be changed
- - + +
- + $timezones ): ?> + + + + + + All dates and times will be displayed in your selected timezone
-
+
+ + +
-
Change Password
-

Leave these fields blank if you don't want to change your password.

+ +
+
+
+
+ +
+
+
+
Change Password
+
+
+
+ +
- +
- - Minimum 8 characters + + + Password requirements: min 8 chars, uppercase, lowercase, number, special character +
-
+
- +
-
- - Cancel - - -
+
diff --git a/src/Cms/Auth/Filters/AuthenticationFilter.php b/src/Cms/Auth/Filters/AuthenticationFilter.php index e4accc4..bac5bee 100644 --- a/src/Cms/Auth/Filters/AuthenticationFilter.php +++ b/src/Cms/Auth/Filters/AuthenticationFilter.php @@ -4,7 +4,7 @@ use Neuron\Routing\Filter; use Neuron\Routing\RouteMap; -use Neuron\Cms\Auth\AuthManager; +use Neuron\Cms\Services\Auth\Authentication; use Neuron\Patterns\Registry; /** @@ -17,13 +17,13 @@ */ class AuthenticationFilter extends Filter { - private AuthManager $_authManager; + private Authentication $_authentication; private string $_loginUrl = '/login'; private string $_redirectParam = 'redirect'; - public function __construct( AuthManager $authManager, string $loginUrl = '/login' ) + public function __construct( Authentication $authentication, string $loginUrl = '/login' ) { - $this->_authManager = $authManager; + $this->_authentication = $authentication; $this->_loginUrl = $loginUrl; parent::__construct( @@ -37,7 +37,7 @@ function( RouteMap $route ) { $this->checkAuthentication( $route ); }, */ protected function checkAuthentication( RouteMap $route ): void { - $user = $this->_authManager->user(); + $user = $this->_authentication->user(); if( !$user ) { // Store intended URL for post-login redirect diff --git a/src/Cms/Auth/Filters/CsrfFilter.php b/src/Cms/Auth/Filters/CsrfFilter.php index 5052ce9..de7a340 100644 --- a/src/Cms/Auth/Filters/CsrfFilter.php +++ b/src/Cms/Auth/Filters/CsrfFilter.php @@ -4,7 +4,7 @@ use Neuron\Routing\Filter; use Neuron\Routing\RouteMap; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Log\Log; /** @@ -17,12 +17,12 @@ */ class CsrfFilter extends Filter { - private CsrfTokenManager $_csrfManager; + private CsrfToken $_csrfToken; private array $_exemptMethods = ['GET', 'HEAD', 'OPTIONS']; - public function __construct( CsrfTokenManager $csrfManager ) + public function __construct( CsrfToken $csrfToken ) { - $this->_csrfManager = $csrfManager; + $this->_csrfToken = $csrfToken; parent::__construct( function( RouteMap $route ) { $this->validateCsrfToken( $route ); }, @@ -53,7 +53,7 @@ protected function validateCsrfToken( RouteMap $route ): void } // Validate token - if( !$this->_csrfManager->validate( $token ) ) + if( !$this->_csrfToken->validate( $token ) ) { Log::warning( 'Invalid CSRF token' ); $this->respondForbidden( 'Invalid CSRF token' ); diff --git a/src/Cms/Auth/Filters/MemberAuthenticationFilter.php b/src/Cms/Auth/Filters/MemberAuthenticationFilter.php index 8a2ecbb..829391b 100644 --- a/src/Cms/Auth/Filters/MemberAuthenticationFilter.php +++ b/src/Cms/Auth/Filters/MemberAuthenticationFilter.php @@ -4,7 +4,7 @@ use Neuron\Routing\Filter; use Neuron\Routing\RouteMap; -use Neuron\Cms\Auth\AuthManager; +use Neuron\Cms\Services\Auth\Authentication; use Neuron\Patterns\Registry; /** @@ -18,19 +18,19 @@ */ class MemberAuthenticationFilter extends Filter { - private AuthManager $_authManager; + private Authentication $_authentication; private string $_loginUrl = '/login'; private string $_verifyEmailUrl = '/verify-email-required'; private string $_redirectParam = 'redirect'; private bool $_requireEmailVerification = true; public function __construct( - AuthManager $authManager, + Authentication $authentication, string $loginUrl = '/login', bool $requireEmailVerification = true ) { - $this->_authManager = $authManager; + $this->_authentication = $authentication; $this->_loginUrl = $loginUrl; $this->_requireEmailVerification = $requireEmailVerification; @@ -45,7 +45,7 @@ function( RouteMap $route ) { $this->checkAuthentication( $route ); }, */ protected function checkAuthentication( RouteMap $route ): void { - $user = $this->_authManager->user(); + $user = $this->_authentication->user(); // Check if user is authenticated if( !$user ) diff --git a/src/Cms/Controllers/Admin/Dashboard.php b/src/Cms/Controllers/Admin/Dashboard.php index 5180181..8ecbeb6 100644 --- a/src/Cms/Controllers/Admin/Dashboard.php +++ b/src/Cms/Controllers/Admin/Dashboard.php @@ -3,7 +3,7 @@ namespace Neuron\Cms\Controllers\Admin; use Neuron\Cms\Controllers\Content; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Cms\Auth\SessionManager; use Neuron\Core\Exceptions\NotFound; use Neuron\Mvc\Application; @@ -48,8 +48,8 @@ public function index( Request $request ): string // Generate CSRF token and store in Registry for helper functions $sessionManager = new SessionManager(); $sessionManager->start(); - $csrfManager = new CsrfTokenManager( $sessionManager ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $sessionManager ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); $viewData = [ 'Title' => 'Dashboard | ' . $this->getName(), diff --git a/src/Cms/Controllers/Admin/Posts.php b/src/Cms/Controllers/Admin/Posts.php index 0d6f7a6..e260929 100644 --- a/src/Cms/Controllers/Admin/Posts.php +++ b/src/Cms/Controllers/Admin/Posts.php @@ -11,9 +11,7 @@ use Neuron\Cms\Services\Post\Updater; use Neuron\Cms\Services\Post\Deleter; use Neuron\Cms\Services\Tag\Resolver as TagResolver; -use Neuron\Cms\Auth\CsrfTokenManager; -use Neuron\Cms\Auth\SessionManager; -use Neuron\Data\Setting\SettingManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; @@ -87,8 +85,8 @@ public function index( Request $request ): string // Generate CSRF token $sessionManager = $this->getSessionManager(); - $csrfManager = new CsrfTokenManager( $sessionManager ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $sessionManager ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); // Get all posts or filter by author if not admin if( $user->isAdmin() || $user->isEditor() ) @@ -133,8 +131,8 @@ public function create( Request $request ): string } // Generate CSRF token - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); $viewData = [ 'Title' => 'Create Post | ' . $this->getName(), @@ -169,14 +167,14 @@ public function store( Request $request ): never try { // Get form data - $title = $_POST['title'] ?? ''; - $slug = $_POST['slug'] ?? ''; - $content = $_POST['content'] ?? ''; - $excerpt = $_POST['excerpt'] ?? ''; - $featuredImage = $_POST['featured_image'] ?? ''; - $status = $_POST['status'] ?? Post::STATUS_DRAFT; - $categoryIds = $_POST['categories'] ?? []; - $tagNames = $_POST['tags'] ?? ''; + $title = $request->post('title', '' ); + $slug = $request->post( 'slug', '' ); + $content = $request->post('content', '' ); + $excerpt = $request->post( 'excerpt', '' ); + $featuredImage = $request->post('featured_image', '' ); + $status = $request->post( 'status', Post::STATUS_DRAFT ); + $categoryIds = $request->post( 'categories', [] ); + $tagNames = $request->post( 'tags', '' ); // Create post using service $this->_postCreator->create( @@ -229,8 +227,8 @@ public function edit( Request $request ): string } // Generate CSRF token - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); $viewData = [ 'Title' => 'Edit Post | ' . $this->getName(), diff --git a/src/Cms/Controllers/Admin/Profile.php b/src/Cms/Controllers/Admin/Profile.php index 6a23203..30fc361 100644 --- a/src/Cms/Controllers/Admin/Profile.php +++ b/src/Cms/Controllers/Admin/Profile.php @@ -6,7 +6,7 @@ use Neuron\Cms\Repositories\DatabaseUserRepository; use Neuron\Cms\Services\User\Updater; use Neuron\Cms\Auth\PasswordHasher; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Data\Setting\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; @@ -57,17 +57,18 @@ public function edit( Request $request ): string } // Generate CSRF token - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - // Get available timezones grouped by region + // Get available timezones grouped by region with selection state $timezones = \DateTimeZone::listIdentifiers(); + $groupedTimezones = group_timezones_for_select( $timezones, $user->getTimezone() ); $viewData = [ 'Title' => 'Profile | ' . $this->getName(), 'Description' => 'Edit Your Profile', 'User' => $user, - 'timezones' => $timezones, + 'groupedTimezones' => $groupedTimezones, 'success' => $this->getSessionManager()->getFlash( 'success' ), 'error' => $this->getSessionManager()->getFlash( 'error' ) ]; @@ -95,7 +96,9 @@ public function update( Request $request ): never throw new \RuntimeException( 'Authenticated user not found' ); } - $email = $request->post( 'email', '' ); + // Security: Only use email from POST if provided by Account Information form + // Password change form doesn't include email field, preventing email hijacking attacks + $email = $request->post( 'email', $user->getEmail() ); $timezone = $request->post( 'timezone', '' ); $currentPassword = $request->post( 'current_password', '' ); $newPassword = $request->post( 'new_password', '' ); diff --git a/src/Cms/Controllers/Admin/Users.php b/src/Cms/Controllers/Admin/Users.php index b4ea15a..881af81 100644 --- a/src/Cms/Controllers/Admin/Users.php +++ b/src/Cms/Controllers/Admin/Users.php @@ -9,7 +9,7 @@ use Neuron\Cms\Services\User\Updater; use Neuron\Cms\Services\User\Deleter; use Neuron\Cms\Auth\PasswordHasher; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Data\Setting\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; @@ -58,8 +58,8 @@ public function index( Request $request ): string $user = Registry::getInstance()->get( 'Auth.User' ); // Generate CSRF token - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); $users = $this->_repository->all(); @@ -91,8 +91,8 @@ public function create( Request $request ): string $user = Registry::getInstance()->get( 'Auth.User' ); // Generate CSRF token - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); $viewData = [ 'Title' => 'Create User | ' . $this->getName(), @@ -143,10 +143,10 @@ public function store( Request $request ): never * Show edit user form * * @param Request $request - * @return string|never + * @return string * @throws \Exception */ - public function edit( Request $request ) : string|never + public function edit( Request $request ): string { $id = (int)$request->getRouteParameter( 'id' ); $currentUser = Registry::getInstance()->get( 'Auth.User' ); @@ -158,8 +158,8 @@ public function edit( Request $request ) : string|never } // Generate CSRF token - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); $viewData = [ 'Title' => 'Edit User | ' . $this->getName(), @@ -205,8 +205,8 @@ public function update( Request $request ): never $this->redirect( 'admin_users_edit', ['id' => $id], ['error', 'Username and email are required'] ); } - $role = $_POST['role'] ?? $user->getRole(); - $password = $_POST['password'] ?? null; + $role = $request->post( 'role', $user->getRole() ); + $password = $request->post( 'password', null ); try { diff --git a/src/Cms/Controllers/Auth/Login.php b/src/Cms/Controllers/Auth/Login.php index 85c1b9f..0d65b87 100644 --- a/src/Cms/Controllers/Auth/Login.php +++ b/src/Cms/Controllers/Auth/Login.php @@ -3,8 +3,8 @@ namespace Neuron\Cms\Controllers\Auth; use Neuron\Cms\Controllers\Content; -use Neuron\Cms\Auth\AuthManager; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\Authentication; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Core\Exceptions\NotFound; use Neuron\Mvc\Application; use Neuron\Mvc\Responses\HttpResponseStatus; @@ -20,8 +20,8 @@ */ class Login extends Content { - private ?AuthManager $_authManager; - private CsrfTokenManager $_csrfManager; + private ?Authentication $_authentication; + private CsrfToken $_csrfToken; /** * @param Application|null $app @@ -31,16 +31,16 @@ public function __construct( ?Application $app = null ) { parent::__construct( $app ); - // Get AuthManager from Registry (set up by AuthInitializer) - $this->_authManager = Registry::getInstance()->get( 'AuthManager' ); + // Get Authentication from Registry (set up by AuthInitializer) + $this->_authentication = Registry::getInstance()->get( 'Authentication' ); - if( !$this->_authManager ) + if( !$this->_authentication ) { - throw new \RuntimeException( 'AuthManager not found in Registry.' ); + throw new \RuntimeException( 'Authentication not found in Registry.' ); } // Initialize CSRF manager with parent's session manager - $this->_csrfManager = new CsrfTokenManager( $this->getSessionManager() ); + $this->_csrfToken = new CsrfToken( $this->getSessionManager() ); } /** @@ -53,16 +53,19 @@ public function __construct( ?Application $app = null ) public function showLoginForm( Request $request ): string { // If already logged in, redirect to the dashboard - if( $this->_authManager->check() ) + if( $this->_authentication->check() ) { $this->redirect( 'admin_dashboard' ); } // Set CSRF token in Registry so csrf_field() helper works - Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfManager->getToken() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfToken->getToken() ); - $defaultRedirect = $this->urlFor( 'admin_dashboard' ) ?? '/admin/dashboard'; - $requestedRedirect = $request->get( 'redirect', $defaultRedirect ); + // Get redirect parameter from URL or default to admin dashboard + $defaultRedirect = $this->urlFor( 'admin_dashboard', [], '/admin/dashboard' ) ?? '/admin/dashboard'; + $requestedRedirect = $request->get( 'redirect', $defaultRedirect ) ?? $defaultRedirect; + + // Validate and use requested redirect, fallback to default if invalid $redirectUrl = $this->isValidRedirectUrl( $requestedRedirect ) ? $requestedRedirect : $defaultRedirect; @@ -94,7 +97,7 @@ public function login( Request $request ): never // Validate CSRF token $token = $request->post( 'csrf_token' ); - if( !$this->_csrfManager->validate( $token ) ) + if( !$this->_csrfToken->validate( $token ) ) { $this->redirect( 'login', [], ['error', 'Invalid CSRF token. Please try again.'] ); } @@ -111,14 +114,16 @@ public function login( Request $request ): never } // Attempt authentication - if( !$this->_authManager->attempt( $username, $password, $remember ) ) + if( !$this->_authentication->attempt( $username, $password, $remember ) ) { $this->redirect( 'login', [], ['error', 'Invalid username or password.'] ); } // Successful login - redirect to intended URL or dashboard - $defaultRedirect = $this->urlFor( 'admin_dashboard' ) ?? '/admin/dashboard'; - $requestedRedirect = $_POST['redirect_url'] ?? $defaultRedirect; + $defaultRedirect = $this->urlFor( 'admin_dashboard', [], '/admin/dashboard' ) ?? '/admin/dashboard'; + $requestedRedirect = $request->post( 'redirect_url', $defaultRedirect ) ?? $defaultRedirect; + + // Validate and use requested redirect, fallback to default if invalid $redirectUrl = $this->isValidRedirectUrl( $requestedRedirect ) ? $requestedRedirect : $defaultRedirect; @@ -133,8 +138,8 @@ public function login( Request $request ): never */ public function logout( Request $request ): never { - $this->_authManager->logout(); - $this->redirect( 'login', [], ['success', 'You have been logged out successfully.'] ); + $this->_authentication->logout(); + $this->redirect( 'home', [], ['success', 'You have been logged out successfully.'] ); } /** diff --git a/src/Cms/Controllers/Auth/PasswordReset.php b/src/Cms/Controllers/Auth/PasswordReset.php index d26d2ed..9978301 100644 --- a/src/Cms/Controllers/Auth/PasswordReset.php +++ b/src/Cms/Controllers/Auth/PasswordReset.php @@ -3,9 +3,9 @@ namespace Neuron\Cms\Controllers\Auth; use Neuron\Cms\Controllers\Content; -use Neuron\Cms\Auth\PasswordResetManager; +use Neuron\Cms\Services\Auth\PasswordResetter; use Neuron\Cms\Auth\SessionManager; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Core\Exceptions\NotFound; use Neuron\Log\Log; use Neuron\Mvc\Application; @@ -24,8 +24,8 @@ */ class PasswordReset extends Content { - private ?PasswordResetManager $_resetManager; - private CsrfTokenManager $_csrfManager; + private ?PasswordResetter $_passwordResetter; + private CsrfToken $_csrfToken; /** * @param Application|null $app @@ -35,16 +35,16 @@ public function __construct( ?Application $app = null ) { parent::__construct( $app ); - // Get PasswordResetManager from Registry - $this->_resetManager = Registry::getInstance()->get( 'PasswordResetManager' ); + // Get PasswordResetter from Registry + $this->_passwordResetter = Registry::getInstance()->get( 'PasswordResetter' ); - if( !$this->_resetManager ) + if( !$this->_passwordResetter ) { - throw new \RuntimeException( 'PasswordResetManager not found in Registry. Ensure password reset is properly configured.' ); + throw new \RuntimeException( 'PasswordResetter not found in Registry. Ensure password reset is properly configured.' ); } // Initialize CSRF manager with parent's session manager - $this->_csrfManager = new CsrfTokenManager( $this->getSessionManager() ); + $this->_csrfToken = new CsrfToken( $this->getSessionManager() ); } /** @@ -56,7 +56,7 @@ public function __construct( ?Application $app = null ) public function showForgotPasswordForm( Request $request ): string { // Set CSRF token in Registry so csrf_field() helper works - Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfManager->getToken() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfToken->getToken() ); $viewData = [ 'Title' => 'Forgot Password | ' . $this->getName(), @@ -77,14 +77,14 @@ public function showForgotPasswordForm( Request $request ): string /** * Process forgot password request * - * @param array $parameters + * @param Request $request * @return string */ public function requestReset( Request $request ): string { // Validate CSRF token $token = $request->post( 'csrf_token', '' ); - if( !$this->_csrfManager->validate( $token ) ) + if( !$this->_csrfToken->validate( $token ) ) { $this->_sessionManager->flash( 'error', 'Invalid CSRF token. Please try again.' ); header( 'Location: /forgot-password' ); @@ -97,13 +97,13 @@ public function requestReset( Request $request ): string // Validate input if( empty( $email ) || !filter_var( $email, FILTER_VALIDATE_EMAIL ) ) { - $this->redirect( 'admin_posts', [], ['error', 'Please enter a valid email address.'] ); + $this->redirect( 'forgot_password', [], ['error', 'Please enter a valid email address.'] ); } try { // Request password reset - $this->_resetManager->requestReset( $email ); + $this->_passwordResetter->requestReset( $email ); // Always show success message (don't reveal if email exists) $this->_sessionManager->flash( @@ -145,7 +145,7 @@ public function showResetForm( Request $request ): string } // Validate token - $tokenObj = $this->_resetManager->validateToken( $token ); + $tokenObj = $this->_passwordResetter->validateToken( $token ); if( !$tokenObj ) { @@ -155,7 +155,7 @@ public function showResetForm( Request $request ): string } // Set CSRF token in Registry so csrf_field() helper works - Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfManager->getToken() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfToken->getToken() ); $viewData = [ 'Title' => 'Reset Password | ' . $this->getName(), @@ -184,9 +184,9 @@ public function resetPassword( Request $request ): string { // Validate CSRF token $csrfToken = $request->post( 'csrf_token', '' ); - if( !$this->_csrfManager->validate( $csrfToken ) ) + if( !$this->_csrfToken->validate( $csrfToken ) ) { - $this->redirect( 'admin_posts', [], ['error', 'Invalid CSRF token.'] ); + $this->redirect( 'forgot_password', [], ['error', 'Invalid CSRF token.'] ); } // Get form data @@ -209,7 +209,7 @@ public function resetPassword( Request $request ): string try { // Attempt password reset - $success = $this->_resetManager->resetPassword( $token, $password ); + $success = $this->_passwordResetter->resetPassword( $token, $password ); if( !$success ) { diff --git a/src/Cms/Controllers/Member/Dashboard.php b/src/Cms/Controllers/Member/Dashboard.php index 1160efb..7e3393d 100644 --- a/src/Cms/Controllers/Member/Dashboard.php +++ b/src/Cms/Controllers/Member/Dashboard.php @@ -3,7 +3,7 @@ namespace Neuron\Cms\Controllers\Member; use Neuron\Cms\Controllers\Content; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Core\Exceptions\NotFound; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; @@ -45,8 +45,8 @@ public function index( Request $request ): string } // Generate CSRF token and store in Registry - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); $viewData = [ 'Title' => 'Member Dashboard | ' . $this->getName(), @@ -58,7 +58,7 @@ public function index( Request $request ): string HttpResponseStatus::OK, $viewData, 'index', - 'member/dashboard' + 'member' ); } } diff --git a/src/Cms/Controllers/Member/Profile.php b/src/Cms/Controllers/Member/Profile.php index 8d5add3..08ab355 100644 --- a/src/Cms/Controllers/Member/Profile.php +++ b/src/Cms/Controllers/Member/Profile.php @@ -6,7 +6,7 @@ use Neuron\Cms\Repositories\DatabaseUserRepository; use Neuron\Cms\Services\User\Updater; use Neuron\Cms\Auth\PasswordHasher; -use Neuron\Cms\Auth\CsrfTokenManager; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; @@ -57,17 +57,18 @@ public function edit( Request $request ): string } // Generate CSRF token - $csrfManager = new CsrfTokenManager( $this->getSessionManager() ); - Registry::getInstance()->set( 'Auth.CsrfToken', $csrfManager->getToken() ); + $csrfToken = new CsrfToken( $this->getSessionManager() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $csrfToken->getToken() ); - // Get available timezones grouped by region + // Get available timezones grouped by region with selection state $timezones = \DateTimeZone::listIdentifiers(); + $groupedTimezones = group_timezones_for_select( $timezones, $user->getTimezone() ); $viewData = [ 'Title' => 'Profile | ' . $this->getName(), 'Description' => 'Edit Your Profile', 'User' => $user, - 'timezones' => $timezones, + 'groupedTimezones' => $groupedTimezones, 'success' => $this->getSessionManager()->getFlash( 'success' ), 'error' => $this->getSessionManager()->getFlash( 'error' ) ]; @@ -76,7 +77,7 @@ public function edit( Request $request ): string HttpResponseStatus::OK, $viewData, 'edit', - 'member/profile' + 'member' ); } @@ -96,7 +97,9 @@ public function update( Request $request ): never throw new \RuntimeException( 'Authenticated user not found' ); } - $email = $request->post( 'email', '' ); + // Security: Only use email from POST if provided by Account Information form + // Password change form doesn't include email field, preventing email hijacking attacks + $email = $request->post( 'email', $user->getEmail() ); $timezone = $request->post( 'timezone', '' ); $currentPassword = $request->post( 'current_password', '' ); $newPassword = $request->post( 'new_password', '' ); diff --git a/src/Cms/Controllers/Member/Registration.php b/src/Cms/Controllers/Member/Registration.php index 84a0840..4febd7e 100644 --- a/src/Cms/Controllers/Member/Registration.php +++ b/src/Cms/Controllers/Member/Registration.php @@ -3,8 +3,9 @@ namespace Neuron\Cms\Controllers\Member; use Neuron\Cms\Controllers\Content; -use Neuron\Cms\Auth\CsrfTokenManager; -use Neuron\Cms\Auth\EmailVerificationManager; +use Neuron\Cms\Controllers\Traits\UsesDtos; +use Neuron\Cms\Services\Auth\CsrfToken; +use Neuron\Cms\Services\Auth\EmailVerifier; use Neuron\Cms\Auth\ResendVerificationThrottle; use Neuron\Cms\Services\Member\RegistrationService; use Neuron\Core\Exceptions\NotFound; @@ -25,9 +26,10 @@ */ class Registration extends Content { + use UsesDtos; private RegistrationService $_registrationService; - private EmailVerificationManager $_verificationManager; - private CsrfTokenManager $_csrfManager; + private EmailVerifier $_emailVerifier; + private CsrfToken $_csrfToken; private ResendVerificationThrottle $_resendThrottle; private IIpResolver $_ipResolver; @@ -41,15 +43,15 @@ public function __construct( ?Application $app = null ) // Get services from Registry $this->_registrationService = Registry::getInstance()->get( 'RegistrationService' ); - $this->_verificationManager = Registry::getInstance()->get( 'EmailVerificationManager' ); + $this->_emailVerifier = Registry::getInstance()->get( 'EmailVerifier' ); - if( !$this->_registrationService || !$this->_verificationManager ) + if( !$this->_registrationService || !$this->_emailVerifier ) { throw new \RuntimeException( 'Registration services not found in Registry.' ); } // Initialize CSRF manager - $this->_csrfManager = new CsrfTokenManager( $this->getSessionManager() ); + $this->_csrfToken = new CsrfToken( $this->getSessionManager() ); // Initialize resend verification throttle $this->_resendThrottle = new ResendVerificationThrottle(); @@ -83,7 +85,7 @@ public function showRegistrationForm( Request $request ): string } // Set CSRF token in Registry - Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfManager->getToken() ); + Registry::getInstance()->set( 'Auth.CsrfToken', $this->_csrfToken->getToken() ); $viewData = [ 'Title' => 'Register | ' . $this->getName(), @@ -107,30 +109,31 @@ public function showRegistrationForm( Request $request ): string */ public function processRegistration( Request $request ): never { - // Validate CSRF token - $token = $request->post( 'csrf_token' ); + // Validate CSRF token - defensively handle null/non-string values + $tokenRaw = $request->post( 'csrf_token' ); - if( !$this->_csrfManager->validate( $token ) ) + if( $tokenRaw === null || $tokenRaw === '' ) { $this->redirect( 'register', [], ['error', 'Invalid CSRF token. Please try again.'] ); } - // Get form data - $username = $request->post( 'username' ) ?? ''; - $email = $request->post( 'email' ) ?? ''; - $password = $request->post( 'password' ) ?? ''; - $passwordConfirmation = $request->post( 'password_confirmation' ) ?? ''; + $token = (string)$tokenRaw; + + if( !$this->_csrfToken->validate( $token ) ) + { + $this->redirect( 'register', [], ['error', 'Invalid CSRF token. Please try again.'] ); + } try { - // Register user - $user = $this->_registrationService->register( - $username, - $email, - $password, - $passwordConfirmation - ); + // Create and populate RegisterUser DTO from request + $dto = $this->createDtoFromRequest( 'RegisterUser', $request ); + // Validate the DTO + $this->validateDtoOrFail( $dto ); + + // Register user using DTO + $this->_registrationService->registerWithDto( $dto ); // Check if verification is required $settings = Registry::getInstance()->get( 'Settings' ); $requireVerification = $settings->get( 'member', 'require_email_verification' ) ?? true; @@ -208,7 +211,7 @@ public function verify( Request $request ): string try { // Verify the token - $success = $this->_verificationManager->verifyEmail( $token ); + $success = $this->_emailVerifier->verifyEmail( $token ); if( $success ) { @@ -269,10 +272,17 @@ public function verify( Request $request ): string */ public function resendVerification( Request $request ): never { - // Validate CSRF token - $token = $request->post( 'csrf_token' ); + // Validate CSRF token - defensively handle null/non-string values + $tokenRaw = $request->post( 'csrf_token' ); + + if( $tokenRaw === null || $tokenRaw === '' ) + { + $this->redirect( 'register', [], ['error', 'Invalid CSRF token. Please try again.'] ); + } + + $token = (string)$tokenRaw; - if( !$this->_csrfManager->validate( $token ) ) + if( !$this->_csrfToken->validate( $token ) ) { $this->redirect( 'register', [], ['error', 'Invalid CSRF token. Please try again.'] ); } @@ -297,7 +307,7 @@ public function resendVerification( Request $request ): never try { // Resend verification email - $this->_verificationManager->resendVerification( $email ); + $this->_emailVerifier->resendVerification( $email ); // Always show success message (don't reveal if email exists) $this->redirect( 'verify_email_sent', [], ['success', 'If an account exists with that email, a verification email has been sent.'] ); diff --git a/src/Cms/Controllers/Traits/UsesDtos.php b/src/Cms/Controllers/Traits/UsesDtos.php new file mode 100644 index 0000000..d6ff25f --- /dev/null +++ b/src/Cms/Controllers/Traits/UsesDtos.php @@ -0,0 +1,142 @@ +get( 'DtoFactoryService' ); + + if( !$factory ) + { + $factory = new DtoFactoryService(); + Registry::getInstance()->set( 'DtoFactoryService', $factory ); + } + + return $factory; + } + + /** + * Populate a DTO from request data + * + * Sets DTO properties from POST data. Silently ignores properties + * that don't exist in the DTO. + * + * @param Dto $dto DTO to populate + * @param Request $request Request containing form data + * @param array $fields Array of field names to populate (defaults to all POST data) + * @return Dto The populated DTO + */ + protected function populateDtoFromRequest( Dto $dto, Request $request, array $fields = [] ): Dto + { + // If no specific fields provided, use all POST data keys + if( empty( $fields ) ) + { + $fields = array_keys( $_POST ); + } + + foreach( $fields as $field ) + { + $value = $request->post( $field ); + + // Get the property from the DTO + $property = $dto->getProperty( $field ); + + // Skip if property doesn't exist + if( !$property ) + { + continue; + } + + // Set the value + try + { + // Use magic setter which handles validation per property + $dto->$field = $value; + } + catch( Validation $e ) + { + // Property-level validation errors are collected + // They'll be returned when validate() is called on the DTO + } + } + + return $dto; + } + + /** + * Validate a DTO and return errors + * + * @param Dto $dto DTO to validate + * @return array Array of validation error messages (empty if valid) + */ + protected function validateDto( Dto $dto ): array + { + try + { + $dto->validate(); + } + catch( Validation $e ) + { + // Validation exception is thrown, but errors are also stored in DTO + } + + return $dto->getErrors(); + } + + /** + * Validate a DTO and throw exception with formatted message if invalid + * + * @param Dto $dto DTO to validate + * @return void + * @throws \Exception if validation fails + */ + protected function validateDtoOrFail( Dto $dto ): void + { + $errors = $this->validateDto( $dto ); + + if( !empty( $errors ) ) + { + $message = implode( ', ', $errors ); + throw new \Exception( $message ); + } + } + + /** + * Create and populate a DTO from request data + * + * Convenience method that creates a DTO by name and populates it from request. + * + * @param string $name DTO name (e.g., 'RegisterUser', 'CreatePost') + * @param Request $request Request containing form data + * @param array $fields Optional array of field names to populate + * @return Dto + * @throws \Exception if DTO cannot be created + */ + protected function createDtoFromRequest( string $name, Request $request, array $fields = [] ): Dto + { + $dto = $this->getDtoFactory()->create( $name ); + return $this->populateDtoFromRequest( $dto, $request, $fields ); + } +} diff --git a/src/Cms/Dtos/CreateCategoryDto.yaml b/src/Cms/Dtos/CreateCategoryDto.yaml index 4356a73..05c5262 100644 --- a/src/Cms/Dtos/CreateCategoryDto.yaml +++ b/src/Cms/Dtos/CreateCategoryDto.yaml @@ -1,17 +1,16 @@ -properties: +dto: name: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 2 - max: 100 + length: + min: 2 + max: 100 slug: type: string required: true - validators: - - IsNotEmpty + length: + min: 2 + max: 100 description: type: string required: false diff --git a/src/Cms/Dtos/CreatePostDto.yaml b/src/Cms/Dtos/CreatePostDto.yaml index 3f239b0..e59ea87 100644 --- a/src/Cms/Dtos/CreatePostDto.yaml +++ b/src/Cms/Dtos/CreatePostDto.yaml @@ -1,24 +1,18 @@ -properties: +dto: title: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 5 - max: 255 + length: + min: 5 + max: 255 body: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 10 + length: + min: 10 status: type: string required: true - validators: - - IsNotEmpty slug: type: string required: false @@ -31,6 +25,8 @@ properties: categoryIds: type: array required: false + items: + type: integer tags: type: string required: false diff --git a/src/Cms/Dtos/CreateUserDto.yaml b/src/Cms/Dtos/CreateUserDto.yaml index 95ceaa9..08000d9 100644 --- a/src/Cms/Dtos/CreateUserDto.yaml +++ b/src/Cms/Dtos/CreateUserDto.yaml @@ -1,27 +1,19 @@ -properties: +dto: username: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 3 - max: 50 + length: + min: 3 + max: 50 email: - type: string + type: email required: true - validators: - - IsNotEmpty - - IsEmail password: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 8 + length: + min: 8 + max: 255 role: type: string required: true - validators: - - IsNotEmpty diff --git a/src/Cms/Dtos/RegisterUserDto.yaml b/src/Cms/Dtos/RegisterUserDto.yaml index 3f2c592..ca4245d 100644 --- a/src/Cms/Dtos/RegisterUserDto.yaml +++ b/src/Cms/Dtos/RegisterUserDto.yaml @@ -1,28 +1,22 @@ -properties: +dto: username: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 3 - max: 50 - - IsAlphanumeric + length: + min: 3 + max: 50 email: - type: string + type: email required: true - validators: - - IsNotEmpty - - IsEmail password: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 8 + length: + min: 8 + max: 255 password_confirmation: type: string required: true - validators: - - IsNotEmpty + length: + min: 8 + max: 255 diff --git a/src/Cms/Dtos/UpdateCategoryDto.yaml b/src/Cms/Dtos/UpdateCategoryDto.yaml index 4356a73..05c5262 100644 --- a/src/Cms/Dtos/UpdateCategoryDto.yaml +++ b/src/Cms/Dtos/UpdateCategoryDto.yaml @@ -1,17 +1,16 @@ -properties: +dto: name: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 2 - max: 100 + length: + min: 2 + max: 100 slug: type: string required: true - validators: - - IsNotEmpty + length: + min: 2 + max: 100 description: type: string required: false diff --git a/src/Cms/Dtos/UpdatePostDto.yaml b/src/Cms/Dtos/UpdatePostDto.yaml index 3f239b0..e59ea87 100644 --- a/src/Cms/Dtos/UpdatePostDto.yaml +++ b/src/Cms/Dtos/UpdatePostDto.yaml @@ -1,24 +1,18 @@ -properties: +dto: title: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 5 - max: 255 + length: + min: 5 + max: 255 body: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 10 + length: + min: 10 status: type: string required: true - validators: - - IsNotEmpty slug: type: string required: false @@ -31,6 +25,8 @@ properties: categoryIds: type: array required: false + items: + type: integer tags: type: string required: false diff --git a/src/Cms/Dtos/UpdateUserDto.yaml b/src/Cms/Dtos/UpdateUserDto.yaml index d66abbb..65945b0 100644 --- a/src/Cms/Dtos/UpdateUserDto.yaml +++ b/src/Cms/Dtos/UpdateUserDto.yaml @@ -1,26 +1,19 @@ -properties: +dto: username: type: string required: true - validators: - - IsNotEmpty - - IsStringLength: - min: 3 - max: 50 + length: + min: 3 + max: 50 email: - type: string + type: email required: true - validators: - - IsNotEmpty - - IsEmail password: type: string required: false - validators: - - IsStringLength: - min: 8 + length: + min: 8 + max: 255 role: type: string required: true - validators: - - IsNotEmpty diff --git a/src/Cms/Auth/AuthManager.php b/src/Cms/Services/Auth/Authentication.php similarity index 97% rename from src/Cms/Auth/AuthManager.php rename to src/Cms/Services/Auth/Authentication.php index e51b647..b2d00bd 100644 --- a/src/Cms/Auth/AuthManager.php +++ b/src/Cms/Services/Auth/Authentication.php @@ -1,20 +1,22 @@ _dtoDirectory = rtrim( $dtoDirectory, '/' ); + } + + /** + * Get a DTO instance by name + * + * @param string $name DTO name (e.g., 'RegisterUser', 'CreatePost') + * @return Dto + * @throws Exception if DTO file doesn't exist or cannot be loaded + */ + public function create( string $name ): Dto + { + // Check cache first + if( isset( $this->_dtoCache[ $name ] ) ) + { + return clone $this->_dtoCache[ $name ]; + } + + // Build file path + $filePath = $this->_dtoDirectory . '/' . $name . 'Dto.yaml'; + + // Check if file exists + if( !file_exists( $filePath ) ) + { + throw new Exception( "DTO definition file not found: {$filePath}" ); + } + + // Create DTO using Neuron\Dto\Factory + $factory = new Factory( $filePath ); + $dto = $factory->create(); + + // Cache for future use + $this->_dtoCache[ $name ] = $dto; + + // Return a clone so callers can set values without affecting cache + return clone $dto; + } + + /** + * Create a RegisterUser DTO + * + * @return Dto + * @throws Exception + */ + public function createRegisterUser(): Dto + { + return $this->create( 'RegisterUser' ); + } + + /** + * Create a CreateUser DTO + * + * @return Dto + * @throws Exception + */ + public function createCreateUser(): Dto + { + return $this->create( 'CreateUser' ); + } + + /** + * Create an UpdateUser DTO + * + * @return Dto + * @throws Exception + */ + public function createUpdateUser(): Dto + { + return $this->create( 'UpdateUser' ); + } + + /** + * Create a CreateCategory DTO + * + * @return Dto + * @throws Exception + */ + public function createCreateCategory(): Dto + { + return $this->create( 'CreateCategory' ); + } + + /** + * Create an UpdateCategory DTO + * + * @return Dto + * @throws Exception + */ + public function createUpdateCategory(): Dto + { + return $this->create( 'UpdateCategory' ); + } + + /** + * Create a CreatePost DTO + * + * @return Dto + * @throws Exception + */ + public function createCreatePost(): Dto + { + return $this->create( 'CreatePost' ); + } + + /** + * Create an UpdatePost DTO + * + * @return Dto + * @throws Exception + */ + public function createUpdatePost(): Dto + { + return $this->create( 'UpdatePost' ); + } + + /** + * Clear the DTO cache + * + * @return void + */ + public function clearCache(): void + { + $this->_dtoCache = []; + } + + /** + * Get the DTO directory path + * + * @return string + */ + public function getDtoDirectory(): string + { + return $this->_dtoDirectory; + } +} diff --git a/src/Cms/Services/Member/RegistrationService.php b/src/Cms/Services/Member/RegistrationService.php index 79baea6..236e83d 100644 --- a/src/Cms/Services/Member/RegistrationService.php +++ b/src/Cms/Services/Member/RegistrationService.php @@ -2,11 +2,12 @@ namespace Neuron\Cms\Services\Member; -use Neuron\Cms\Auth\EmailVerificationManager; +use Neuron\Cms\Services\Auth\EmailVerifier; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\IUserRepository; use Neuron\Data\Setting\SettingManager; +use Neuron\Dto\Dto; use Neuron\Events\Emitter; use Neuron\Cms\Events\UserCreatedEvent; use Exception; @@ -22,7 +23,7 @@ class RegistrationService { private IUserRepository $_userRepository; private PasswordHasher $_passwordHasher; - private EmailVerificationManager $_verificationManager; + private EmailVerifier $_emailVerifier; private SettingManager $_settings; private ?Emitter $_emitter; @@ -31,21 +32,21 @@ class RegistrationService * * @param IUserRepository $userRepository User repository * @param PasswordHasher $passwordHasher Password hasher - * @param EmailVerificationManager $verificationManager Email verification manager + * @param EmailVerifier $emailVerifier Email verification service * @param SettingManager $settings Settings manager * @param Emitter|null $emitter Event emitter (optional) */ public function __construct( IUserRepository $userRepository, PasswordHasher $passwordHasher, - EmailVerificationManager $verificationManager, + EmailVerifier $emailVerifier, SettingManager $settings, ?Emitter $emitter = null ) { $this->_userRepository = $userRepository; $this->_passwordHasher = $passwordHasher; - $this->_verificationManager = $verificationManager; + $this->_emailVerifier = $emailVerifier; $this->_settings = $settings; $this->_emitter = $emitter; } @@ -115,12 +116,94 @@ public function register( // Create user in database $this->_userRepository->create( $user ); + // Send a verification email if required + if( $requireVerification ) + { + try + { + $this->_emailVerifier->sendVerificationEmail( $user ); + } + catch( Exception $e ) + { + // Log error but don't fail registration + // User can request resend later + } + } + + // Emit user created event + if( $this->_emitter ) + { + $this->_emitter->emit( new UserCreatedEvent( $user ) ); + } + + return $user; + } + + /** + * Register a new user using a RegisterUser DTO + * + * @param Dto $dto RegisterUser DTO with validated data + * @return User Created user + * @throws Exception if registration is disabled or validation fails + */ + public function registerWithDto( Dto $dto ): User + { + // Check if registration is enabled + if( !$this->isRegistrationEnabled() ) + { + throw new Exception( 'User registration is currently disabled.' ); + } + + // Extract data from DTO + $username = $dto->username; + $email = $dto->email; + $password = $dto->password; + $passwordConfirmation = $dto->password_confirmation; + + // Validate business rules (uniqueness checks) + $this->validateUserBusinessRules( $username, $email ); + + // Validate password confirmation + if( $password !== $passwordConfirmation ) + { + throw new Exception( 'Passwords do not match.' ); + } + + // Create user + $user = new User(); + $user->setUsername( $username ); + $user->setEmail( $email ); + $user->setPasswordHash( $this->_passwordHasher->hash( $password ) ); + + // Set default role from settings + $defaultRole = $this->_settings->get( 'member', 'default_role' ) ?? User::ROLE_SUBSCRIBER; + $user->setRole( $defaultRole ); + + // Determine if email verification is required + $requireVerification = $this->_settings->get( 'member', 'require_email_verification' ) ?? true; + + if( $requireVerification ) + { + // User starts as inactive until email is verified + $user->setStatus( User::STATUS_INACTIVE ); + $user->setEmailVerified( false ); + } + else + { + // User is immediately active if verification not required + $user->setStatus( User::STATUS_ACTIVE ); + $user->setEmailVerified( true ); + } + + // Create user in database + $this->_userRepository->create( $user ); + // Send verification email if required if( $requireVerification ) { try { - $this->_verificationManager->sendVerificationEmail( $user ); + $this->_emailVerifier->sendVerificationEmail( $user ); } catch( Exception $e ) { @@ -211,4 +294,26 @@ private function validateRegistration( throw new Exception( implode( ', ', $errors ) ); } } + + /** + * Validate user business rules (uniqueness checks) + * + * @param string $username Username to validate + * @param string $email Email to validate + * @throws Exception if business rules fail + */ + private function validateUserBusinessRules( string $username, string $email ): void + { + // Check if username is already taken + if( $this->_userRepository->findByUsername( $username ) ) + { + throw new Exception( 'Username is already taken.' ); + } + + // Check if email is already registered + if( $this->_userRepository->findByEmail( $email ) ) + { + throw new Exception( 'Email is already registered.' ); + } + } } diff --git a/src/Cms/View/helpers.php b/src/Cms/View/helpers.php index 44884a2..7088fc7 100644 --- a/src/Cms/View/helpers.php +++ b/src/Cms/View/helpers.php @@ -203,3 +203,55 @@ function get_timezones(): array return $grouped; } } + +if (!function_exists('group_timezones_for_select')) { + /** + * Prepare timezones for select dropdown with selection state + * + * Groups timezones by region and returns a structured array ready for rendering + * in a select dropdown. Each timezone includes value, label, and selected state. + * + * @param array $timezones List of timezone identifiers + * @param string|null $currentTimezone Currently selected timezone + * @return array> Grouped timezones + * + * @example + * $grouped = group_timezones_for_select($timezones, 'America/New_York'); + * // Returns: + * // [ + * // 'America' => [ + * // ['value' => 'America/New_York', 'label' => 'America/New_York', 'selected' => true], + * // ['value' => 'America/Chicago', 'label' => 'America/Chicago', 'selected' => false], + * // ], + * // 'Other' => [...] + * // ] + */ + function group_timezones_for_select(array $timezones, ?string $currentTimezone = null): array + { + $grouped = ['Other' => []]; + + // Group timezones by region + foreach ($timezones as $timezone) { + $parts = explode('/', $timezone, 2); + if (count($parts) === 2) { + $region = $parts[0]; + if (!isset($grouped[$region])) { + $grouped[$region] = []; + } + $grouped[$region][] = [ + 'value' => $timezone, + 'label' => $timezone, + 'selected' => $timezone === $currentTimezone + ]; + } else { + $grouped['Other'][] = [ + 'value' => $timezone, + 'label' => $timezone, + 'selected' => $timezone === $currentTimezone + ]; + } + } + + return $grouped; + } +} diff --git a/tests/Cms/Auth/MemberAuthenticationFilterTest.php b/tests/Cms/Auth/MemberAuthenticationFilterTest.php index 65aaf20..34ab5fe 100644 --- a/tests/Cms/Auth/MemberAuthenticationFilterTest.php +++ b/tests/Cms/Auth/MemberAuthenticationFilterTest.php @@ -2,7 +2,7 @@ namespace Tests\Cms\Auth; -use Neuron\Cms\Auth\AuthManager; +use Neuron\Cms\Services\Auth\Authentication; use Neuron\Cms\Auth\Filters\MemberAuthenticationFilter; use Neuron\Cms\Models\User; use Neuron\Routing\RouteMap; @@ -15,7 +15,7 @@ */ class MemberAuthenticationFilterTest extends TestCase { - private AuthManager $_authManager; + private Authentication $_authentication; private MemberAuthenticationFilter $_filter; private RouteMap $_route; @@ -24,10 +24,10 @@ protected function setUp(): void parent::setUp(); // Create mock auth manager - $this->_authManager = $this->createMock( AuthManager::class ); + $this->_authentication = $this->createMock( Authentication::class ); // Create filter - $this->_filter = new MemberAuthenticationFilter( $this->_authManager, '/login', true ); + $this->_filter = new MemberAuthenticationFilter( $this->_authentication, '/login', true ); // Create mock route $this->_route = $this->createMock( RouteMap::class ); @@ -75,7 +75,7 @@ public function testBeforeWithAuthenticatedAndVerifiedUser(): void $user->setEmailVerified( true ); // Auth manager returns user - $this->_authManager + $this->_authentication ->method( 'user' ) ->willReturn( $user ); @@ -94,14 +94,14 @@ public function testBeforeWithAuthenticatedAndVerifiedUser(): void public function testBeforeWithUnauthenticatedUser(): void { // Auth manager returns null (no user) - $this->_authManager + $this->_authentication ->method( 'user' ) ->willReturn( null ); // We can't easily test exit() behavior, but we can verify the condition // In a real scenario, this would redirect and exit // For testing, we just verify the user() method is called - $this->assertNull( $this->_authManager->user() ); + $this->assertNull( $this->_authentication->user() ); // Note: In production, calling $this->_filter->pre() would trigger redirect and exit // We skip that here to avoid process termination in tests @@ -121,12 +121,12 @@ public function testBeforeWithUnverifiedUser(): void $user->setEmailVerified( false ); // Auth manager returns unverified user - $this->_authManager + $this->_authentication ->method( 'user' ) ->willReturn( $user ); // Filter requires verification - $filter = new MemberAuthenticationFilter( $this->_authManager, '/login', true ); + $filter = new MemberAuthenticationFilter( $this->_authentication, '/login', true ); // Verify user is unverified and filter requires verification $this->assertFalse( $user->isEmailVerified() ); @@ -145,12 +145,12 @@ public function testBeforeWithUnverifiedUserButVerificationNotRequired(): void $user->setEmailVerified( false ); // Auth manager returns unverified user - $this->_authManager + $this->_authentication ->method( 'user' ) ->willReturn( $user ); // Filter doesn't require verification - $filter = new MemberAuthenticationFilter( $this->_authManager, '/login', false ); + $filter = new MemberAuthenticationFilter( $this->_authentication, '/login', false ); // This should NOT redirect, even though user is unverified $filter->pre( $this->_route ); diff --git a/tests/Cms/Auth/AuthManagerTest.php b/tests/Cms/Services/AuthenticationTest.php similarity index 61% rename from tests/Cms/Auth/AuthManagerTest.php rename to tests/Cms/Services/AuthenticationTest.php index a16acab..3830b7b 100644 --- a/tests/Cms/Auth/AuthManagerTest.php +++ b/tests/Cms/Services/AuthenticationTest.php @@ -1,9 +1,9 @@ with( 'database' ) ->willReturn( $config ); - $this->userRepository = new DatabaseUserRepository($settings); + $this->_userRepository = new DatabaseUserRepository($settings); // Get PDO connection via reflection to create table - $reflection = new \ReflectionClass($this->userRepository); + $reflection = new \ReflectionClass($this->_userRepository); $property = $reflection->getProperty('_pdo'); $property->setAccessible(true); - $this->pdo = $property->getValue($this->userRepository); + $this->pdo = $property->getValue($this->_userRepository); // Create users table $this->createUsersTable(); - $this->sessionManager = new SessionManager([ + $this->_sessionManager = new SessionManager([ 'cookie_secure' => false // Disable HTTPS requirement for tests ]); - $this->passwordHasher = new PasswordHasher(); + $this->_passwordHasher = new PasswordHasher(); - $this->authManager = new AuthManager( - $this->userRepository, - $this->sessionManager, - $this->passwordHasher + $this->_authentication = new Authentication( + $this->_userRepository, + $this->_sessionManager, + $this->_passwordHasher ); } @@ -98,62 +98,62 @@ private function createTestUser(string $username = 'testuser', string $password $user = new User(); $user->setUsername($username); $user->setEmail($username . '@example.com'); - $user->setPasswordHash($this->passwordHasher->hash($password)); + $user->setPasswordHash($this->_passwordHasher->hash($password)); $user->setRole(User::ROLE_AUTHOR); $user->setStatus(User::STATUS_ACTIVE); - return $this->userRepository->create($user); + return $this->_userRepository->create($user); } public function testAttemptWithCorrectCredentials(): void { $user = $this->createTestUser('testuser', 'TestPass123'); - $result = $this->authManager->attempt('testuser', 'TestPass123'); + $result = $this->_authentication->attempt('testuser', 'TestPass123'); $this->assertTrue($result); - $this->assertTrue($this->authManager->check()); + $this->assertTrue($this->_authentication->check()); } public function testAttemptWithIncorrectPassword(): void { $user = $this->createTestUser('testuser', 'TestPass123'); - $result = $this->authManager->attempt('testuser', 'WrongPassword'); + $result = $this->_authentication->attempt('testuser', 'WrongPassword'); $this->assertFalse($result); - $this->assertFalse($this->authManager->check()); + $this->assertFalse($this->_authentication->check()); } public function testAttemptWithNonexistentUser(): void { - $result = $this->authManager->attempt('nonexistent', 'password'); + $result = $this->_authentication->attempt('nonexistent', 'password'); $this->assertFalse($result); - $this->assertFalse($this->authManager->check()); + $this->assertFalse($this->_authentication->check()); } public function testAttemptWithInactiveUser(): void { $user = $this->createTestUser('suspended', 'TestPass123'); $user->setStatus(User::STATUS_SUSPENDED); - $this->userRepository->update($user); + $this->_userRepository->update($user); - $result = $this->authManager->attempt('suspended', 'TestPass123'); + $result = $this->_authentication->attempt('suspended', 'TestPass123'); $this->assertFalse($result); - $this->assertFalse($this->authManager->check()); + $this->assertFalse($this->_authentication->check()); } public function testAttemptWithLockedOutUser(): void { $user = $this->createTestUser('locked', 'TestPass123'); $user->setLockedUntil((new DateTimeImmutable())->modify('+10 minutes')); - $this->userRepository->update($user); + $this->_userRepository->update($user); - $result = $this->authManager->attempt('locked', 'TestPass123'); + $result = $this->_authentication->attempt('locked', 'TestPass123'); $this->assertFalse($result); - $this->assertFalse($this->authManager->check()); + $this->assertFalse($this->_authentication->check()); } public function testFailedLoginAttemptsIncrement(): void @@ -163,13 +163,13 @@ public function testFailedLoginAttemptsIncrement(): void $this->assertEquals(0, $user->getFailedLoginAttempts()); // First failed attempt - $this->authManager->attempt('failtest', 'WrongPassword'); - $user = $this->userRepository->findByUsername('failtest'); + $this->_authentication->attempt('failtest', 'WrongPassword'); + $user = $this->_userRepository->findByUsername('failtest'); $this->assertEquals(1, $user->getFailedLoginAttempts()); // Second failed attempt - $this->authManager->attempt('failtest', 'WrongPassword'); - $user = $this->userRepository->findByUsername('failtest'); + $this->_authentication->attempt('failtest', 'WrongPassword'); + $user = $this->_userRepository->findByUsername('failtest'); $this->assertEquals(2, $user->getFailedLoginAttempts()); } @@ -179,15 +179,15 @@ public function testAccountLockoutAfterMaxAttempts(): void // Make 5 failed attempts (default max) for ($i = 0; $i < 5; $i++) { - $this->authManager->attempt('locktest', 'WrongPassword'); + $this->_authentication->attempt('locktest', 'WrongPassword'); } - $user = $this->userRepository->findByUsername('locktest'); + $user = $this->_userRepository->findByUsername('locktest'); $this->assertTrue($user->isLockedOut()); $this->assertNotNull($user->getLockedUntil()); // Should not be able to login even with correct password - $result = $this->authManager->attempt('locktest', 'TestPass123'); + $result = $this->_authentication->attempt('locktest', 'TestPass123'); $this->assertFalse($result); } @@ -197,15 +197,15 @@ public function testSuccessfulLoginResetsFailedAttempts(): void // Make 3 failed attempts for ($i = 0; $i < 3; $i++) { - $this->authManager->attempt('resettest', 'WrongPassword'); + $this->_authentication->attempt('resettest', 'WrongPassword'); } - $user = $this->userRepository->findByUsername('resettest'); + $user = $this->_userRepository->findByUsername('resettest'); $this->assertEquals(3, $user->getFailedLoginAttempts()); // Successful login should reset - $this->authManager->attempt('resettest', 'TestPass123'); - $user = $this->userRepository->findByUsername('resettest'); + $this->_authentication->attempt('resettest', 'TestPass123'); + $user = $this->_userRepository->findByUsername('resettest'); $this->assertEquals(0, $user->getFailedLoginAttempts()); } @@ -213,9 +213,9 @@ public function testLoginWithRememberMe(): void { $user = $this->createTestUser('remembertest', 'TestPass123'); - $this->authManager->attempt('remembertest', 'TestPass123', true); + $this->_authentication->attempt('remembertest', 'TestPass123', true); - $user = $this->userRepository->findByUsername('remembertest'); + $user = $this->_userRepository->findByUsername('remembertest'); $this->assertNotNull($user->getRememberToken()); } @@ -223,31 +223,31 @@ public function testLoginWithoutRememberMe(): void { $user = $this->createTestUser('noremember', 'TestPass123'); - $this->authManager->attempt('noremember', 'TestPass123', false); + $this->_authentication->attempt('noremember', 'TestPass123', false); - $user = $this->userRepository->findByUsername('noremember'); + $user = $this->_userRepository->findByUsername('noremember'); $this->assertNull($user->getRememberToken()); } public function testCheckReturnsTrue(): void { $user = $this->createTestUser('checktest', 'TestPass123'); - $this->authManager->attempt('checktest', 'TestPass123'); + $this->_authentication->attempt('checktest', 'TestPass123'); - $this->assertTrue($this->authManager->check()); + $this->assertTrue($this->_authentication->check()); } public function testCheckReturnsFalse(): void { - $this->assertFalse($this->authManager->check()); + $this->assertFalse($this->_authentication->check()); } public function testUserReturnsAuthenticatedUser(): void { $user = $this->createTestUser('usertest', 'TestPass123'); - $this->authManager->attempt('usertest', 'TestPass123'); + $this->_authentication->attempt('usertest', 'TestPass123'); - $authUser = $this->authManager->user(); + $authUser = $this->_authentication->user(); $this->assertNotNull($authUser); $this->assertEquals('usertest', $authUser->getUsername()); @@ -255,23 +255,23 @@ public function testUserReturnsAuthenticatedUser(): void public function testUserReturnsNullWhenNotAuthenticated(): void { - $this->assertNull($this->authManager->user()); + $this->assertNull($this->_authentication->user()); } public function testLogout(): void { $user = $this->createTestUser('logouttest', 'TestPass123'); - $this->authManager->attempt('logouttest', 'TestPass123', true); + $this->_authentication->attempt('logouttest', 'TestPass123', true); - $this->assertTrue($this->authManager->check()); + $this->assertTrue($this->_authentication->check()); - $this->authManager->logout(); + $this->_authentication->logout(); - $this->assertFalse($this->authManager->check()); - $this->assertNull($this->authManager->user()); + $this->assertFalse($this->_authentication->check()); + $this->assertNull($this->_authentication->user()); // Remember token should be removed - $user = $this->userRepository->findByUsername('logouttest'); + $user = $this->_userRepository->findByUsername('logouttest'); $this->assertNull($user->getRememberToken()); } @@ -285,21 +285,21 @@ public function testLoginUsingRememberToken(): void // Manually set the hashed token on the user $user->setRememberToken($hashedToken); - $this->userRepository->update($user); + $this->_userRepository->update($user); // Login using the plain token (as would come from cookie) - $result = $this->authManager->loginUsingRememberToken($plainToken); + $result = $this->_authentication->loginUsingRememberToken($plainToken); $this->assertTrue($result); - $this->assertTrue($this->authManager->check()); + $this->assertTrue($this->_authentication->check()); } public function testLoginUsingInvalidRememberToken(): void { - $result = $this->authManager->loginUsingRememberToken('invalid_token'); + $result = $this->_authentication->loginUsingRememberToken('invalid_token'); $this->assertFalse($result); - $this->assertFalse($this->authManager->check()); + $this->assertFalse($this->_authentication->check()); } public function testPasswordRehashingOnLogin(): void @@ -310,10 +310,10 @@ public function testPasswordRehashingOnLogin(): void // Mock that hash needs rehashing (in reality this checks algorithm version) // For testing, we'll just verify the hash is checked - $this->authManager->attempt('rehashtest', 'TestPass123'); + $this->_authentication->attempt('rehashtest', 'TestPass123'); // Password should be hashed with current algorithm - $user = $this->userRepository->findByUsername('rehashtest'); + $user = $this->_userRepository->findByUsername('rehashtest'); $this->assertNotEmpty($user->getPasswordHash()); } @@ -321,23 +321,23 @@ public function testIsAdmin(): void { $user = $this->createTestUser('admintest', 'TestPass123'); $user->setRole(\Neuron\Cms\Models\User::ROLE_ADMIN); - $this->userRepository->update($user); + $this->_userRepository->update($user); - $this->authManager->attempt('admintest', 'TestPass123'); + $this->_authentication->attempt('admintest', 'TestPass123'); - $this->assertTrue($this->authManager->isAdmin()); + $this->assertTrue($this->_authentication->isAdmin()); } public function testHasRole(): void { $user = $this->createTestUser('roletest', 'TestPass123'); $user->setRole(\Neuron\Cms\Models\User::ROLE_EDITOR); - $this->userRepository->update($user); + $this->_userRepository->update($user); - $this->authManager->attempt('roletest', 'TestPass123'); + $this->_authentication->attempt('roletest', 'TestPass123'); - $this->assertTrue($this->authManager->hasRole(\Neuron\Cms\Models\User::ROLE_EDITOR)); - $this->assertFalse($this->authManager->hasRole(\Neuron\Cms\Models\User::ROLE_ADMIN)); + $this->assertTrue($this->_authentication->hasRole(\Neuron\Cms\Models\User::ROLE_EDITOR)); + $this->assertFalse($this->_authentication->hasRole(\Neuron\Cms\Models\User::ROLE_ADMIN)); } public function testUpdateLastLoginTime(): void @@ -346,9 +346,9 @@ public function testUpdateLastLoginTime(): void $this->assertNull($user->getLastLoginAt()); - $this->authManager->attempt('lastlogin', 'TestPass123'); + $this->_authentication->attempt('lastlogin', 'TestPass123'); - $user = $this->userRepository->findByUsername('lastlogin'); + $user = $this->_userRepository->findByUsername('lastlogin'); $this->assertInstanceOf(DateTimeImmutable::class, $user->getLastLoginAt()); } } diff --git a/tests/Cms/Auth/CsrfTokenManagerTest.php b/tests/Cms/Services/CsrfTokenTest.php similarity index 61% rename from tests/Cms/Auth/CsrfTokenManagerTest.php rename to tests/Cms/Services/CsrfTokenTest.php index 5c18d6e..88d962d 100644 --- a/tests/Cms/Auth/CsrfTokenManagerTest.php +++ b/tests/Cms/Services/CsrfTokenTest.php @@ -1,18 +1,18 @@ sessionManager = new SessionManager([ 'cookie_secure' => false // Disable HTTPS requirement for tests ]); - $this->csrfManager = new CsrfTokenManager($this->sessionManager); + $this->_csrfToken = new CsrfToken($this->sessionManager); $_SESSION = []; // Clear session data } @@ -32,7 +32,7 @@ protected function tearDown(): void public function testGenerateToken(): void { - $token = $this->csrfManager->generate(); + $token = $this->_csrfToken->generate(); $this->assertNotEmpty($token); $this->assertIsString($token); @@ -41,7 +41,7 @@ public function testGenerateToken(): void public function testGenerateTokenStoresInSession(): void { - $token = $this->csrfManager->generate(); + $token = $this->_csrfToken->generate(); $storedToken = $this->sessionManager->get('csrf_token'); @@ -50,28 +50,28 @@ public function testGenerateTokenStoresInSession(): void public function testGenerateTokenIsRandom(): void { - $token1 = $this->csrfManager->generate(); + $token1 = $this->_csrfToken->generate(); // Clear session to force new token $_SESSION = []; - $token2 = $this->csrfManager->generate(); + $token2 = $this->_csrfToken->generate(); $this->assertNotEquals($token1, $token2); } public function testGetTokenReturnsExistingToken(): void { - $generated = $this->csrfManager->generate(); + $generated = $this->_csrfToken->generate(); - $retrieved = $this->csrfManager->getToken(); + $retrieved = $this->_csrfToken->getToken(); $this->assertEquals($generated, $retrieved); } public function testGetTokenGeneratesTokenIfNotExists(): void { - $token = $this->csrfManager->getToken(); + $token = $this->_csrfToken->getToken(); $this->assertNotEmpty($token); $this->assertIsString($token); @@ -79,27 +79,27 @@ public function testGetTokenGeneratesTokenIfNotExists(): void public function testValidateWithCorrectToken(): void { - $token = $this->csrfManager->generate(); + $token = $this->_csrfToken->generate(); - $result = $this->csrfManager->validate($token); + $result = $this->_csrfToken->validate($token); $this->assertTrue($result); } public function testValidateWithIncorrectToken(): void { - $this->csrfManager->generate(); + $this->_csrfToken->generate(); - $result = $this->csrfManager->validate('incorrect_token'); + $result = $this->_csrfToken->validate('incorrect_token'); $this->assertFalse($result); } public function testValidateWithEmptyToken(): void { - $this->csrfManager->generate(); + $this->_csrfToken->generate(); - $result = $this->csrfManager->validate(''); + $result = $this->_csrfToken->validate(''); $this->assertFalse($result); } @@ -108,7 +108,7 @@ public function testValidateWithNoStoredToken(): void { // Don't generate a token first - $result = $this->csrfManager->validate('some_token'); + $result = $this->_csrfToken->validate('some_token'); $this->assertFalse($result); } @@ -118,41 +118,41 @@ public function testValidateUsesTimingSafeComparison(): void // This test verifies that validation uses hash_equals (timing-attack safe) // We can't directly test timing, but we verify it accepts exact matches only - $token = $this->csrfManager->generate(); + $token = $this->_csrfToken->generate(); // Slightly modified tokens should fail $almostToken = substr($token, 0, -1) . 'x'; - $this->assertTrue($this->csrfManager->validate($token)); - $this->assertFalse($this->csrfManager->validate($almostToken)); + $this->assertTrue($this->_csrfToken->validate($token)); + $this->assertFalse($this->_csrfToken->validate($almostToken)); } public function testRegenerateToken(): void { - $firstToken = $this->csrfManager->generate(); + $firstToken = $this->_csrfToken->generate(); - $secondToken = $this->csrfManager->regenerate(); + $secondToken = $this->_csrfToken->regenerate(); $this->assertNotEquals($firstToken, $secondToken); - $this->assertEquals($secondToken, $this->csrfManager->getToken()); + $this->assertEquals($secondToken, $this->_csrfToken->getToken()); } public function testRegenerateTokenInvalidatesOldToken(): void { - $oldToken = $this->csrfManager->generate(); + $oldToken = $this->_csrfToken->generate(); - $newToken = $this->csrfManager->regenerate(); + $newToken = $this->_csrfToken->regenerate(); // Old token should no longer be valid - $this->assertFalse($this->csrfManager->validate($oldToken)); + $this->assertFalse($this->_csrfToken->validate($oldToken)); // New token should be valid - $this->assertTrue($this->csrfManager->validate($newToken)); + $this->assertTrue($this->_csrfToken->validate($newToken)); } public function testTokenLength(): void { - $token = $this->csrfManager->generate(); + $token = $this->_csrfToken->generate(); // Token should be URL-safe and reasonably long $this->assertGreaterThanOrEqual(32, strlen($token)); @@ -161,20 +161,20 @@ public function testTokenLength(): void public function testTokenPersistsAcrossMultipleValidations(): void { - $token = $this->csrfManager->generate(); + $token = $this->_csrfToken->generate(); // Validate multiple times - $this->assertTrue($this->csrfManager->validate($token)); - $this->assertTrue($this->csrfManager->validate($token)); - $this->assertTrue($this->csrfManager->validate($token)); + $this->assertTrue($this->_csrfToken->validate($token)); + $this->assertTrue($this->_csrfToken->validate($token)); + $this->assertTrue($this->_csrfToken->validate($token)); // Token should still be valid - $this->assertEquals($token, $this->csrfManager->getToken()); + $this->assertEquals($token, $this->_csrfToken->getToken()); } public function testTokenIsUrlSafe(): void { - $token = $this->csrfManager->generate(); + $token = $this->_csrfToken->generate(); // Encode and decode should return same token $encoded = urlencode($token); diff --git a/tests/Cms/Services/Dto/DtoFactoryServiceTest.php b/tests/Cms/Services/Dto/DtoFactoryServiceTest.php new file mode 100644 index 0000000..0abac6a --- /dev/null +++ b/tests/Cms/Services/Dto/DtoFactoryServiceTest.php @@ -0,0 +1,198 @@ +_testDtoPath = __DIR__ . '/../../../../src/Cms/Dtos'; + $this->_factory = new DtoFactoryService( $this->_testDtoPath ); + } + + public function testCreateRegisterUserDto(): void + { + $dto = $this->_factory->createRegisterUser(); + + $this->assertInstanceOf( Dto::class, $dto ); + $this->assertNotNull( $dto->getProperty( 'username' ) ); + $this->assertNotNull( $dto->getProperty( 'email' ) ); + $this->assertNotNull( $dto->getProperty( 'password' ) ); + $this->assertNotNull( $dto->getProperty( 'password_confirmation' ) ); + } + + public function testCreateCreateUserDto(): void + { + $dto = $this->_factory->createCreateUser(); + + $this->assertInstanceOf( Dto::class, $dto ); + $this->assertNotNull( $dto->getProperty( 'username' ) ); + $this->assertNotNull( $dto->getProperty( 'email' ) ); + $this->assertNotNull( $dto->getProperty( 'password' ) ); + $this->assertNotNull( $dto->getProperty( 'role' ) ); + } + + public function testCreateUpdateUserDto(): void + { + $dto = $this->_factory->createUpdateUser(); + + $this->assertInstanceOf( Dto::class, $dto ); + $this->assertNotNull( $dto->getProperty( 'username' ) ); + $this->assertNotNull( $dto->getProperty( 'email' ) ); + $this->assertNotNull( $dto->getProperty( 'password' ) ); + $this->assertNotNull( $dto->getProperty( 'role' ) ); + } + + public function testCreateCreateCategoryDto(): void + { + $dto = $this->_factory->createCreateCategory(); + + $this->assertInstanceOf( Dto::class, $dto ); + $this->assertNotNull( $dto->getProperty( 'name' ) ); + $this->assertNotNull( $dto->getProperty( 'slug' ) ); + $this->assertNotNull( $dto->getProperty( 'description' ) ); + } + + public function testCreateUpdateCategoryDto(): void + { + $dto = $this->_factory->createUpdateCategory(); + + $this->assertInstanceOf( Dto::class, $dto ); + $this->assertNotNull( $dto->getProperty( 'name' ) ); + $this->assertNotNull( $dto->getProperty( 'slug' ) ); + $this->assertNotNull( $dto->getProperty( 'description' ) ); + } + + public function testCreateCreatePostDto(): void + { + $dto = $this->_factory->createCreatePost(); + + $this->assertInstanceOf( Dto::class, $dto ); + $this->assertNotNull( $dto->getProperty( 'title' ) ); + $this->assertNotNull( $dto->getProperty( 'body' ) ); + $this->assertNotNull( $dto->getProperty( 'status' ) ); + $this->assertNotNull( $dto->getProperty( 'slug' ) ); + $this->assertNotNull( $dto->getProperty( 'excerpt' ) ); + $this->assertNotNull( $dto->getProperty( 'featuredImage' ) ); + $this->assertNotNull( $dto->getProperty( 'categoryIds' ) ); + $this->assertNotNull( $dto->getProperty( 'tags' ) ); + } + + public function testCreateUpdatePostDto(): void + { + $dto = $this->_factory->createUpdatePost(); + + $this->assertInstanceOf( Dto::class, $dto ); + $this->assertNotNull( $dto->getProperty( 'title' ) ); + $this->assertNotNull( $dto->getProperty( 'body' ) ); + $this->assertNotNull( $dto->getProperty( 'status' ) ); + } + + public function testSetAndGetDtoValues(): void + { + $dto = $this->_factory->createRegisterUser(); + + // Set values + $dto->username = 'testuser'; + $dto->email = 'test@example.com'; + $dto->password = 'ValidPassword123!'; + $dto->password_confirmation = 'ValidPassword123!'; + + // Get values + $this->assertEquals( 'testuser', $dto->username ); + $this->assertEquals( 'test@example.com', $dto->email ); + $this->assertEquals( 'ValidPassword123!', $dto->password ); + $this->assertEquals( 'ValidPassword123!', $dto->password_confirmation ); + } + + public function testValidationFailsForInvalidData(): void + { + $dto = $this->_factory->createRegisterUser(); + + // Set invalid username (too short) + try + { + $dto->username = 'ab'; // Less than 3 characters + $dto->validate(); + $errors = $dto->getErrors(); + + $this->assertNotEmpty( $errors ); + } + catch( \Exception $e ) + { + // Validation exception expected + $this->assertTrue( true ); + } + } + + public function testValidationPassesForValidData(): void + { + $dto = $this->_factory->createRegisterUser(); + + // Set valid data + $dto->username = 'validuser'; + $dto->email = 'valid@example.com'; + $dto->password = 'ValidPassword123!'; + $dto->password_confirmation = 'ValidPassword123!'; + + try + { + $dto->validate(); + $errors = $dto->getErrors(); + + // Should have no errors + $this->assertEmpty( $errors ); + } + catch( \Exception $e ) + { + // Should not throw exception for valid data + $this->fail( 'Validation should pass for valid data: ' . $e->getMessage() ); + } + } + + public function testCachingWorksCorrectly(): void + { + $dto1 = $this->_factory->createRegisterUser(); + $dto2 = $this->_factory->createRegisterUser(); + + // Both should be instances of Dto + $this->assertInstanceOf( Dto::class, $dto1 ); + $this->assertInstanceOf( Dto::class, $dto2 ); + + // But they should be different instances (clones) + $this->assertNotSame( $dto1, $dto2 ); + } + + public function testClearCache(): void + { + $dto1 = $this->_factory->createRegisterUser(); + $this->_factory->clearCache(); + $dto2 = $this->_factory->createRegisterUser(); + + $this->assertInstanceOf( Dto::class, $dto1 ); + $this->assertInstanceOf( Dto::class, $dto2 ); + $this->assertNotSame( $dto1, $dto2 ); + } + + public function testGetDtoDirectory(): void + { + $directory = $this->_factory->getDtoDirectory(); + $this->assertEquals( $this->_testDtoPath, $directory ); + } + + public function testCreateThrowsExceptionForNonExistentDto(): void + { + $this->expectException( \Exception::class ); + $this->expectExceptionMessageMatches( '/DTO definition file not found/' ); + + $this->_factory->create( 'NonExistent' ); + } +} diff --git a/tests/Cms/Auth/EmailVerificationManagerTest.php b/tests/Cms/Services/EmailVerifierTest.php similarity index 96% rename from tests/Cms/Auth/EmailVerificationManagerTest.php rename to tests/Cms/Services/EmailVerifierTest.php index 8f5a8fc..8d19f83 100644 --- a/tests/Cms/Auth/EmailVerificationManagerTest.php +++ b/tests/Cms/Services/EmailVerifierTest.php @@ -1,8 +1,8 @@ _verificationUrl = 'http://test.com/verify-email'; // Create manager - $this->_manager = new EmailVerificationManager( + $this->_manager = new EmailVerifier( $this->_tokenRepository, $this->_userRepository, $this->_settings, @@ -52,13 +52,13 @@ protected function setUp(): void public function testConstructorSetsProperties(): void { - $this->assertInstanceOf( EmailVerificationManager::class, $this->_manager ); + $this->assertInstanceOf( EmailVerifier::class, $this->_manager ); } public function testSetTokenExpirationMinutes(): void { $result = $this->_manager->setTokenExpirationMinutes( 120 ); - $this->assertInstanceOf( EmailVerificationManager::class, $result ); + $this->assertInstanceOf( EmailVerifier::class, $result ); } public function testSendVerificationEmail(): void diff --git a/tests/Cms/Auth/PasswordResetManagerTest.php b/tests/Cms/Services/PasswordResetterTest.php similarity index 95% rename from tests/Cms/Auth/PasswordResetManagerTest.php rename to tests/Cms/Services/PasswordResetterTest.php index 4fd29c4..c6efc83 100644 --- a/tests/Cms/Auth/PasswordResetManagerTest.php +++ b/tests/Cms/Services/PasswordResetterTest.php @@ -1,9 +1,9 @@ _resetUrl = 'http://test.com/reset-password'; // Create manager - $this->_manager = new PasswordResetManager( + $this->_manager = new PasswordResetter( $this->_tokenRepository, $this->_userRepository, $this->_passwordHasher, @@ -56,13 +56,13 @@ protected function setUp(): void public function testConstructorSetsProperties(): void { - $this->assertInstanceOf( PasswordResetManager::class, $this->_manager ); + $this->assertInstanceOf( PasswordResetter::class, $this->_manager ); } public function testSetTokenExpirationMinutes(): void { $result = $this->_manager->setTokenExpirationMinutes( 120 ); - $this->assertInstanceOf( PasswordResetManager::class, $result ); + $this->assertInstanceOf( PasswordResetter::class, $result ); } public function testRequestResetWithNonexistentUser(): void diff --git a/tests/Cms/Services/RegistrationServiceTest.php b/tests/Cms/Services/RegistrationServiceTest.php index 4c9b5fb..f078bce 100644 --- a/tests/Cms/Services/RegistrationServiceTest.php +++ b/tests/Cms/Services/RegistrationServiceTest.php @@ -2,7 +2,7 @@ namespace Tests\Cms\Services; -use Neuron\Cms\Auth\EmailVerificationManager; +use Neuron\Cms\Services\Auth\EmailVerifier; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\IUserRepository; @@ -16,7 +16,7 @@ class RegistrationServiceTest extends TestCase { private IUserRepository $_userRepository; private PasswordHasher $_passwordHasher; - private EmailVerificationManager $_verificationManager; + private EmailVerifier $_emailVerifier; private SettingManager $_settings; private Emitter $_emitter; private RegistrationService $_service; @@ -28,7 +28,7 @@ protected function setUp(): void // Create mocks $this->_userRepository = $this->createMock( IUserRepository::class ); $this->_passwordHasher = new PasswordHasher(); - $this->_verificationManager = $this->createMock( EmailVerificationManager::class ); + $this->_emailVerifier = $this->createMock( EmailVerifier::class ); $this->_emitter = $this->createMock( Emitter::class ); // Set up settings with Memory source @@ -42,7 +42,7 @@ protected function setUp(): void $this->_service = new RegistrationService( $this->_userRepository, $this->_passwordHasher, - $this->_verificationManager, + $this->_emailVerifier, $this->_settings, $this->_emitter ); @@ -69,7 +69,7 @@ public function testIsRegistrationEnabledReturnsFalse(): void $service = new RegistrationService( $this->_userRepository, $this->_passwordHasher, - $this->_verificationManager, + $this->_emailVerifier, $settings, $this->_emitter ); @@ -116,7 +116,7 @@ public function testRegisterWithValidData(): void } ); // Expect verification email to be sent - $this->_verificationManager + $this->_emailVerifier ->expects( $this->once() ) ->method( 'sendVerificationEmail' ); @@ -138,7 +138,7 @@ public function testRegisterWhenDisabled(): void $service = new RegistrationService( $this->_userRepository, $this->_passwordHasher, - $this->_verificationManager, + $this->_emailVerifier, $settings, $this->_emitter ); @@ -260,7 +260,7 @@ public function testRegisterWithoutEmailVerification(): void $service = new RegistrationService( $this->_userRepository, $this->_passwordHasher, - $this->_verificationManager, + $this->_emailVerifier, $settings, $this->_emitter ); @@ -288,7 +288,7 @@ public function testRegisterWithoutEmailVerification(): void } ); // Verification email should NOT be sent - $this->_verificationManager + $this->_emailVerifier ->expects( $this->never() ) ->method( 'sendVerificationEmail' ); diff --git a/versionlog.md b/versionlog.md index e6c46d6..54d749b 100644 --- a/versionlog.md +++ b/versionlog.md @@ -1,4 +1,5 @@ ## 0.8.8 +* Refactored requests to use the now dto powered format. ## 0.8.7 2025-11-14 * Refactored all controllers to support the new method signature. @@ -7,9 +8,7 @@ * Cleanup and minor fixes. ## 0.8.5 2025-11-12 - ## 0.8.4 2025-11-12 - ## 0.8.3 2025-11-12 * Large refactoring.