+
+
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.