diff --git a/README.md b/README.md index ea9d1c5..02814ea 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,24 @@ A modern, actively maintained user management module for Yii2. Built as a spirit composer require cgsmith/yii2-user ``` +### Optional Dependencies + +For additional features, install these packages: + +```bash +# Two-Factor Authentication (TOTP) +composer require pragmarx/google2fa bacon/bacon-qr-code + +# Social Authentication +composer require yiisoft/yii2-authclient + +# reCAPTCHA support +composer require google/recaptcha + +# hCaptcha support +composer require skrtdev/hcaptcha +``` + ### Local Development Add to your `composer.json`: @@ -108,19 +126,24 @@ return [ | Last Login Tracking | ✅ | ✅ | ✅ | | Email Change Strategies | ✅ | ✅ | ✅ | | CSRF Protection | ✅ | ✅ | ✅ | +| Two-Factor Auth (TOTP) | ❌ | ❌ | ✅ | +| Session History | ❌ | ❌ | ✅ | +| CAPTCHA Support | ❌ | ❌ | ✅ | ### Advanced Features | Feature | dektrium | usuario | cgsmith | |-------------------------|:--------:|:-------:|:-------:| -| Social Authentication | ✅ | ✅ | 🔄 v2 | -| Two-Factor Auth (2FA) | ❌ | ❌ | 🔄 v2 | -| GDPR Compliance | ❌ | ✅ | 🔄 v2 | -| Data Export | ❌ | ✅ | 🔄 v2 | -| Account Deletion | ❌ | ✅ | 🔄 v2 | +| Social Authentication | ✅ | ✅ | ✅ | +| GDPR Compliance | ❌ | ✅ | ✅ | +| GDPR Consent Management | ❌ | ❌ | ✅ | +| Data Export | ❌ | ✅ | ✅ | +| Account Deletion | ❌ | ✅ | ✅ | | User Impersonation | ✅ | ✅ | ✅ | | Gravatar Support | ✅ | ✅ | ✅ | | Avatar Upload | ❌ | ❌ | ✅ | +| RBAC Management UI | ❌ | ❌ | ✅ | +| Session Separation | ❌ | ❌ | ✅ | | Migration from dektrium | N/A | ✅ | ✅ | | Migration from usuario | N/A | N/A | ✅ | @@ -136,17 +159,19 @@ return [ ## Configuration Options +### Core Options + | Option | Type | Default | Description | |---------------------------|--------|-----------------------------------------|-------------------------------------| | `enableRegistration` | bool | `true` | Enable/disable user registration | | `enableConfirmation` | bool | `true` | Require email confirmation | | `enableUnconfirmedLogin` | bool | `false` | Allow login without confirmation | | `enablePasswordRecovery` | bool | `true` | Enable password recovery | -| `enableGdpr` | bool | `false` | Enable GDPR features (v2) | | `enableImpersonation` | bool | `true` | Enable admin impersonation | | `enableGeneratedPassword` | bool | `false` | Auto-generate passwords | | `enableGravatar` | bool | `true` | Enable Gravatar support | | `enableAvatarUpload` | bool | `true` | Enable local avatar uploads | +| `enableAccountDelete` | bool | `true` | Allow users to delete accounts | | `emailChangeStrategy` | int | `1` | Email change strategy (0-2) | | `rememberFor` | int | `1209600` | Remember me duration (seconds) | | `confirmWithin` | int | `86400` | Confirmation token expiry (seconds) | @@ -163,6 +188,298 @@ return [ | `maxAvatarSize` | int | `2097152` | Max avatar file size (bytes) | | `avatarExtensions` | array | `['jpg', 'jpeg', 'png', 'gif', 'webp']` | Allowed avatar extensions | +### GDPR Options + +| Option | Type | Default | Description | +|--------------------------------------|--------|---------|------------------------------------------| +| `enableGdpr` | bool | `false` | Enable GDPR features (export, delete) | +| `enableGdprConsent` | bool | `false` | Enable GDPR consent tracking | +| `requireGdprConsentBeforeRegistration` | bool | `true` | Require consent during registration | +| `gdprConsentVersion` | string | `'1.0'` | Current consent version | +| `gdprConsentUrl` | string | `null` | URL to privacy policy | +| `gdprExemptRoutes` | array | `[]` | Routes exempt from consent check | + +### Session Options + +| Option | Type | Default | Description | +|-------------------------|--------|-------------------|--------------------------------------| +| `enableSessionHistory` | bool | `false` | Enable session tracking | +| `sessionHistoryLimit` | int | `10` | Max sessions to track per user | +| `enableSessionSeparation` | bool | `false` | Separate frontend/backend sessions | +| `backendSessionName` | string | `'BACKENDSESSID'` | Backend session cookie name | +| `frontendSessionName` | string | `'PHPSESSID'` | Frontend session cookie name | + +### Two-Factor Authentication Options + +| Option | Type | Default | Description | +|-----------------------------|--------|---------|----------------------------------------| +| `enableTwoFactor` | bool | `false` | Enable 2FA support | +| `twoFactorIssuer` | string | `''` | Issuer name in authenticator app | +| `twoFactorBackupCodesCount` | int | `10` | Number of backup codes to generate | +| `twoFactorRequireForAdmins` | bool | `false` | Require 2FA for admin users | + +### Social Authentication Options + +| Option | Type | Default | Description | +|--------------------------|------|---------|-------------------------------------| +| `enableSocialAuth` | bool | `false` | Enable social login | +| `enableSocialRegistration` | bool | `true` | Allow registration via social | +| `enableSocialConnect` | bool | `true` | Allow linking social accounts | + +### CAPTCHA Options + +| Option | Type | Default | Description | +|-----------------------|--------|--------------|------------------------------------------| +| `enableCaptcha` | bool | `false` | Enable CAPTCHA on forms | +| `captchaType` | string | `'yii'` | Type: 'yii', 'recaptcha-v2', 'recaptcha-v3', 'hcaptcha' | +| `reCaptchaSiteKey` | string | `null` | reCAPTCHA site key | +| `reCaptchaSecretKey` | string | `null` | reCAPTCHA secret key | +| `reCaptchaV3Threshold`| float | `0.5` | reCAPTCHA v3 score threshold (0.0-1.0) | +| `hCaptchaSiteKey` | string | `null` | hCaptcha site key | +| `hCaptchaSecretKey` | string | `null` | hCaptcha secret key | +| `captchaForms` | array | `['register']` | Forms to show CAPTCHA: 'login', 'register', 'recovery' | + +### RBAC Management Options + +| Option | Type | Default | Description | +|--------------------------|--------|---------|---------------------------------------| +| `enableRbacManagement` | bool | `false` | Enable RBAC management UI | +| `rbacManagementPermission` | string | `null` | Permission required to manage RBAC | + +## Feature Documentation + +### Two-Factor Authentication (TOTP) + +Enable TOTP-based two-factor authentication with Google Authenticator, Authy, or any TOTP-compatible app. + +```php +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableTwoFactor' => true, + 'twoFactorIssuer' => 'My Application', + 'twoFactorBackupCodesCount' => 10, + 'twoFactorRequireForAdmins' => false, + ], +], +``` + +**Features:** +- QR code setup with authenticator apps +- Manual secret key entry option +- Backup codes for account recovery +- Optional enforcement for admin users +- Secure login flow interruption + +**Routes:** +- `user/settings/two-factor` - 2FA settings page +- `user/two-factor` - Verification during login + +### Social Authentication + +Enable login and registration via OAuth2 providers using yii2-authclient. + +```php +'components' => [ + 'authClientCollection' => [ + 'class' => 'yii\authclient\Collection', + 'clients' => [ + 'google' => [ + 'class' => 'yii\authclient\clients\Google', + 'clientId' => 'your-client-id', + 'clientSecret' => 'your-client-secret', + ], + 'github' => [ + 'class' => 'yii\authclient\clients\GitHub', + 'clientId' => 'your-client-id', + 'clientSecret' => 'your-client-secret', + ], + ], + ], +], +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableSocialAuth' => true, + 'enableSocialRegistration' => true, + 'enableSocialConnect' => true, + ], +], +``` + +**Features:** +- Login with social accounts +- Register new accounts via social providers +- Connect/disconnect social accounts in settings +- Link multiple social accounts to one user + +**Routes:** +- `user/auth/` - OAuth callback +- `user/settings/networks` - Manage connected accounts + +### Session History + +Track and manage active user sessions across devices. + +```php +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableSessionHistory' => true, + 'sessionHistoryLimit' => 10, + ], +], +``` + +**Features:** +- View all active sessions +- See device/browser information +- IP address and last activity tracking +- Terminate individual sessions +- Terminate all other sessions + +**Routes:** +- `user/settings/sessions` - View active sessions + +### CAPTCHA Support + +Protect forms with CAPTCHA verification. Supports Yii's built-in CAPTCHA, Google reCAPTCHA (v2 and v3), and hCaptcha. + +```php +// Using Yii's built-in CAPTCHA +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableCaptcha' => true, + 'captchaType' => 'yii', + 'captchaForms' => ['register', 'login', 'recovery'], + ], +], + +// Using reCAPTCHA v2 +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableCaptcha' => true, + 'captchaType' => 'recaptcha-v2', + 'reCaptchaSiteKey' => 'your-site-key', + 'reCaptchaSecretKey' => 'your-secret-key', + 'captchaForms' => ['register'], + ], +], + +// Using reCAPTCHA v3 (invisible) +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableCaptcha' => true, + 'captchaType' => 'recaptcha-v3', + 'reCaptchaSiteKey' => 'your-site-key', + 'reCaptchaSecretKey' => 'your-secret-key', + 'reCaptchaV3Threshold' => 0.5, + 'captchaForms' => ['register', 'login'], + ], +], + +// Using hCaptcha +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableCaptcha' => true, + 'captchaType' => 'hcaptcha', + 'hCaptchaSiteKey' => 'your-site-key', + 'hCaptchaSecretKey' => 'your-secret-key', + 'captchaForms' => ['register'], + ], +], +``` + +### GDPR Consent Management + +Track and enforce GDPR consent with version management. + +```php +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableGdprConsent' => true, + 'gdprConsentVersion' => '1.0', + 'gdprConsentUrl' => '/site/privacy', + 'requireGdprConsentBeforeRegistration' => true, + 'gdprExemptRoutes' => ['site/privacy', 'site/terms'], + ], +], +``` + +**Features:** +- Consent checkbox during registration +- Optional marketing consent +- Consent version tracking +- Force re-consent when version changes +- Exempt routes from consent check + +**Routes:** +- `user/gdpr/consent` - Consent page for existing users + +### RBAC Management UI + +Web-based interface for managing roles, permissions, and user assignments. + +```php +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableRbacManagement' => true, + 'rbacManagementPermission' => 'manageRbac', // optional + ], +], +``` + +**Features:** +- Create, edit, delete roles +- Create, edit, delete permissions +- Assign permissions to roles +- Role inheritance (child roles) +- Assign roles to users from admin panel + +**Routes:** +- `user/rbac` - RBAC overview +- `user/rbac/roles` - Manage roles +- `user/rbac/permissions` - Manage permissions +- `user/admin/assignments/` - User role assignments + +### Frontend/Backend Session Separation + +Use separate session cookies for frontend and backend applications. + +```php +// Backend configuration +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableSessionSeparation' => true, + 'backendSessionName' => 'BACKENDSESSID', + ], +], +'components' => [ + 'session' => [ + 'name' => 'BACKENDSESSID', + ], + 'user' => [ + 'class' => 'cgsmith\user\components\BackendUser', + ], +], + +// Frontend configuration (default) +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableSessionSeparation' => true, + 'frontendSessionName' => 'PHPSESSID', + ], +], +``` + ## Console Commands ```bash @@ -224,6 +541,14 @@ Available events: - `RecoveryController::EVENT_AFTER_REQUEST` - `RecoveryController::EVENT_BEFORE_RESET` - `RecoveryController::EVENT_AFTER_RESET` +- `TwoFactorController::EVENT_BEFORE_ENABLE` +- `TwoFactorController::EVENT_AFTER_ENABLE` +- `TwoFactorController::EVENT_BEFORE_DISABLE` +- `TwoFactorController::EVENT_AFTER_DISABLE` +- `SocialController::EVENT_BEFORE_CONNECT` +- `SocialController::EVENT_AFTER_CONNECT` +- `SocialController::EVENT_BEFORE_DISCONNECT` +- `SocialController::EVENT_AFTER_DISCONNECT` ## View Customization @@ -241,17 +566,6 @@ Override views by setting up theme path mapping: ], ``` -## GDPR Features (Coming in v2) - -GDPR compliance features are planned for v2. When complete, users will be able to: - -- Export all their personal data as JSON -- Request account deletion with soft-delete support -- View what data is stored about them -- Manage consent preferences - -See the [v2 Roadmap](#v2-roadmap) for more details. - ## Migration from dektrium/yii2-user 1. Install cgsmith/yii2-user @@ -322,45 +636,18 @@ class User extends \cgsmith\user\models\User } ``` -## v2 Roadmap +## Future Roadmap -The following features are planned for version 2.0: +The following features are planned for future releases: ### Authentication & Security -- [ ] **Two-Factor Authentication (2FA)** - - TOTP (Google Authenticator, Authy) - - SMS verification - - Backup codes - - Per-user 2FA enforcement - [ ] **Passwordless Authentication** - Magic link login - WebAuthn/FIDO2 support -- [ ] **Enhanced Session Management** - - View active sessions - - Remote session termination - - Device fingerprinting - -### Social Authentication - -- [ ] **OAuth2 Provider Integration** - - Google - - GitHub - - Facebook - - Apple - - Microsoft - - Custom providers via configuration -- [ ] **Account Linking** - - Link multiple social accounts - - Unlink social accounts - - Primary account selection - -### Security Hardening - -- [ ] **Brute Force Protection** +- [ ] **Enhanced Brute Force Protection** - Rate limiting per IP/user - Progressive delays - - CAPTCHA integration (reCAPTCHA v3, hCaptcha) - [ ] **Password Policies** - Password strength meter - Common password blocklist @@ -423,7 +710,7 @@ The following features are planned for version 2.0: - Automatic migration script generation for custom fields - Support for foreign key relationship preservation - Rollback support with data integrity checks - + - [ ] **Smart Migration from 2amigos/yii2-usuario** - Auto-detect custom columns added to user table - Interactive migration wizard for custom fields @@ -434,11 +721,6 @@ The following features are planned for version 2.0: ### Compliance -- [ ] **Enhanced GDPR** - - Right to be forgotten workflow - - Data retention policies - - Consent management - - Cookie consent integration - [ ] **Accessibility** - WCAG 2.1 AA compliance - Screen reader support diff --git a/codeception.yml b/codeception.yml new file mode 100644 index 0000000..55077c5 --- /dev/null +++ b/codeception.yml @@ -0,0 +1,21 @@ +actor_suffix: Tester +paths: + tests: tests + output: tests/_output + data: tests/_data + support: tests/_support +settings: + bootstrap: _bootstrap.php + colors: true + memory_limit: 1024M +extensions: + enabled: + - Codeception\Extension\RunFailed +coverage: + enabled: true + include: + - src/* + exclude: + - src/migrations/* + - src/views/* + - src/messages/* diff --git a/composer.json b/composer.json index 3703417..0287158 100644 --- a/composer.json +++ b/composer.json @@ -19,8 +19,18 @@ "yiisoft/yii2": "~2.0.0" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "phpstan/phpstan": "^1.10" + "codeception/codeception": "^5.0", + "codeception/module-asserts": "^3.0", + "codeception/module-yii2": "^1.1", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.0" + }, + "scripts": { + "test": "codecept run", + "test:unit": "codecept run unit", + "test:functional": "codecept run functional", + "phpstan": "phpstan analyse", + "lint": "@phpstan" }, "autoload": { "psr-4": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..ac1006a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,19 @@ +parameters: + level: 6 + paths: + - src + excludePaths: + - src/migrations + + ignoreErrors: + - '#Call to an undefined method yii\\web\\IdentityInterface::#' + - '#Call to an undefined method yii\\base\\Component::get[A-Za-z]+\(\)#' + + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + + yii2: + config_path: null + +includes: + - vendor/phpstan/phpstan/conf/bleedingEdge.neon diff --git a/src/Bootstrap.php b/src/Bootstrap.php index b7137a0..b9504ea 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -114,6 +114,14 @@ class Bootstrap implements BootstrapInterface return new \cgsmith\user\services\CaptchaService($module); }); + $container->setSingleton('cgsmith\user\services\TwoFactorService', function () use ($module) { + return new \cgsmith\user\services\TwoFactorService($module); + }); + + $container->setSingleton('cgsmith\user\services\SocialAuthService', function () use ($module) { + return new \cgsmith\user\services\SocialAuthService($module); + }); + // Bind module for injection $container->setSingleton(Module::class, function () use ($module) { return $module; @@ -170,6 +178,21 @@ class Bootstrap implements BootstrapInterface $rules['gdpr/consent'] = 'gdpr/consent'; } + if ($module->enableTwoFactor) { + $rules['two-factor'] = 'two-factor/verify'; + $rules['settings/two-factor'] = 'two-factor/index'; + $rules['settings/two-factor/enable'] = 'two-factor/enable'; + $rules['settings/two-factor/disable'] = 'two-factor/disable'; + $rules['settings/two-factor/backup-codes'] = 'two-factor/backup-codes'; + $rules['settings/two-factor/regenerate-backup-codes'] = 'two-factor/regenerate-backup-codes'; + } + + if ($module->enableSocialAuth) { + $rules['auth/'] = 'social/auth'; + $rules['settings/networks'] = 'social/networks'; + $rules['settings/networks/disconnect/'] = 'social/disconnect'; + } + return $rules; } } diff --git a/src/Module.php b/src/Module.php index 11449da..10dfc8b 100644 --- a/src/Module.php +++ b/src/Module.php @@ -172,6 +172,52 @@ class Module extends BaseModule implements BootstrapInterface */ public array $captchaForms = ['register']; + /** + * Whether to enable two-factor authentication. + */ + public bool $enableTwoFactor = false; + + /** + * Issuer name for TOTP (shown in authenticator app). + */ + public string $twoFactorIssuer = ''; + + /** + * Number of backup codes to generate. + */ + public int $twoFactorBackupCodesCount = 10; + + /** + * Whether to require 2FA for admin users. + */ + public bool $twoFactorRequireForAdmins = false; + + /** + * Whether to enable social network authentication. + */ + public bool $enableSocialAuth = false; + + /** + * Whether to allow registration via social networks. + */ + public bool $enableSocialRegistration = true; + + /** + * Whether to allow connecting social accounts in settings. + */ + public bool $enableSocialConnect = true; + + /** + * Whether to enable RBAC management UI. + */ + public bool $enableRbacManagement = false; + + /** + * RBAC permission name required to access RBAC management. + * If null, only admins can access RBAC management. + */ + public ?string $rbacManagementPermission = null; + /** * Email change strategy. */ @@ -426,6 +472,18 @@ class Module extends BaseModule implements BootstrapInterface return new \cgsmith\user\services\CaptchaService($this); }); + $container->setSingleton('cgsmith\user\services\TwoFactorService', function () { + return new \cgsmith\user\services\TwoFactorService($this); + }); + + $container->setSingleton('cgsmith\user\services\SocialAuthService', function () { + return new \cgsmith\user\services\SocialAuthService($this); + }); + + $container->setSingleton('cgsmith\user\services\RbacService', function () { + return new \cgsmith\user\services\RbacService($this); + }); + $container->setSingleton(Module::class, function () { return $this; }); @@ -459,6 +517,7 @@ class Module extends BaseModule implements BootstrapInterface 'admin/unblock/' => 'admin/unblock', 'admin/confirm/' => 'admin/confirm', 'admin/impersonate/' => 'admin/impersonate', + 'admin/assignments/' => 'admin/assignments', ]; if ($this->enableGdpr) { @@ -471,6 +530,33 @@ class Module extends BaseModule implements BootstrapInterface $rules['gdpr/consent'] = 'gdpr/consent'; } + if ($this->enableTwoFactor) { + $rules['two-factor'] = 'two-factor/verify'; + $rules['settings/two-factor'] = 'two-factor/index'; + $rules['settings/two-factor/enable'] = 'two-factor/enable'; + $rules['settings/two-factor/disable'] = 'two-factor/disable'; + $rules['settings/two-factor/backup-codes'] = 'two-factor/backup-codes'; + $rules['settings/two-factor/regenerate-backup-codes'] = 'two-factor/regenerate-backup-codes'; + } + + if ($this->enableSocialAuth) { + $rules['auth/'] = 'social/auth'; + $rules['settings/networks'] = 'social/networks'; + $rules['settings/networks/disconnect/'] = 'social/disconnect'; + } + + if ($this->enableRbacManagement) { + $rules['rbac'] = 'rbac/index'; + $rules['rbac/roles'] = 'rbac/roles'; + $rules['rbac/roles/create'] = 'rbac/create-role'; + $rules['rbac/roles/update/'] = 'rbac/update-role'; + $rules['rbac/roles/delete/'] = 'rbac/delete-role'; + $rules['rbac/permissions'] = 'rbac/permissions'; + $rules['rbac/permissions/create'] = 'rbac/create-permission'; + $rules['rbac/permissions/update/'] = 'rbac/update-permission'; + $rules['rbac/permissions/delete/'] = 'rbac/delete-permission'; + } + return $rules; } diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php index 19d8079..9e53778 100644 --- a/src/controllers/AdminController.php +++ b/src/controllers/AdminController.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace cgsmith\user\controllers; use cgsmith\user\filters\AccessRule; +use cgsmith\user\models\AssignmentForm; use cgsmith\user\models\User; use cgsmith\user\models\UserSearch; use cgsmith\user\Module; -use cgsmith\user\services\RegistrationService; use cgsmith\user\services\MailerService; +use cgsmith\user\services\RbacService; +use cgsmith\user\services\RegistrationService; use cgsmith\user\services\UserService; use Yii; use yii\filters\AccessControl; @@ -340,6 +342,49 @@ class AdminController extends Controller return $this->redirect(['index']); } + /** + * Manage user role assignments. + */ + public function actionAssignments(int $id): string + { + /** @var Module $module */ + $module = $this->module; + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + $user = $this->findUser($id); + + $model = new AssignmentForm(); + $model->loadAssignments($user->id); + + return $this->render('_assignments', [ + 'user' => $user, + 'model' => $model, + 'roles' => $rbacService->getRoles(), + 'module' => $module, + ]); + } + + /** + * Update user role assignments. + */ + public function actionUpdateAssignments(int $id): Response + { + $user = $this->findUser($id); + + $model = new AssignmentForm(); + $model->userId = $user->id; + + if ($model->load(Yii::$app->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Role assignments have been updated.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Failed to update role assignments.')); + } + + return $this->redirect(['assignments', 'id' => $id]); + } + /** * Find user by ID. */ diff --git a/src/controllers/RbacController.php b/src/controllers/RbacController.php new file mode 100644 index 0000000..b318fc1 --- /dev/null +++ b/src/controllers/RbacController.php @@ -0,0 +1,243 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'roles' => ['@']], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'delete-role' => ['post'], + 'delete-permission' => ['post'], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeAction($action): bool + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableRbacManagement) { + throw new NotFoundHttpException(); + } + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + /** @var User $user */ + $user = Yii::$app->user->identity; + + if (!$rbacService->canManageRbac($user)) { + throw new ForbiddenHttpException(Yii::t('user', 'You are not allowed to manage RBAC.')); + } + + return parent::beforeAction($action); + } + + /** + * RBAC overview page. + */ + public function actionIndex(): string + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + $roles = $rbacService->getRoles(); + $permissions = $rbacService->getPermissions(); + + return $this->render('index', [ + 'roles' => $roles, + 'permissions' => $permissions, + 'module' => $this->module, + ]); + } + + /** + * List roles. + */ + public function actionRoles(): string + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + return $this->render('roles', [ + 'roles' => $rbacService->getRoles(), + 'module' => $this->module, + ]); + } + + /** + * Create a new role. + */ + public function actionCreateRole(): Response|string + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + $model = new RoleForm(); + + if ($model->load(Yii::$app->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Role has been created.')); + return $this->redirect(['roles']); + } + + return $this->render('role-form', [ + 'model' => $model, + 'permissions' => $rbacService->getPermissions(), + 'roles' => $rbacService->getRoles(), + 'module' => $this->module, + 'isNew' => true, + ]); + } + + /** + * Update a role. + */ + public function actionUpdateRole(string $name): Response|string + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + $model = new RoleForm(); + if (!$model->loadRole($name)) { + throw new NotFoundHttpException(Yii::t('user', 'Role not found.')); + } + + if ($model->load(Yii::$app->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Role has been updated.')); + return $this->redirect(['roles']); + } + + return $this->render('role-form', [ + 'model' => $model, + 'permissions' => $rbacService->getPermissions(), + 'roles' => array_filter($rbacService->getRoles(), fn($r) => $r->name !== $name), + 'module' => $this->module, + 'isNew' => false, + ]); + } + + /** + * Delete a role. + */ + public function actionDeleteRole(string $name): Response + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + if ($rbacService->deleteRole($name)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Role has been deleted.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Failed to delete role.')); + } + + return $this->redirect(['roles']); + } + + /** + * List permissions. + */ + public function actionPermissions(): string + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + return $this->render('permissions', [ + 'permissions' => $rbacService->getPermissions(), + 'module' => $this->module, + ]); + } + + /** + * Create a new permission. + */ + public function actionCreatePermission(): Response|string + { + $model = new PermissionForm(); + + if ($model->load(Yii::$app->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Permission has been created.')); + return $this->redirect(['permissions']); + } + + return $this->render('permission-form', [ + 'model' => $model, + 'module' => $this->module, + 'isNew' => true, + ]); + } + + /** + * Update a permission. + */ + public function actionUpdatePermission(string $name): Response|string + { + $model = new PermissionForm(); + if (!$model->loadPermission($name)) { + throw new NotFoundHttpException(Yii::t('user', 'Permission not found.')); + } + + if ($model->load(Yii::$app->request->post()) && $model->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Permission has been updated.')); + return $this->redirect(['permissions']); + } + + return $this->render('permission-form', [ + 'model' => $model, + 'module' => $this->module, + 'isNew' => false, + ]); + } + + /** + * Delete a permission. + */ + public function actionDeletePermission(string $name): Response + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + if ($rbacService->deletePermission($name)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Permission has been deleted.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Failed to delete permission.')); + } + + return $this->redirect(['permissions']); + } +} diff --git a/src/controllers/SecurityController.php b/src/controllers/SecurityController.php index 1e2cdca..28cd3bb 100644 --- a/src/controllers/SecurityController.php +++ b/src/controllers/SecurityController.php @@ -9,6 +9,7 @@ use cgsmith\user\models\LoginForm; use cgsmith\user\models\User; use cgsmith\user\Module; use cgsmith\user\services\SessionService; +use cgsmith\user\services\TwoFactorService; use Yii; use yii\filters\AccessControl; use yii\filters\VerbFilter; @@ -71,19 +72,31 @@ class SecurityController extends Controller $event = new FormEvent(['form' => $model]); $module->trigger(self::EVENT_BEFORE_LOGIN, $event); - if ($model->load(Yii::$app->request->post()) && $model->login()) { - // Track session - if ($module->enableSessionHistory) { - /** @var SessionService $sessionService */ - $sessionService = Yii::$container->get(SessionService::class); - $sessionService->trackSession(Yii::$app->user->identity); + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + $user = $model->getUser(); + + if ($module->enableTwoFactor && $user !== null) { + /** @var TwoFactorService $twoFactorService */ + $twoFactorService = Yii::$container->get(TwoFactorService::class); + + if ($twoFactorService->isEnabled($user)) { + $twoFactorService->storePending2FAUser($user->id, $model->rememberMe); + return $this->redirect(['/' . $module->urlPrefix . '/two-factor']); + } } - // Trigger after login event - $event = new FormEvent(['form' => $model]); - $module->trigger(self::EVENT_AFTER_LOGIN, $event); + if ($model->login()) { + if ($module->enableSessionHistory) { + /** @var SessionService $sessionService */ + $sessionService = Yii::$container->get(SessionService::class); + $sessionService->trackSession(Yii::$app->user->identity); + } - return $this->goBack(); + $event = new FormEvent(['form' => $model]); + $module->trigger(self::EVENT_AFTER_LOGIN, $event); + + return $this->goBack(); + } } return $this->render('login', [ diff --git a/src/controllers/SocialController.php b/src/controllers/SocialController.php new file mode 100644 index 0000000..f94544f --- /dev/null +++ b/src/controllers/SocialController.php @@ -0,0 +1,150 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'actions' => ['auth'], 'roles' => ['?', '@']], + ['allow' => true, 'actions' => ['networks', 'connect', 'disconnect'], 'roles' => ['@']], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'disconnect' => ['post'], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function actions(): array + { + return [ + 'auth' => [ + 'class' => AuthAction::class, + 'successCallback' => [$this, 'onAuthSuccess'], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeAction($action): bool + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableSocialAuth) { + throw new NotFoundHttpException(); + } + + return parent::beforeAction($action); + } + + /** + * Handle successful authentication. + */ + public function onAuthSuccess($client): Response + { + /** @var Module $module */ + $module = $this->module; + + /** @var SocialAuthService $socialAuthService */ + $socialAuthService = Yii::$container->get(SocialAuthService::class); + + $user = $socialAuthService->handleCallback($client); + + if ($user !== null) { + if ($module->enableSessionHistory) { + /** @var \cgsmith\user\services\SessionService $sessionService */ + $sessionService = Yii::$container->get(\cgsmith\user\services\SessionService::class); + $sessionService->trackSession($user); + } + + return $this->goBack(); + } + + Yii::$app->session->setFlash('danger', Yii::t('user', 'Unable to complete authentication.')); + return $this->redirect(['/' . $module->urlPrefix . '/login']); + } + + /** + * Display connected networks. + */ + public function actionNetworks(): string + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableSocialConnect) { + return $this->redirect(['settings/account']); + } + + /** @var User $user */ + $user = Yii::$app->user->identity; + + /** @var SocialAuthService $socialAuthService */ + $socialAuthService = Yii::$container->get(SocialAuthService::class); + + $connectedAccounts = $socialAuthService->getConnectedAccounts($user); + $availableClients = $socialAuthService->getAuthClients(); + + $connectedProviders = array_map(fn($account) => $account->provider, $connectedAccounts); + + return $this->render('networks', [ + 'connectedAccounts' => $connectedAccounts, + 'availableClients' => $availableClients, + 'connectedProviders' => $connectedProviders, + 'module' => $module, + ]); + } + + /** + * Disconnect a social account. + */ + public function actionDisconnect(int $id): Response + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + /** @var SocialAuthService $socialAuthService */ + $socialAuthService = Yii::$container->get(SocialAuthService::class); + + if ($socialAuthService->disconnect($user, $id)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Account disconnected successfully.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Unable to disconnect account.')); + } + + return $this->redirect(['networks']); + } +} diff --git a/src/controllers/TwoFactorController.php b/src/controllers/TwoFactorController.php new file mode 100644 index 0000000..65816c7 --- /dev/null +++ b/src/controllers/TwoFactorController.php @@ -0,0 +1,248 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'actions' => ['verify'], 'roles' => ['?', '@']], + ['allow' => true, 'roles' => ['@']], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'enable' => ['post'], + 'disable' => ['post'], + 'regenerate-backup-codes' => ['post'], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeAction($action): bool + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableTwoFactor) { + throw new NotFoundHttpException(); + } + + return parent::beforeAction($action); + } + + /** + * 2FA settings page. + */ + public function actionIndex(): string + { + /** @var Module $module */ + $module = $this->module; + + /** @var User $user */ + $user = Yii::$app->user->identity; + + /** @var TwoFactorService $twoFactorService */ + $twoFactorService = Yii::$container->get(TwoFactorService::class); + + $isEnabled = $twoFactorService->isEnabled($user); + $backupCodesCount = $twoFactorService->getBackupCodesCount($user); + + $setupForm = null; + $secret = null; + $qrCodeDataUri = null; + + if (!$isEnabled) { + $secret = $twoFactorService->generateSecret(); + $qrCodeDataUri = $twoFactorService->getQrCodeDataUri($user, $secret); + $setupForm = new TwoFactorSetupForm(['secret' => $secret]); + } + + return $this->render('index', [ + 'module' => $module, + 'isEnabled' => $isEnabled, + 'backupCodesCount' => $backupCodesCount, + 'setupForm' => $setupForm, + 'secret' => $secret, + 'qrCodeDataUri' => $qrCodeDataUri, + ]); + } + + /** + * Enable 2FA. + */ + public function actionEnable(): Response + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + /** @var TwoFactorService $twoFactorService */ + $twoFactorService = Yii::$container->get(TwoFactorService::class); + + $model = new TwoFactorSetupForm(); + + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + if ($twoFactorService->enable($user, $model->secret, $model->code)) { + $twoFactor = $twoFactorService->getTwoFactor($user); + Yii::$app->session->setFlash('success', Yii::t('user', 'Two-factor authentication has been enabled.')); + Yii::$app->session->setFlash('backup_codes', $twoFactor->backup_codes); + return $this->redirect(['backup-codes']); + } + + Yii::$app->session->setFlash('danger', Yii::t('user', 'Invalid verification code. Please try again.')); + } + + return $this->redirect(['index']); + } + + /** + * Disable 2FA. + */ + public function actionDisable(): Response + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + /** @var TwoFactorService $twoFactorService */ + $twoFactorService = Yii::$container->get(TwoFactorService::class); + + if ($twoFactorService->disable($user)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Two-factor authentication has been disabled.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Failed to disable two-factor authentication.')); + } + + return $this->redirect(['index']); + } + + /** + * Display backup codes. + */ + public function actionBackupCodes(): Response|string + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + /** @var TwoFactorService $twoFactorService */ + $twoFactorService = Yii::$container->get(TwoFactorService::class); + + if (!$twoFactorService->isEnabled($user)) { + return $this->redirect(['index']); + } + + $backupCodes = Yii::$app->session->getFlash('backup_codes'); + $twoFactor = $twoFactorService->getTwoFactor($user); + + return $this->render('backup-codes', [ + 'backupCodes' => $backupCodes, + 'backupCodesCount' => count($twoFactor->backup_codes ?? []), + 'module' => $this->module, + ]); + } + + /** + * Regenerate backup codes. + */ + public function actionRegenerateBackupCodes(): Response + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + /** @var TwoFactorService $twoFactorService */ + $twoFactorService = Yii::$container->get(TwoFactorService::class); + + $codes = $twoFactorService->regenerateBackupCodes($user); + + if ($codes !== null) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Backup codes have been regenerated.')); + Yii::$app->session->setFlash('backup_codes', $codes); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Failed to regenerate backup codes.')); + } + + return $this->redirect(['backup-codes']); + } + + /** + * Verify 2FA during login. + */ + public function actionVerify(): Response|string + { + /** @var Module $module */ + $module = $this->module; + + /** @var TwoFactorService $twoFactorService */ + $twoFactorService = Yii::$container->get(TwoFactorService::class); + + $userId = $twoFactorService->getPending2FAUserId(); + + if ($userId === null) { + return $this->redirect(['/' . $module->urlPrefix . '/login']); + } + + $user = User::findOne($userId); + if ($user === null) { + $twoFactorService->clearPending2FA(); + return $this->redirect(['/' . $module->urlPrefix . '/login']); + } + + $model = new TwoFactorForm(); + + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + if ($twoFactorService->verify($user, $model->code)) { + $twoFactorService->clearPending2FA(); + + $rememberMe = $twoFactorService->getPending2FARememberMe(); + $duration = $rememberMe ? $module->rememberFor : 0; + + if (Yii::$app->user->login($user, $duration)) { + $user->updateLastLogin(); + + if ($module->enableSessionHistory) { + /** @var \cgsmith\user\services\SessionService $sessionService */ + $sessionService = Yii::$container->get(\cgsmith\user\services\SessionService::class); + $sessionService->trackSession($user); + } + + return $this->goBack(); + } + } + + $model->addError('code', Yii::t('user', 'Invalid verification code.')); + } + + return $this->render('verify', [ + 'model' => $model, + 'module' => $module, + ]); + } +} diff --git a/src/events/SocialAuthEvent.php b/src/events/SocialAuthEvent.php new file mode 100644 index 0000000..1c18a7c --- /dev/null +++ b/src/events/SocialAuthEvent.php @@ -0,0 +1,26 @@ + 'Add uppercase letters.', 'Add numbers.' => 'Add numbers.', 'Add special characters.' => 'Add special characters.', + + // Session History + 'Session History' => 'Session History', + 'Sessions' => 'Sessions', + 'Active Sessions' => 'Active Sessions', + 'Your active sessions are listed below. You can terminate any session you don\'t recognize.' => 'Your active sessions are listed below. You can terminate any session you don\'t recognize.', + 'Current Session' => 'Current Session', + 'Device' => 'Device', + 'IP Address' => 'IP Address', + 'Last Activity' => 'Last Activity', + 'Started' => 'Started', + 'Terminate' => 'Terminate', + 'Terminate All Other Sessions' => 'Terminate All Other Sessions', + 'Are you sure you want to terminate this session?' => 'Are you sure you want to terminate this session?', + 'Are you sure you want to terminate all other sessions?' => 'Are you sure you want to terminate all other sessions?', + 'Session has been terminated.' => 'Session has been terminated.', + 'All other sessions have been terminated.' => 'All other sessions have been terminated.', + 'Failed to terminate session.' => 'Failed to terminate session.', + 'Failed to terminate sessions.' => 'Failed to terminate sessions.', + 'No other active sessions.' => 'No other active sessions.', + 'Unknown Device' => 'Unknown Device', + + // GDPR Consent + 'Privacy Consent' => 'Privacy Consent', + 'Consent Required' => 'Consent Required', + 'We need your consent to continue.' => 'We need your consent to continue.', + 'I agree to the privacy policy' => 'I agree to the privacy policy', + 'I agree to receive marketing communications (optional)' => 'I agree to receive marketing communications (optional)', + 'Read the privacy policy' => 'Read the privacy policy', + 'You must agree to the privacy policy to continue.' => 'You must agree to the privacy policy to continue.', + 'Thank you for providing your consent.' => 'Thank you for providing your consent.', + 'Please review our updated privacy policy.' => 'Please review our updated privacy policy.', + 'Submit' => 'Submit', + + // CAPTCHA + 'CAPTCHA' => 'CAPTCHA', + 'Verification Code' => 'Verification Code', + 'Please complete the CAPTCHA verification.' => 'Please complete the CAPTCHA verification.', + 'Invalid CAPTCHA verification.' => 'Invalid CAPTCHA verification.', + 'The verification code is incorrect.' => 'The verification code is incorrect.', + + // Two-Factor Authentication + 'Two-Factor Authentication' => 'Two-Factor Authentication', + 'Two-Factor Verification' => 'Two-Factor Verification', + 'Enable Two-Factor Authentication' => 'Enable Two-Factor Authentication', + 'Disable Two-Factor Authentication' => 'Disable Two-Factor Authentication', + 'Two-factor authentication adds an extra layer of security to your account.' => 'Two-factor authentication adds an extra layer of security to your account.', + 'Scan the QR code with your authenticator app, then enter the code below.' => 'Scan the QR code with your authenticator app, then enter the code below.', + 'Enter the code from your authenticator app.' => 'Enter the code from your authenticator app.', + 'Authentication Code' => 'Authentication Code', + 'Verify Code' => 'Verify Code', + 'Verify' => 'Verify', + 'Enable' => 'Enable', + 'Disable' => 'Disable', + 'Enabled' => 'Enabled', + 'Disabled' => 'Disabled', + 'Invalid verification code.' => 'Invalid verification code.', + 'Two-factor authentication has been enabled.' => 'Two-factor authentication has been enabled.', + 'Two-factor authentication has been disabled.' => 'Two-factor authentication has been disabled.', + 'Failed to enable two-factor authentication.' => 'Failed to enable two-factor authentication.', + 'Failed to disable two-factor authentication.' => 'Failed to disable two-factor authentication.', + 'Backup Codes' => 'Backup Codes', + 'Regenerate Backup Codes' => 'Regenerate Backup Codes', + 'Your backup codes are listed below. Each code can only be used once.' => 'Your backup codes are listed below. Each code can only be used once.', + 'Store these codes in a safe place. You can use them to access your account if you lose your authenticator device.' => 'Store these codes in a safe place. You can use them to access your account if you lose your authenticator device.', + 'New backup codes have been generated.' => 'New backup codes have been generated.', + 'Use a backup code' => 'Use a backup code', + 'Use authenticator code' => 'Use authenticator code', + 'Two-factor authentication is required for your account.' => 'Two-factor authentication is required for your account.', + 'Set up two-factor authentication' => 'Set up two-factor authentication', + 'You cannot log in without a valid authenticator code.' => 'You cannot log in without a valid authenticator code.', + "Can't use your authenticator app?" => "Can't use your authenticator app?", + 'Back to login' => 'Back to login', + 'If you can\'t access your authenticator app, enter one of your backup codes.' => 'If you can\'t access your authenticator app, enter one of your backup codes.', + 'Secret Key' => 'Secret Key', + 'If you cannot scan the QR code, enter this key manually in your authenticator app:' => 'If you cannot scan the QR code, enter this key manually in your authenticator app:', + + // Social Authentication + 'Social Networks' => 'Social Networks', + 'Connected Networks' => 'Connected Networks', + 'Connect Social Account' => 'Connect Social Account', + 'Disconnect' => 'Disconnect', + 'Connect' => 'Connect', + 'Connected' => 'Connected', + 'Not connected' => 'Not connected', + 'No social accounts connected.' => 'No social accounts connected.', + 'Social account has been connected.' => 'Social account has been connected.', + 'Social account has been disconnected.' => 'Social account has been disconnected.', + 'Failed to connect social account.' => 'Failed to connect social account.', + 'Failed to disconnect social account.' => 'Failed to disconnect social account.', + 'This social account is already connected to another user.' => 'This social account is already connected to another user.', + 'Sign in with {provider}' => 'Sign in with {provider}', + 'Connect {provider}' => 'Connect {provider}', + 'Connect your social accounts to enable quick sign in.' => 'Connect your social accounts to enable quick sign in.', + 'Complete Registration' => 'Complete Registration', + 'Please complete your registration.' => 'Please complete your registration.', + 'Social registration is disabled.' => 'Social registration is disabled.', + + // RBAC Management + 'RBAC Management' => 'RBAC Management', + 'Roles' => 'Roles', + 'Permissions' => 'Permissions', + 'Manage Roles' => 'Manage Roles', + 'Manage Permissions' => 'Manage Permissions', + 'Create Role' => 'Create Role', + 'Update Role' => 'Update Role', + 'Create Permission' => 'Create Permission', + 'Update Permission' => 'Update Permission', + 'Role' => 'Role', + 'Permission' => 'Permission', + 'Description' => 'Description', + 'Child Roles' => 'Child Roles', + 'No roles have been created yet.' => 'No roles have been created yet.', + 'No permissions have been created yet.' => 'No permissions have been created yet.', + 'Role has been created.' => 'Role has been created.', + 'Role has been updated.' => 'Role has been updated.', + 'Role has been deleted.' => 'Role has been deleted.', + 'Permission has been created.' => 'Permission has been created.', + 'Permission has been updated.' => 'Permission has been updated.', + 'Permission has been deleted.' => 'Permission has been deleted.', + 'Failed to delete role.' => 'Failed to delete role.', + 'Failed to delete permission.' => 'Failed to delete permission.', + 'Are you sure you want to delete this role?' => 'Are you sure you want to delete this role?', + 'Are you sure you want to delete this permission?' => 'Are you sure you want to delete this permission?', + 'Role not found.' => 'Role not found.', + 'Permission not found.' => 'Permission not found.', + 'A role with this name already exists.' => 'A role with this name already exists.', + 'A permission with this name already exists.' => 'A permission with this name already exists.', + 'Name can only contain letters, numbers, underscores, and hyphens.' => 'Name can only contain letters, numbers, underscores, and hyphens.', + 'Name can only contain letters, numbers, underscores, hyphens, and dots.' => 'Name can only contain letters, numbers, underscores, hyphens, and dots.', + 'Permission names can contain letters, numbers, underscores, hyphens, and dots.' => 'Permission names can contain letters, numbers, underscores, hyphens, and dots.', + 'This role will inherit all permissions from selected child roles.' => 'This role will inherit all permissions from selected child roles.', + 'You are not allowed to manage RBAC.' => 'You are not allowed to manage RBAC.', + 'Edit' => 'Edit', + 'Actions' => 'Actions', + + // Role Assignments + 'Assignments' => 'Assignments', + 'Assigned Roles' => 'Assigned Roles', + 'Update Assignments' => 'Update Assignments', + 'Role assignments have been updated.' => 'Role assignments have been updated.', + 'Failed to update role assignments.' => 'Failed to update role assignments.', + 'Select the roles you want to assign to this user.' => 'Select the roles you want to assign to this user.', + 'No roles available.' => 'No roles available.', + 'You can assign multiple roles or permissions to user by using the form below' => 'You can assign multiple roles or permissions to user by using the form below', + 'RBAC is not configured. Configure authManager in your application to use this feature.' => 'RBAC is not configured. Configure authManager in your application to use this feature.', + 'Create roles' => 'Create roles', + + // Admin Password Reset + 'Resend Password' => 'Resend Password', + 'New password has been generated and sent to user.' => 'New password has been generated and sent to user.', + 'An error occurred while generating the password.' => 'An error occurred while generating the password.', + 'An error occurred while deleting the user.' => 'An error occurred while deleting the user.', + 'An error occurred while blocking the user.' => 'An error occurred while blocking the user.', + 'An error occurred while unblocking the user.' => 'An error occurred while unblocking the user.', + 'An error occurred while confirming the user.' => 'An error occurred while confirming the user.', + 'Profile has been updated.' => 'Profile has been updated.', ]; diff --git a/src/models/AssignmentForm.php b/src/models/AssignmentForm.php new file mode 100644 index 0000000..8cd129d --- /dev/null +++ b/src/models/AssignmentForm.php @@ -0,0 +1,70 @@ + ['string']], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'userId' => Yii::t('user', 'User'), + 'roles' => Yii::t('user', 'Roles'), + ]; + } + + /** + * Load current assignments for a user. + */ + public function loadAssignments(int $userId): void + { + $this->userId = $userId; + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + $userRoles = $rbacService->getUserRoles($userId); + $this->roles = array_keys($userRoles); + } + + /** + * Save the assignments. + */ + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + return $rbacService->updateUserRoles($this->userId, $this->roles); + } +} diff --git a/src/models/PermissionForm.php b/src/models/PermissionForm.php new file mode 100644 index 0000000..d0608d1 --- /dev/null +++ b/src/models/PermissionForm.php @@ -0,0 +1,101 @@ + 64], + ['name', 'match', 'pattern' => '/^[\w\-\.]+$/', 'message' => Yii::t('user', 'Name can only contain letters, numbers, underscores, hyphens, and dots.')], + ['name', 'validateUniqueName'], + ['description', 'string', 'max' => 255], + ]; + } + + /** + * Validate that the name is unique. + */ + public function validateUniqueName(string $attribute): void + { + if ($this->name === $this->originalName) { + return; + } + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + if ($rbacService->getPermission($this->name) !== null) { + $this->addError($attribute, Yii::t('user', 'A permission with this name already exists.')); + } + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'name' => Yii::t('user', 'Name'), + 'description' => Yii::t('user', 'Description'), + ]; + } + + /** + * Load from an existing permission. + */ + public function loadPermission(string $name): bool + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + $permission = $rbacService->getPermission($name); + if ($permission === null) { + return false; + } + + $this->originalName = $name; + $this->name = $permission->name; + $this->description = $permission->description; + + return true; + } + + /** + * Save the permission. + */ + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + if ($this->originalName !== null) { + return $rbacService->updatePermission($this->originalName, $this->name, $this->description); + } + + return $rbacService->createPermission($this->name, $this->description) !== null; + } +} diff --git a/src/models/RoleForm.php b/src/models/RoleForm.php new file mode 100644 index 0000000..8769358 --- /dev/null +++ b/src/models/RoleForm.php @@ -0,0 +1,155 @@ + 64], + ['name', 'match', 'pattern' => '/^[\w\-]+$/', 'message' => Yii::t('user', 'Name can only contain letters, numbers, underscores, and hyphens.')], + ['name', 'validateUniqueName'], + ['description', 'string', 'max' => 255], + [['permissions', 'childRoles'], 'each', 'rule' => ['string']], + ]; + } + + /** + * Validate that the name is unique. + */ + public function validateUniqueName(string $attribute): void + { + if ($this->name === $this->originalName) { + return; + } + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + if ($rbacService->getRole($this->name) !== null) { + $this->addError($attribute, Yii::t('user', 'A role with this name already exists.')); + } + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'name' => Yii::t('user', 'Name'), + 'description' => Yii::t('user', 'Description'), + 'permissions' => Yii::t('user', 'Permissions'), + 'childRoles' => Yii::t('user', 'Child Roles'), + ]; + } + + /** + * Load from an existing role. + */ + public function loadRole(string $name): bool + { + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + + $role = $rbacService->getRole($name); + if ($role === null) { + return false; + } + + $this->originalName = $name; + $this->name = $role->name; + $this->description = $role->description; + + $children = $rbacService->getChildren($name); + foreach ($children as $child) { + if ($child->type === \yii\rbac\Item::TYPE_ROLE) { + $this->childRoles[] = $child->name; + } else { + $this->permissions[] = $child->name; + } + } + + return true; + } + + /** + * Save the role. + */ + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + /** @var RbacService $rbacService */ + $rbacService = Yii::$container->get(RbacService::class); + $authManager = $rbacService->getAuthManager(); + + if ($authManager === null) { + return false; + } + + if ($this->originalName !== null) { + if (!$rbacService->updateRole($this->originalName, $this->name, $this->description)) { + return false; + } + } else { + if ($rbacService->createRole($this->name, $this->description) === null) { + return false; + } + } + + $role = $rbacService->getRole($this->name); + if ($role === null) { + return false; + } + + $authManager->removeChildren($role); + + foreach ($this->permissions as $permissionName) { + $permission = $authManager->getPermission($permissionName); + if ($permission !== null) { + try { + $authManager->addChild($role, $permission); + } catch (\Exception $e) { + // Skip if already exists + } + } + } + + foreach ($this->childRoles as $childRoleName) { + $childRole = $authManager->getRole($childRoleName); + if ($childRole !== null && $childRole->name !== $role->name) { + try { + $authManager->addChild($role, $childRole); + } catch (\Exception $e) { + // Skip if already exists or would cause loop + } + } + } + + return true; + } +} diff --git a/src/models/SocialAccount.php b/src/models/SocialAccount.php new file mode 100644 index 0000000..39b4982 --- /dev/null +++ b/src/models/SocialAccount.php @@ -0,0 +1,128 @@ + 255], + [['provider_id'], 'string', 'max' => 255], + [['email'], 'string', 'max' => 255], + [['username'], 'string', 'max' => 255], + [['data'], 'safe'], + [['user_id'], 'exist', 'targetClass' => User::class, 'targetAttribute' => 'id', 'skipOnEmpty' => true], + [['provider', 'provider_id'], 'unique', 'targetAttribute' => ['provider', 'provider_id']], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'id' => Yii::t('user', 'ID'), + 'user_id' => Yii::t('user', 'User'), + 'provider' => Yii::t('user', 'Provider'), + 'provider_id' => Yii::t('user', 'Provider ID'), + 'email' => Yii::t('user', 'Email'), + 'username' => Yii::t('user', 'Username'), + 'data' => Yii::t('user', 'Data'), + 'created_at' => Yii::t('user', 'Connected At'), + ]; + } + + /** + * Get user relation. + */ + public function getUser(): ActiveQuery + { + return $this->hasOne(User::class, ['id' => 'user_id']); + } + + /** + * Get decoded data. + */ + public function getDecodedData(): array + { + if (empty($this->data)) { + return []; + } + + return is_array($this->data) ? $this->data : (json_decode($this->data, true) ?: []); + } + + /** + * Check if this account is connected to a user. + */ + public function getIsConnected(): bool + { + return $this->user_id !== null; + } + + /** + * Connect this account to a user. + */ + public function connect(User $user): bool + { + $this->user_id = $user->id; + return $this->save(false, ['user_id']); + } + + /** + * Find account by provider and client ID. + */ + public static function findByProviderAndId(string $provider, string $providerId): ?self + { + return static::find() + ->byProvider($provider) + ->byProviderId($providerId) + ->one(); + } +} diff --git a/src/models/TwoFactor.php b/src/models/TwoFactor.php new file mode 100644 index 0000000..c3d33d7 --- /dev/null +++ b/src/models/TwoFactor.php @@ -0,0 +1,119 @@ + 64], + [['user_id'], 'unique'], + [['user_id'], 'exist', 'targetClass' => User::class, 'targetAttribute' => 'id'], + [['backup_codes'], 'safe'], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'id' => Yii::t('user', 'ID'), + 'user_id' => Yii::t('user', 'User'), + 'secret' => Yii::t('user', 'Secret'), + 'enabled_at' => Yii::t('user', 'Enabled At'), + 'backup_codes' => Yii::t('user', 'Backup Codes'), + 'created_at' => Yii::t('user', 'Created At'), + 'updated_at' => Yii::t('user', 'Updated At'), + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeSave($insert): bool + { + if (!parent::beforeSave($insert)) { + return false; + } + + if (is_array($this->backup_codes)) { + $this->backup_codes = json_encode($this->backup_codes); + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function afterFind(): void + { + parent::afterFind(); + + if (is_string($this->backup_codes)) { + $this->backup_codes = json_decode($this->backup_codes, true) ?: []; + } + } + + /** + * Get user relation. + */ + public function getUser(): ActiveQuery + { + return $this->hasOne(User::class, ['id' => 'user_id']); + } + + /** + * Check if 2FA is enabled. + */ + public function getIsEnabled(): bool + { + return $this->enabled_at !== null; + } +} diff --git a/src/models/TwoFactorForm.php b/src/models/TwoFactorForm.php new file mode 100644 index 0000000..667721d --- /dev/null +++ b/src/models/TwoFactorForm.php @@ -0,0 +1,38 @@ + 6, 'max' => 10], + ['code', 'match', 'pattern' => '/^[0-9a-zA-Z]+$/', 'message' => Yii::t('user', 'Invalid code format.')], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'code' => Yii::t('user', 'Verification Code'), + ]; + } +} diff --git a/src/models/TwoFactorSetupForm.php b/src/models/TwoFactorSetupForm.php new file mode 100644 index 0000000..79225e5 --- /dev/null +++ b/src/models/TwoFactorSetupForm.php @@ -0,0 +1,42 @@ + 6], + ['code', 'match', 'pattern' => '/^[0-9]+$/', 'message' => Yii::t('user', 'Code must be 6 digits.')], + ['secret', 'required'], + ['secret', 'string'], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'code' => Yii::t('user', 'Verification Code'), + 'secret' => Yii::t('user', 'Secret Key'), + ]; + } +} diff --git a/src/models/User.php b/src/models/User.php index 1a9445b..bc82814 100644 --- a/src/models/User.php +++ b/src/models/User.php @@ -43,6 +43,8 @@ use yii\web\IdentityInterface; * @property-read Profile $profile * @property-read Token[] $tokens * @property-read Session[] $sessions + * @property-read TwoFactor|null $twoFactor + * @property-read SocialAccount[] $socialAccounts */ class User extends ActiveRecord implements IdentityInterface, UserInterface { @@ -273,6 +275,22 @@ class User extends ActiveRecord implements IdentityInterface, UserInterface return $this->hasMany(Session::class, ['user_id' => 'id']); } + /** + * Get user two-factor authentication relation. + */ + public function getTwoFactor(): ActiveQuery + { + return $this->hasOne(TwoFactor::class, ['user_id' => 'id']); + } + + /** + * Get user social accounts relation. + */ + public function getSocialAccounts(): ActiveQuery + { + return $this->hasMany(SocialAccount::class, ['user_id' => 'id']); + } + // Helper methods /** diff --git a/src/models/query/SocialAccountQuery.php b/src/models/query/SocialAccountQuery.php new file mode 100644 index 0000000..b6515e3 --- /dev/null +++ b/src/models/query/SocialAccountQuery.php @@ -0,0 +1,57 @@ +andWhere(['user_id' => $userId]); + } + + /** + * Filter by provider. + */ + public function byProvider(string $provider): self + { + return $this->andWhere(['provider' => $provider]); + } + + /** + * Filter by provider ID. + */ + public function byProviderId(string $providerId): self + { + return $this->andWhere(['provider_id' => $providerId]); + } + + /** + * Filter connected accounts only. + */ + public function connected(): self + { + return $this->andWhere(['not', ['user_id' => null]]); + } + + /** + * Filter unconnected accounts only. + */ + public function unconnected(): self + { + return $this->andWhere(['user_id' => null]); + } +} diff --git a/src/models/query/TwoFactorQuery.php b/src/models/query/TwoFactorQuery.php new file mode 100644 index 0000000..3cb9bfa --- /dev/null +++ b/src/models/query/TwoFactorQuery.php @@ -0,0 +1,33 @@ +andWhere(['user_id' => $userId]); + } + + /** + * Filter enabled only. + */ + public function enabled(): self + { + return $this->andWhere(['not', ['enabled_at' => null]]); + } +} diff --git a/src/services/RbacService.php b/src/services/RbacService.php new file mode 100644 index 0000000..329a8b8 --- /dev/null +++ b/src/services/RbacService.php @@ -0,0 +1,379 @@ +authManager; + } + + /** + * Get all roles. + * + * @return Role[] + */ + public function getRoles(): array + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return []; + } + + return $authManager->getRoles(); + } + + /** + * Get a role by name. + */ + public function getRole(string $name): ?Role + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return null; + } + + return $authManager->getRole($name); + } + + /** + * Create a new role. + */ + public function createRole(string $name, ?string $description = null): ?Role + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return null; + } + + $role = $authManager->createRole($name); + $role->description = $description; + + if ($authManager->add($role)) { + return $role; + } + + return null; + } + + /** + * Update a role. + */ + public function updateRole(string $name, string $newName, ?string $description = null): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $role = $authManager->getRole($name); + if ($role === null) { + return false; + } + + $role->name = $newName; + $role->description = $description; + + return $authManager->update($name, $role); + } + + /** + * Delete a role. + */ + public function deleteRole(string $name): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $role = $authManager->getRole($name); + if ($role === null) { + return false; + } + + return $authManager->remove($role); + } + + /** + * Get all permissions. + * + * @return Permission[] + */ + public function getPermissions(): array + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return []; + } + + return $authManager->getPermissions(); + } + + /** + * Get a permission by name. + */ + public function getPermission(string $name): ?Permission + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return null; + } + + return $authManager->getPermission($name); + } + + /** + * Create a new permission. + */ + public function createPermission(string $name, ?string $description = null): ?Permission + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return null; + } + + $permission = $authManager->createPermission($name); + $permission->description = $description; + + if ($authManager->add($permission)) { + return $permission; + } + + return null; + } + + /** + * Update a permission. + */ + public function updatePermission(string $name, string $newName, ?string $description = null): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $permission = $authManager->getPermission($name); + if ($permission === null) { + return false; + } + + $permission->name = $newName; + $permission->description = $description; + + return $authManager->update($name, $permission); + } + + /** + * Delete a permission. + */ + public function deletePermission(string $name): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $permission = $authManager->getPermission($name); + if ($permission === null) { + return false; + } + + return $authManager->remove($permission); + } + + /** + * Get roles assigned to a user. + * + * @return Role[] + */ + public function getUserRoles(int $userId): array + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return []; + } + + return $authManager->getRolesByUser($userId); + } + + /** + * Get permissions assigned directly to a user. + * + * @return Permission[] + */ + public function getUserPermissions(int $userId): array + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return []; + } + + return $authManager->getPermissionsByUser($userId); + } + + /** + * Assign a role to a user. + */ + public function assignRole(int $userId, string $roleName): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $role = $authManager->getRole($roleName); + if ($role === null) { + return false; + } + + try { + $authManager->assign($role, $userId); + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Revoke a role from a user. + */ + public function revokeRole(int $userId, string $roleName): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $role = $authManager->getRole($roleName); + if ($role === null) { + return false; + } + + return $authManager->revoke($role, $userId); + } + + /** + * Update user role assignments. + */ + public function updateUserRoles(int $userId, array $roleNames): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $authManager->revokeAll($userId); + + foreach ($roleNames as $roleName) { + $role = $authManager->getRole($roleName); + if ($role !== null) { + try { + $authManager->assign($role, $userId); + } catch (\Exception $e) { + // Role already assigned, skip + } + } + } + + return true; + } + + /** + * Add child item to a role. + */ + public function addChild(string $parentName, string $childName): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $parent = $authManager->getRole($parentName); + if ($parent === null) { + return false; + } + + $child = $authManager->getRole($childName) ?? $authManager->getPermission($childName); + if ($child === null) { + return false; + } + + try { + return $authManager->addChild($parent, $child); + } catch (\Exception $e) { + return false; + } + } + + /** + * Remove child item from a role. + */ + public function removeChild(string $parentName, string $childName): bool + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return false; + } + + $parent = $authManager->getRole($parentName); + if ($parent === null) { + return false; + } + + $child = $authManager->getRole($childName) ?? $authManager->getPermission($childName); + if ($child === null) { + return false; + } + + return $authManager->removeChild($parent, $child); + } + + /** + * Get children of an item. + * + * @return Item[] + */ + public function getChildren(string $name): array + { + $authManager = $this->getAuthManager(); + if ($authManager === null) { + return []; + } + + return $authManager->getChildren($name); + } + + /** + * Check if user can manage RBAC. + */ + public function canManageRbac(User $user): bool + { + if ($this->module->rbacManagementPermission !== null && $this->getAuthManager() !== null) { + return $this->getAuthManager()->checkAccess($user->id, $this->module->rbacManagementPermission); + } + + return $user->getIsAdmin(); + } +} diff --git a/src/services/SocialAuthService.php b/src/services/SocialAuthService.php new file mode 100644 index 0000000..7677aec --- /dev/null +++ b/src/services/SocialAuthService.php @@ -0,0 +1,306 @@ +getUserAttributes(); + $provider = $client->getId(); + $providerId = (string) ($attributes['id'] ?? ''); + + if (empty($providerId)) { + return null; + } + + $account = SocialAccount::findByProviderAndId($provider, $providerId); + + if ($account !== null && $account->user !== null) { + return $this->login($account, $client); + } + + if (!Yii::$app->user->isGuest) { + return $this->connect(Yii::$app->user->identity, $client, $attributes); + } + + if (!$this->module->enableSocialRegistration) { + return null; + } + + return $this->register($client, $attributes); + } + + /** + * Login via social account. + */ + protected function login(SocialAccount $account, ClientInterface $client): ?User + { + $user = $account->user; + + if ($user === null || $user->getIsBlocked()) { + return null; + } + + $event = new SocialAuthEvent([ + 'user' => $user, + 'account' => $account, + 'client' => $client, + 'type' => SocialAuthEvent::TYPE_LOGIN, + ]); + $this->module->trigger(self::EVENT_BEFORE_LOGIN, $event); + + if (Yii::$app->user->login($user, $this->module->rememberFor)) { + $user->updateLastLogin(); + $this->module->trigger(self::EVENT_AFTER_LOGIN, $event); + return $user; + } + + return null; + } + + /** + * Register new user via social account. + */ + protected function register(ClientInterface $client, array $attributes): ?User + { + $provider = $client->getId(); + $providerId = (string) ($attributes['id'] ?? ''); + $email = $attributes['email'] ?? null; + $username = $attributes['login'] ?? $attributes['username'] ?? null; + + $account = new SocialAccount([ + 'provider' => $provider, + 'provider_id' => $providerId, + 'email' => $email, + 'username' => $username, + 'data' => json_encode($attributes), + ]); + + $event = new SocialAuthEvent([ + 'account' => $account, + 'client' => $client, + 'type' => SocialAuthEvent::TYPE_REGISTER, + ]); + $this->module->trigger(self::EVENT_BEFORE_REGISTER, $event); + + $transaction = Yii::$app->db->beginTransaction(); + + try { + $user = new User(); + $user->email = $email ?: $this->generatePlaceholderEmail($provider, $providerId); + $user->username = $this->generateUniqueUsername($username); + $user->password = Yii::$app->security->generateRandomString(16); + $user->status = User::STATUS_ACTIVE; + $user->email_confirmed_at = $email ? new Expression('NOW()') : null; + + if (!$user->save()) { + $transaction->rollBack(); + return null; + } + + $account->user_id = $user->id; + if (!$account->save()) { + $transaction->rollBack(); + return null; + } + + $transaction->commit(); + + $event->user = $user; + $this->module->trigger(self::EVENT_AFTER_REGISTER, $event); + + if (Yii::$app->user->login($user, $this->module->rememberFor)) { + $user->updateLastLogin(); + } + + return $user; + + } catch (\Exception $e) { + $transaction->rollBack(); + Yii::error('Social registration failed: ' . $e->getMessage(), __METHOD__); + return null; + } + } + + /** + * Connect social account to existing user. + */ + public function connect(User $user, ClientInterface $client, ?array $attributes = null): ?User + { + $attributes = $attributes ?? $client->getUserAttributes(); + $provider = $client->getId(); + $providerId = (string) ($attributes['id'] ?? ''); + + if (empty($providerId)) { + return null; + } + + $existingAccount = SocialAccount::findByProviderAndId($provider, $providerId); + if ($existingAccount !== null && $existingAccount->user_id !== $user->id) { + return null; + } + + $account = SocialAccount::find() + ->byUser($user->id) + ->byProvider($provider) + ->one(); + + if ($account === null) { + $account = new SocialAccount([ + 'user_id' => $user->id, + 'provider' => $provider, + ]); + } + + $account->provider_id = $providerId; + $account->email = $attributes['email'] ?? null; + $account->username = $attributes['login'] ?? $attributes['username'] ?? null; + $account->data = json_encode($attributes); + + $event = new SocialAuthEvent([ + 'user' => $user, + 'account' => $account, + 'client' => $client, + 'type' => SocialAuthEvent::TYPE_CONNECT, + ]); + $this->module->trigger(self::EVENT_BEFORE_CONNECT, $event); + + if ($account->save()) { + $this->module->trigger(self::EVENT_AFTER_CONNECT, $event); + return $user; + } + + return null; + } + + /** + * Disconnect social account from user. + */ + public function disconnect(User $user, int $accountId): bool + { + $account = SocialAccount::find() + ->byUser($user->id) + ->andWhere(['id' => $accountId]) + ->one(); + + if ($account === null) { + return false; + } + + $event = new SocialAuthEvent([ + 'user' => $user, + 'account' => $account, + 'type' => SocialAuthEvent::TYPE_DISCONNECT, + ]); + $this->module->trigger(self::EVENT_BEFORE_DISCONNECT, $event); + + if ($account->delete()) { + $this->module->trigger(self::EVENT_AFTER_DISCONNECT, $event); + return true; + } + + return false; + } + + /** + * Get connected social accounts for a user. + * + * @return SocialAccount[] + */ + public function getConnectedAccounts(User $user): array + { + return SocialAccount::find() + ->byUser($user->id) + ->orderBy(['provider' => SORT_ASC]) + ->all(); + } + + /** + * Generate a placeholder email for users without email from social provider. + */ + protected function generatePlaceholderEmail(string $provider, string $providerId): string + { + return $provider . '_' . $providerId . '@social.local'; + } + + /** + * Generate a unique username. + */ + protected function generateUniqueUsername(?string $baseUsername): ?string + { + if ($baseUsername === null) { + return null; + } + + $username = $baseUsername; + $counter = 1; + + while (User::findByUsername($username) !== null) { + $username = $baseUsername . $counter; + $counter++; + } + + return $username; + } + + /** + * Get available auth clients. + */ + public function getAuthClients(): array + { + if (!Yii::$app->has('authClientCollection')) { + return []; + } + + return Yii::$app->get('authClientCollection')->getClients(); + } + + /** + * Get a specific auth client by name. + */ + public function getAuthClient(string $name): ?ClientInterface + { + if (!Yii::$app->has('authClientCollection')) { + return null; + } + + $collection = Yii::$app->get('authClientCollection'); + + if (!$collection->hasClient($name)) { + return null; + } + + return $collection->getClient($name); + } +} diff --git a/src/services/TwoFactorService.php b/src/services/TwoFactorService.php new file mode 100644 index 0000000..83713ec --- /dev/null +++ b/src/services/TwoFactorService.php @@ -0,0 +1,320 @@ +generateSecretKey(); + } + + /** + * Generate QR code URL for authenticator app. + */ + public function getQrCodeUrl(User $user, string $secret): string + { + if (!class_exists('\PragmaRX\Google2FA\Google2FA')) { + throw new \RuntimeException('pragmarx/google2fa package is required for 2FA support'); + } + + $google2fa = new \PragmaRX\Google2FA\Google2FA(); + $issuer = $this->module->twoFactorIssuer ?: Yii::$app->name; + $holder = $user->email; + + return $google2fa->getQRCodeUrl($issuer, $holder, $secret); + } + + /** + * Generate QR code as data URI (base64 PNG). + */ + public function getQrCodeDataUri(User $user, string $secret): string + { + $qrCodeUrl = $this->getQrCodeUrl($user, $secret); + + if (!class_exists('\BaconQrCode\Renderer\ImageRenderer')) { + return ''; + } + + $renderer = new \BaconQrCode\Renderer\ImageRenderer( + new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200), + new \BaconQrCode\Renderer\Image\SvgImageBackEnd() + ); + $writer = new \BaconQrCode\Writer($renderer); + + $svg = $writer->writeString($qrCodeUrl); + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + /** + * Verify a TOTP code. + */ + public function verifyCode(string $secret, string $code): bool + { + if (!class_exists('\PragmaRX\Google2FA\Google2FA')) { + return false; + } + + $google2fa = new \PragmaRX\Google2FA\Google2FA(); + return $google2fa->verifyKey($secret, $code); + } + + /** + * Enable 2FA for a user. + */ + public function enable(User $user, string $secret, string $code): bool + { + if (!$this->verifyCode($secret, $code)) { + return false; + } + + $event = new TwoFactorEvent([ + 'user' => $user, + 'type' => TwoFactorEvent::TYPE_ENABLED, + ]); + $this->module->trigger(self::EVENT_BEFORE_ENABLE, $event); + + $twoFactor = TwoFactor::find()->byUser($user->id)->one(); + if ($twoFactor === null) { + $twoFactor = new TwoFactor(); + $twoFactor->user_id = $user->id; + } + + $twoFactor->secret = $secret; + $twoFactor->enabled_at = new Expression('NOW()'); + $twoFactor->backup_codes = $this->generateBackupCodes(); + + if ($twoFactor->save()) { + $this->module->trigger(self::EVENT_AFTER_ENABLE, $event); + return true; + } + + return false; + } + + /** + * Disable 2FA for a user. + */ + public function disable(User $user): bool + { + $twoFactor = TwoFactor::find()->byUser($user->id)->one(); + if ($twoFactor === null) { + return true; + } + + $event = new TwoFactorEvent([ + 'user' => $user, + 'type' => TwoFactorEvent::TYPE_DISABLED, + ]); + $this->module->trigger(self::EVENT_BEFORE_DISABLE, $event); + + if ($twoFactor->delete()) { + $this->module->trigger(self::EVENT_AFTER_DISABLE, $event); + return true; + } + + return false; + } + + /** + * Check if user has 2FA enabled. + */ + public function isEnabled(User $user): bool + { + $twoFactor = TwoFactor::find()->byUser($user->id)->enabled()->one(); + return $twoFactor !== null; + } + + /** + * Check if user requires 2FA. + */ + public function isRequired(User $user): bool + { + if (!$this->module->enableTwoFactor) { + return false; + } + + if ($this->module->twoFactorRequireForAdmins && $user->getIsAdmin()) { + return true; + } + + return $this->isEnabled($user); + } + + /** + * Verify 2FA code or backup code. + */ + public function verify(User $user, string $code): bool + { + $twoFactor = TwoFactor::find()->byUser($user->id)->enabled()->one(); + if ($twoFactor === null) { + return false; + } + + if ($this->verifyCode($twoFactor->secret, $code)) { + $event = new TwoFactorEvent([ + 'user' => $user, + 'type' => TwoFactorEvent::TYPE_VERIFIED, + ]); + $this->module->trigger(self::EVENT_VERIFIED, $event); + return true; + } + + if ($this->verifyBackupCode($twoFactor, $code)) { + $event = new TwoFactorEvent([ + 'user' => $user, + 'type' => TwoFactorEvent::TYPE_BACKUP_USED, + ]); + $this->module->trigger(self::EVENT_BACKUP_USED, $event); + return true; + } + + return false; + } + + /** + * Verify and consume a backup code. + */ + protected function verifyBackupCode(TwoFactor $twoFactor, string $code): bool + { + $backupCodes = $twoFactor->backup_codes ?? []; + + $normalizedCode = strtoupper(str_replace(['-', ' '], '', $code)); + + $index = array_search($normalizedCode, array_map('strtoupper', $backupCodes), true); + if ($index === false) { + return false; + } + + unset($backupCodes[$index]); + $twoFactor->backup_codes = array_values($backupCodes); + $twoFactor->save(false, ['backup_codes']); + + return true; + } + + /** + * Generate backup codes. + */ + public function generateBackupCodes(): array + { + $codes = []; + $count = $this->module->twoFactorBackupCodesCount; + + for ($i = 0; $i < $count; $i++) { + $codes[] = strtoupper(Yii::$app->security->generateRandomString(8)); + } + + return $codes; + } + + /** + * Regenerate backup codes for a user. + */ + public function regenerateBackupCodes(User $user): ?array + { + $twoFactor = TwoFactor::find()->byUser($user->id)->enabled()->one(); + if ($twoFactor === null) { + return null; + } + + $codes = $this->generateBackupCodes(); + $twoFactor->backup_codes = $codes; + + if ($twoFactor->save(false, ['backup_codes'])) { + return $codes; + } + + return null; + } + + /** + * Get remaining backup codes count. + */ + public function getBackupCodesCount(User $user): int + { + $twoFactor = TwoFactor::find()->byUser($user->id)->one(); + if ($twoFactor === null) { + return 0; + } + + return count($twoFactor->backup_codes ?? []); + } + + /** + * Store pending 2FA user ID in session. + */ + public function storePending2FAUser(int $userId, bool $rememberMe = false): void + { + Yii::$app->session->set(self::SESSION_2FA_USER_ID, $userId); + Yii::$app->session->set(self::SESSION_2FA_REMEMBER, $rememberMe); + } + + /** + * Get pending 2FA user ID from session. + */ + public function getPending2FAUserId(): ?int + { + return Yii::$app->session->get(self::SESSION_2FA_USER_ID); + } + + /** + * Get pending 2FA remember me setting. + */ + public function getPending2FARememberMe(): bool + { + return (bool) Yii::$app->session->get(self::SESSION_2FA_REMEMBER, false); + } + + /** + * Clear pending 2FA session data. + */ + public function clearPending2FA(): void + { + Yii::$app->session->remove(self::SESSION_2FA_USER_ID); + Yii::$app->session->remove(self::SESSION_2FA_REMEMBER); + } + + /** + * Get TwoFactor model for user. + */ + public function getTwoFactor(User $user): ?TwoFactor + { + return TwoFactor::find()->byUser($user->id)->one(); + } +} diff --git a/src/views/admin/_assignments.php b/src/views/admin/_assignments.php index 8c214c1..3a781c0 100644 --- a/src/views/admin/_assignments.php +++ b/src/views/admin/_assignments.php @@ -3,19 +3,69 @@ /** * @var yii\web\View $this * @var cgsmith\user\models\User $user + * @var cgsmith\user\models\AssignmentForm $model + * @var yii\rbac\Role[] $roles + * @var cgsmith\user\Module $module */ + +use yii\helpers\Html; +use yii\widgets\ActiveForm; + ?> beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?> -
- -
- -authManager !== null): ?> -

+authManager === null): ?> +
+ +
+ +
+ + enableRbacManagement): ?> + urlPrefix . '/rbac/roles'], ['class' => 'alert-link']) ?> + +
-

+ + ['update-assignments', 'id' => $user->id], + ]); ?> + +
+ +

+
+ +
+
+ name, $model->roles), + [ + 'value' => $role->name, + 'id' => 'role-' . $role->name, + 'class' => 'form-check-input', + ] + ) ?> + +
+
+ +
+
+ +
+ 'btn btn-primary']) ?> +
+ + + endContent() ?> diff --git a/src/views/rbac/index.php b/src/views/rbac/index.php new file mode 100644 index 0000000..2e9e54e --- /dev/null +++ b/src/views/rbac/index.php @@ -0,0 +1,80 @@ +title = Yii::t('user', 'RBAC Management'); +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+ +
+
+
+
+

+
+ 'btn btn-sm btn-primary']) ?> +
+
+
+ +

+ +
    + +
  • +
    + name) ?> + description): ?> +
    description) ?> + +
    + $role->name], ['class' => 'btn btn-sm btn-outline-secondary']) ?> +
  • + +
+ +
+
+
+ +
+
+
+

+
+ 'btn btn-sm btn-primary']) ?> +
+
+
+ +

+ +
    + +
  • +
    + name) ?> + description): ?> +
    description) ?> + +
    + $permission->name], ['class' => 'btn btn-sm btn-outline-secondary']) ?> +
  • + +
+ +
+
+
+
+
diff --git a/src/views/rbac/permission-form.php b/src/views/rbac/permission-form.php new file mode 100644 index 0000000..717c112 --- /dev/null +++ b/src/views/rbac/permission-form.php @@ -0,0 +1,45 @@ +title = $isNew ? Yii::t('user', 'Create Permission') : Yii::t('user', 'Update Permission'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'RBAC Management'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Permissions'), 'url' => ['permissions']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+ +
+
+ + + field($model, 'name')->textInput(['maxlength' => 64]) ?> + +

+ +

+ + field($model, 'description')->textarea(['rows' => 3, 'maxlength' => 255]) ?> + +
+ $isNew ? 'btn btn-success' : 'btn btn-primary'] + ) ?> + 'btn btn-secondary']) ?> +
+ + +
+
+
diff --git a/src/views/rbac/permissions.php b/src/views/rbac/permissions.php new file mode 100644 index 0000000..85d8431 --- /dev/null +++ b/src/views/rbac/permissions.php @@ -0,0 +1,66 @@ +title = Yii::t('user', 'Permissions'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'RBAC Management'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+ +

+ 'btn btn-success']) ?> +

+ +
+
+ +

+ + + + + + + + + + + + + + + + + + + + +
name) ?>description) ?> + createdAt): ?> + formatter->asDatetime($permission->createdAt) ?> + + - + + + $permission->name], ['class' => 'btn btn-sm btn-primary']) ?> + $permission->name], [ + 'class' => 'btn btn-sm btn-danger', + 'data' => [ + 'confirm' => Yii::t('user', 'Are you sure you want to delete this permission?'), + 'method' => 'post', + ], + ]) ?> +
+ +
+
+
diff --git a/src/views/rbac/role-form.php b/src/views/rbac/role-form.php new file mode 100644 index 0000000..7d96015 --- /dev/null +++ b/src/views/rbac/role-form.php @@ -0,0 +1,103 @@ +title = $isNew ? Yii::t('user', 'Create Role') : Yii::t('user', 'Update Role'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'RBAC Management'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Roles'), 'url' => ['roles']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+ +
+
+ + + field($model, 'name')->textInput(['maxlength' => 64, 'readonly' => !$isNew]) ?> + + field($model, 'description')->textarea(['rows' => 3, 'maxlength' => 255]) ?> + + +
+ +
+ +
+
+ name, $model->permissions), + [ + 'value' => $permission->name, + 'id' => 'permission-' . $permission->name, + 'class' => 'form-check-input', + ] + ) ?> + +
+
+ +
+
+ + + +
+ +

+
+ +
+
+ name, $model->childRoles), + [ + 'value' => $role->name, + 'id' => 'child-role-' . $role->name, + 'class' => 'form-check-input', + ] + ) ?> + +
+
+ +
+
+ + +
+ $isNew ? 'btn btn-success' : 'btn btn-primary'] + ) ?> + 'btn btn-secondary']) ?> +
+ + +
+
+
diff --git a/src/views/rbac/roles.php b/src/views/rbac/roles.php new file mode 100644 index 0000000..015c384 --- /dev/null +++ b/src/views/rbac/roles.php @@ -0,0 +1,66 @@ +title = Yii::t('user', 'Roles'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'RBAC Management'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+ +

+ 'btn btn-success']) ?> +

+ +
+
+ +

+ + + + + + + + + + + + + + + + + + + + +
name) ?>description) ?> + createdAt): ?> + formatter->asDatetime($role->createdAt) ?> + + - + + + $role->name], ['class' => 'btn btn-sm btn-primary']) ?> + $role->name], [ + 'class' => 'btn btn-sm btn-danger', + 'data' => [ + 'confirm' => Yii::t('user', 'Are you sure you want to delete this role?'), + 'method' => 'post', + ], + ]) ?> +
+ +
+
+
diff --git a/src/views/social/networks.php b/src/views/social/networks.php new file mode 100644 index 0000000..5ec9499 --- /dev/null +++ b/src/views/social/networks.php @@ -0,0 +1,79 @@ +title = Yii::t('user', 'Connected Networks'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Settings'), 'url' => ['settings/account']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+

+ + +

+
+ +
+
+
+ provider)) ?> + username): ?> + - username) ?> + email): ?> + - email) ?> + +
+ Yii::$app->formatter->asRelativeTime($account->created_at) + ]) ?> +
+ $account->id], + [ + 'class' => 'btn btn-outline-danger btn-sm', + 'data-method' => 'post', + 'data-confirm' => Yii::t('user', 'Are you sure you want to disconnect this account?'), + ] + ) ?> +
+
+ +
+ + + + !in_array($client->getId(), $connectedProviders)); + ?> + +

+
+ ['/' . $module->urlPrefix . '/auth'], + 'popupMode' => false, + 'clients' => $unconnectedClients, + ]) ?> +
+ +
+ +
+ + +
+ +
+ +
diff --git a/src/views/two-factor/backup-codes.php b/src/views/two-factor/backup-codes.php new file mode 100644 index 0000000..23d14ec --- /dev/null +++ b/src/views/two-factor/backup-codes.php @@ -0,0 +1,70 @@ +title = Yii::t('user', 'Backup Codes'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Settings'), 'url' => ['settings/account']]; +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Two-Factor Authentication'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+ + +
+ +

+
+ +
+
+
+ +
+ +
+ +
+
+
+ +

+ +

+ +

$backupCodesCount]) ?>

+ + +
+ +
+ + + +
+ +

+

+ + + 'btn btn-warning', + 'data-confirm' => Yii::t('user', 'Are you sure? This will invalidate all existing backup codes.'), + ] + ) ?> + + +
+ 'btn btn-secondary']) ?> +
+
diff --git a/src/views/two-factor/index.php b/src/views/two-factor/index.php new file mode 100644 index 0000000..39ce3c6 --- /dev/null +++ b/src/views/two-factor/index.php @@ -0,0 +1,96 @@ +activeFormClass; +$this->title = Yii::t('user', 'Two-Factor Authentication'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Settings'), 'url' => ['settings/account']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+ + +
+ +
+ +

$backupCodesCount]) ?>

+ +
+ 'btn btn-secondary'] + ) ?> +
+ +
+ +

+

+ + + 'btn btn-danger', + 'data-confirm' => Yii::t('user', 'Are you sure you want to disable two-factor authentication?'), + ] + ) ?> + + + +
+ +
+ +

+ +
    +
  1. +
  2. +
  3. +
+ + +
+ QR Code +
+ + +

+ + +

+ + ['enable']] + $module->formFieldConfig) ?> + + + + field($setupForm, 'code')->textInput([ + 'autofocus' => true, + 'autocomplete' => 'one-time-code', + 'inputmode' => 'numeric', + 'pattern' => '[0-9]*', + 'maxlength' => 6, + ]) ?> + +
+ 'btn btn-primary']) ?> +
+ + + +
diff --git a/src/views/two-factor/verify.php b/src/views/two-factor/verify.php new file mode 100644 index 0000000..7e9c404 --- /dev/null +++ b/src/views/two-factor/verify.php @@ -0,0 +1,40 @@ +activeFormClass; +$this->title = Yii::t('user', 'Two-Factor Verification'); +?> + +
+

title) ?>

+ +

+ + 'two-factor-form'] + $module->formFieldConfig) ?> + + field($model, 'code')->textInput([ + 'autofocus' => true, + 'autocomplete' => 'one-time-code', + 'inputmode' => 'numeric', + 'placeholder' => Yii::t('user', 'Enter code'), + ]) ?> + +
+ 'btn btn-primary btn-block']) ?> +
+ + + +

+ + + +

+
diff --git a/src/widgets/SocialConnect.php b/src/widgets/SocialConnect.php new file mode 100644 index 0000000..93a5d41 --- /dev/null +++ b/src/widgets/SocialConnect.php @@ -0,0 +1,54 @@ + + * ``` + */ +class SocialConnect extends Widget +{ + public bool $popupMode = true; + public bool $autoRender = true; + public array $options = []; + + /** + * {@inheritdoc} + */ + public function run(): string + { + /** @var Module $module */ + $module = Yii::$app->getModule('user'); + + if (!$module->enableSocialAuth) { + return ''; + } + + /** @var SocialAuthService $socialAuthService */ + $socialAuthService = Yii::$container->get(SocialAuthService::class); + $clients = $socialAuthService->getAuthClients(); + + if (empty($clients)) { + return ''; + } + + return AuthChoice::widget([ + 'baseAuthUrl' => ['/' . $module->urlPrefix . '/auth'], + 'popupMode' => $this->popupMode, + 'autoRender' => $this->autoRender, + 'options' => $this->options, + ]); + } +} diff --git a/tests/FunctionalConfig.php b/tests/FunctionalConfig.php new file mode 100644 index 0000000..7e46865 --- /dev/null +++ b/tests/FunctionalConfig.php @@ -0,0 +1,48 @@ + 'functional-test-app', + 'basePath' => __DIR__, + 'vendorPath' => dirname(__DIR__) . '/vendor', + 'components' => [ + 'db' => [ + 'class' => \yii\db\Connection::class, + 'dsn' => 'sqlite:' . __DIR__ . '/_data/test.db', + ], + 'authManager' => [ + 'class' => \yii\rbac\PhpManager::class, + ], + 'user' => [ + 'class' => \yii\web\User::class, + 'identityClass' => \cgsmith\user\models\User::class, + ], + 'mailer' => [ + 'class' => \yii\swiftmailer\Mailer::class, + 'useFileTransport' => true, + 'fileTransportPath' => '@tests/_output/mail', + ], + 'security' => [ + 'class' => \yii\base\Security::class, + ], + 'request' => [ + 'class' => \yii\web\Request::class, + 'cookieValidationKey' => 'test-cookie-key', + 'scriptFile' => __DIR__ . '/index.php', + 'scriptUrl' => '/index.php', + ], + 'urlManager' => [ + 'class' => \yii\web\UrlManager::class, + 'enablePrettyUrl' => true, + 'showScriptName' => false, + ], + ], + 'modules' => [ + 'user' => [ + 'class' => \cgsmith\user\Module::class, + ], + ], +]; diff --git a/tests/UnitBootstrap.php b/tests/UnitBootstrap.php new file mode 100644 index 0000000..ba5339e --- /dev/null +++ b/tests/UnitBootstrap.php @@ -0,0 +1,49 @@ + 'test-app', + 'basePath' => __DIR__, + 'vendorPath' => dirname(__DIR__) . '/vendor', + 'components' => [ + 'db' => [ + 'class' => \yii\db\Connection::class, + 'dsn' => 'sqlite::memory:', + ], + 'authManager' => [ + 'class' => \yii\rbac\PhpManager::class, + ], + 'user' => [ + 'class' => \yii\web\User::class, + 'identityClass' => \cgsmith\user\models\User::class, + ], + 'mailer' => [ + 'class' => \yii\swiftmailer\Mailer::class, + 'useFileTransport' => true, + ], + 'security' => [ + 'class' => \yii\base\Security::class, + ], + 'request' => [ + 'class' => \yii\web\Request::class, + 'cookieValidationKey' => 'test-cookie-key', + 'scriptFile' => __DIR__ . '/index.php', + 'scriptUrl' => '/index.php', + ], + 'urlManager' => [ + 'class' => \yii\web\UrlManager::class, + 'enablePrettyUrl' => true, + 'showScriptName' => false, + ], + ], + 'modules' => [ + 'user' => [ + 'class' => \cgsmith\user\Module::class, + ], + ], +]; + +new \yii\web\Application($config); diff --git a/tests/_bootstrap.php b/tests/_bootstrap.php new file mode 100644 index 0000000..22aa106 --- /dev/null +++ b/tests/_bootstrap.php @@ -0,0 +1,12 @@ +module = new Module('user'); + } + + public function testVersionReturnsString(): void + { + $this->assertIsString($this->module->getVersion()); + $this->assertEquals(Module::VERSION, $this->module->getVersion()); + } + + public function testDefaultModelMapReturnsUserClass(): void + { + $userClass = $this->module->getModelClass('User'); + $this->assertEquals('cgsmith\user\models\User', $userClass); + } + + public function testDefaultModelMapReturnsProfileClass(): void + { + $profileClass = $this->module->getModelClass('Profile'); + $this->assertEquals('cgsmith\user\models\Profile', $profileClass); + } + + public function testDefaultModelMapReturnsTokenClass(): void + { + $tokenClass = $this->module->getModelClass('Token'); + $this->assertEquals('cgsmith\user\models\Token', $tokenClass); + } + + public function testDefaultModelMapReturnsLoginFormClass(): void + { + $loginFormClass = $this->module->getModelClass('LoginForm'); + $this->assertEquals('cgsmith\user\models\LoginForm', $loginFormClass); + } + + public function testCustomModelMapOverridesDefault(): void + { + $this->module->modelMap = [ + 'User' => 'app\models\CustomUser', + ]; + + $userClass = $this->module->getModelClass('User'); + $this->assertEquals('app\models\CustomUser', $userClass); + } + + public function testGetModelClassThrowsExceptionForUnknownModel(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown model: NonExistent'); + + $this->module->getModelClass('NonExistent'); + } + + public function testEmailChangeStrategyConstants(): void + { + $this->assertEquals(0, Module::EMAIL_CHANGE_INSECURE); + $this->assertEquals(1, Module::EMAIL_CHANGE_DEFAULT); + $this->assertEquals(2, Module::EMAIL_CHANGE_SECURE); + } + + public function testDefaultConfiguration(): void + { + $this->assertTrue($this->module->enableRegistration); + $this->assertTrue($this->module->enableConfirmation); + $this->assertFalse($this->module->enableUnconfirmedLogin); + $this->assertTrue($this->module->enablePasswordRecovery); + $this->assertFalse($this->module->enableGdpr); + $this->assertFalse($this->module->enableTwoFactor); + $this->assertFalse($this->module->enableSocialAuth); + $this->assertFalse($this->module->enableCaptcha); + $this->assertFalse($this->module->enableSessionHistory); + } + + public function testDefaultPasswordSettings(): void + { + $this->assertEquals(8, $this->module->minPasswordLength); + $this->assertEquals(72, $this->module->maxPasswordLength); + $this->assertEquals(12, $this->module->cost); + } + + public function testDefaultTokenExpiration(): void + { + $this->assertEquals(86400, $this->module->confirmWithin); + $this->assertEquals(21600, $this->module->recoverWithin); + $this->assertEquals(1209600, $this->module->rememberFor); + } + + public function testDefaultAvatarSettings(): void + { + $this->assertTrue($this->module->enableGravatar); + $this->assertTrue($this->module->enableAvatarUpload); + $this->assertEquals('@webroot/uploads/avatars', $this->module->avatarPath); + $this->assertEquals('@web/uploads/avatars', $this->module->avatarUrl); + $this->assertEquals(2097152, $this->module->maxAvatarSize); + $this->assertEquals(['jpg', 'jpeg', 'png', 'gif', 'webp'], $this->module->avatarExtensions); + } + + public function testDefaultUrlPrefix(): void + { + $this->assertEquals('user', $this->module->urlPrefix); + } + + public function testMailerSenderWithDefault(): void + { + $sender = $this->module->getMailerSender(); + $this->assertIsArray($sender); + } + + public function testMailerSenderWithCustomConfig(): void + { + $this->module->mailer = [ + 'sender' => ['custom@example.com' => 'Custom Sender'], + ]; + + $sender = $this->module->getMailerSender(); + $this->assertEquals(['custom@example.com' => 'Custom Sender'], $sender); + } + + public function testDefaultCaptchaSettings(): void + { + $this->assertEquals('yii', $this->module->captchaType); + $this->assertEquals(['register'], $this->module->captchaForms); + $this->assertEquals(0.5, $this->module->reCaptchaV3Threshold); + } + + public function testDefaultTwoFactorSettings(): void + { + $this->assertEquals('', $this->module->twoFactorIssuer); + $this->assertEquals(10, $this->module->twoFactorBackupCodesCount); + $this->assertFalse($this->module->twoFactorRequireForAdmins); + } + + public function testDefaultGdprSettings(): void + { + $this->assertEquals('1.0', $this->module->gdprConsentVersion); + $this->assertNull($this->module->gdprConsentUrl); + $this->assertEquals([], $this->module->gdprExemptRoutes); + $this->assertTrue($this->module->requireGdprConsentBeforeRegistration); + } + + public function testDefaultSessionSettings(): void + { + $this->assertEquals(10, $this->module->sessionHistoryLimit); + $this->assertFalse($this->module->enableSessionSeparation); + $this->assertEquals('BACKENDSESSID', $this->module->backendSessionName); + $this->assertEquals('PHPSESSID', $this->module->frontendSessionName); + } + + public function testDefaultSocialAuthSettings(): void + { + $this->assertTrue($this->module->enableSocialRegistration); + $this->assertTrue($this->module->enableSocialConnect); + } + + public function testDefaultRbacSettings(): void + { + $this->assertFalse($this->module->enableRbacManagement); + $this->assertNull($this->module->rbacManagementPermission); + $this->assertNull($this->module->adminPermission); + $this->assertNull($this->module->impersonatePermission); + } + + public function testDefaultActiveFormClass(): void + { + $this->assertEquals('yii\widgets\ActiveForm', $this->module->activeFormClass); + } + + public function testAdminsArrayIsEmpty(): void + { + $this->assertEquals([], $this->module->admins); + } +} diff --git a/tests/unit/helpers/PasswordTest.php b/tests/unit/helpers/PasswordTest.php new file mode 100644 index 0000000..25170f3 --- /dev/null +++ b/tests/unit/helpers/PasswordTest.php @@ -0,0 +1,139 @@ +assertNotEmpty($hash); + $this->assertNotEquals($password, $hash); + $this->assertStringStartsWith('$2y$', $hash); + } + + public function testHashWithCustomCost(): void + { + $password = 'testPassword123!'; + $hash = Password::hash($password, 10); + + $this->assertNotEmpty($hash); + $this->assertTrue(Password::validate($password, $hash)); + } + + public function testValidateReturnsTrueForCorrectPassword(): void + { + $password = 'testPassword123!'; + $hash = Password::hash($password); + + $this->assertTrue(Password::validate($password, $hash)); + } + + public function testValidateReturnsFalseForIncorrectPassword(): void + { + $password = 'testPassword123!'; + $wrongPassword = 'wrongPassword456!'; + $hash = Password::hash($password); + + $this->assertFalse(Password::validate($wrongPassword, $hash)); + } + + public function testGenerateCreatesPasswordOfSpecifiedLength(): void + { + $length = 16; + $password = Password::generate($length); + + $this->assertEquals($length, strlen($password)); + } + + public function testGenerateDefaultLength(): void + { + $password = Password::generate(); + + $this->assertEquals(12, strlen($password)); + } + + public function testGenerateCreatesUniquePasswords(): void + { + $passwords = []; + for ($i = 0; $i < 10; $i++) { + $passwords[] = Password::generate(); + } + + $uniquePasswords = array_unique($passwords); + $this->assertCount(10, $uniquePasswords); + } + + public function testCheckStrengthWeakPassword(): void + { + $result = Password::checkStrength('abc'); + + $this->assertEquals(0, $result['score']); + $this->assertNotEmpty($result['feedback']); + } + + public function testCheckStrengthMediumPassword(): void + { + $result = Password::checkStrength('Password1'); + + $this->assertGreaterThan(1, $result['score']); + $this->assertLessThanOrEqual(4, $result['score']); + } + + public function testCheckStrengthStrongPassword(): void + { + $result = Password::checkStrength('MyStr0ng!Passw0rd'); + + $this->assertEquals(4, $result['score']); + $this->assertEmpty($result['feedback']); + } + + public function testCheckStrengthFeedbackForShortPassword(): void + { + $result = Password::checkStrength('short'); + + $this->assertContains('Password should be at least 8 characters.', $result['feedback']); + } + + public function testCheckStrengthFeedbackForNoUppercase(): void + { + $result = Password::checkStrength('lowercase123!'); + + $this->assertContains('Add uppercase letters.', $result['feedback']); + } + + public function testCheckStrengthFeedbackForNoLowercase(): void + { + $result = Password::checkStrength('UPPERCASE123!'); + + $this->assertContains('Add lowercase letters.', $result['feedback']); + } + + public function testCheckStrengthFeedbackForNoNumbers(): void + { + $result = Password::checkStrength('NoNumbersHere!'); + + $this->assertContains('Add numbers.', $result['feedback']); + } + + public function testCheckStrengthFeedbackForNoSpecialChars(): void + { + $result = Password::checkStrength('NoSpecial123'); + + $this->assertContains('Add special characters.', $result['feedback']); + } + + public function testCheckStrengthMaxScoreIsFour(): void + { + $result = Password::checkStrength('ThisIsAnExtremelyLongAndSecurePassword123!@#'); + + $this->assertEquals(4, $result['score']); + } +} diff --git a/tests/unit/services/CaptchaServiceTest.php b/tests/unit/services/CaptchaServiceTest.php new file mode 100644 index 0000000..047aa50 --- /dev/null +++ b/tests/unit/services/CaptchaServiceTest.php @@ -0,0 +1,108 @@ +module = new Module('user'); + $this->service = new CaptchaService($this->module); + } + + public function testIsEnabledForFormReturnsFalseWhenCaptchaDisabled(): void + { + $this->module->enableCaptcha = false; + + $this->assertFalse($this->service->isEnabledForForm('login')); + $this->assertFalse($this->service->isEnabledForForm('register')); + $this->assertFalse($this->service->isEnabledForForm('recovery')); + } + + public function testIsEnabledForFormReturnsTrueWhenFormInList(): void + { + $this->module->enableCaptcha = true; + $this->module->captchaForms = ['login', 'register']; + + $this->assertTrue($this->service->isEnabledForForm('login')); + $this->assertTrue($this->service->isEnabledForForm('register')); + $this->assertFalse($this->service->isEnabledForForm('recovery')); + } + + public function testGetCaptchaTypeReturnsConfiguredType(): void + { + $this->module->captchaType = CaptchaService::TYPE_RECAPTCHA_V2; + + $this->assertEquals(CaptchaService::TYPE_RECAPTCHA_V2, $this->service->getCaptchaType()); + } + + public function testGetSiteKeyReturnsNullForYiiType(): void + { + $this->module->captchaType = CaptchaService::TYPE_YII; + + $this->assertNull($this->service->getSiteKey()); + } + + public function testGetSiteKeyReturnsReCaptchaKey(): void + { + $this->module->captchaType = CaptchaService::TYPE_RECAPTCHA_V2; + $this->module->reCaptchaSiteKey = 'test-site-key'; + + $this->assertEquals('test-site-key', $this->service->getSiteKey()); + } + + public function testGetSiteKeyReturnsReCaptchaV3Key(): void + { + $this->module->captchaType = CaptchaService::TYPE_RECAPTCHA_V3; + $this->module->reCaptchaSiteKey = 'test-v3-site-key'; + + $this->assertEquals('test-v3-site-key', $this->service->getSiteKey()); + } + + public function testGetSiteKeyReturnsHCaptchaKey(): void + { + $this->module->captchaType = CaptchaService::TYPE_HCAPTCHA; + $this->module->hCaptchaSiteKey = 'hcaptcha-site-key'; + + $this->assertEquals('hcaptcha-site-key', $this->service->getSiteKey()); + } + + public function testGetReCaptchaActionReturnsCorrectActions(): void + { + $this->assertEquals('login', $this->service->getReCaptchaAction('login')); + $this->assertEquals('register', $this->service->getReCaptchaAction('register')); + $this->assertEquals('recovery', $this->service->getReCaptchaAction('recovery')); + $this->assertEquals('submit', $this->service->getReCaptchaAction('unknown')); + } + + public function testVerifyReCaptchaReturnsFalseWithoutSecretKey(): void + { + $this->module->reCaptchaSecretKey = null; + + $this->assertFalse($this->service->verifyReCaptcha('test-response')); + } + + public function testVerifyHCaptchaReturnsFalseWithoutSecretKey(): void + { + $this->module->hCaptchaSecretKey = null; + + $this->assertFalse($this->service->verifyHCaptcha('test-response')); + } + + public function testCaptchaTypeConstants(): void + { + $this->assertEquals('yii', CaptchaService::TYPE_YII); + $this->assertEquals('recaptcha-v2', CaptchaService::TYPE_RECAPTCHA_V2); + $this->assertEquals('recaptcha-v3', CaptchaService::TYPE_RECAPTCHA_V3); + $this->assertEquals('hcaptcha', CaptchaService::TYPE_HCAPTCHA); + } +}