RBAC and social controller

This commit is contained in:
2026-01-29 17:54:12 +01:00
parent 98a0e33939
commit e003257c84
48 changed files with 4510 additions and 73 deletions

386
README.md
View File

@@ -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/<provider>` - 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/<id>` - 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
@@ -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

21
codeception.yml Normal file
View File

@@ -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/*

View File

@@ -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": {

19
phpstan.neon Normal file
View File

@@ -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

View File

@@ -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/<authclient:[\w\-]+>'] = 'social/auth';
$rules['settings/networks'] = 'social/networks';
$rules['settings/networks/disconnect/<id:\d+>'] = 'social/disconnect';
}
return $rules;
}
}

View File

@@ -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/<id:\d+>' => 'admin/unblock',
'admin/confirm/<id:\d+>' => 'admin/confirm',
'admin/impersonate/<id:\d+>' => 'admin/impersonate',
'admin/assignments/<id:\d+>' => '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/<authclient:[\w\-]+>'] = 'social/auth';
$rules['settings/networks'] = 'social/networks';
$rules['settings/networks/disconnect/<id:\d+>'] = '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/<name:[\w\-]+>'] = 'rbac/update-role';
$rules['rbac/roles/delete/<name:[\w\-]+>'] = 'rbac/delete-role';
$rules['rbac/permissions'] = 'rbac/permissions';
$rules['rbac/permissions/create'] = 'rbac/create-permission';
$rules['rbac/permissions/update/<name:[\w\-\.]+>'] = 'rbac/update-permission';
$rules['rbac/permissions/delete/<name:[\w\-\.]+>'] = 'rbac/delete-permission';
}
return $rules;
}

View File

@@ -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.
*/

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\PermissionForm;
use cgsmith\user\models\RoleForm;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\RbacService;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* RBAC management controller.
*/
class RbacController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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']);
}
}

View File

@@ -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,20 +72,32 @@ 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 ($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']);
}
}
if ($model->login()) {
if ($module->enableSessionHistory) {
/** @var SessionService $sessionService */
$sessionService = Yii::$container->get(SessionService::class);
$sessionService->trackSession(Yii::$app->user->identity);
}
// Trigger after login event
$event = new FormEvent(['form' => $model]);
$module->trigger(self::EVENT_AFTER_LOGIN, $event);
return $this->goBack();
}
}
return $this->render('login', [
'model' => $model,

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\SocialAuthService;
use Yii;
use yii\authclient\AuthAction;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Social authentication controller.
*/
class SocialController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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']);
}
}

View File

@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\TwoFactorForm;
use cgsmith\user\models\TwoFactorSetupForm;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\TwoFactorService;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Two-factor authentication controller.
*/
class TwoFactorController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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,
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\events;
use cgsmith\user\models\SocialAccount;
use cgsmith\user\models\User;
use yii\authclient\ClientInterface;
use yii\base\Event;
/**
* Social authentication event.
*/
class SocialAuthEvent extends Event
{
public const TYPE_LOGIN = 'login';
public const TYPE_REGISTER = 'register';
public const TYPE_CONNECT = 'connect';
public const TYPE_DISCONNECT = 'disconnect';
public ?User $user = null;
public ?SocialAccount $account = null;
public ?ClientInterface $client = null;
public ?string $type = null;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\events;
use cgsmith\user\models\User;
use yii\base\Event;
/**
* Two-factor authentication event.
*/
class TwoFactorEvent extends Event
{
public const TYPE_ENABLED = 'enabled';
public const TYPE_DISABLED = 'disabled';
public const TYPE_VERIFIED = 'verified';
public const TYPE_BACKUP_USED = 'backup_used';
public ?User $user = null;
public ?string $type = null;
}

View File

@@ -190,4 +190,161 @@ return [
'Add uppercase letters.' => '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.',
];

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\services\RbacService;
use Yii;
use yii\base\Model;
/**
* Role assignment form model.
*/
class AssignmentForm extends Model
{
public int $userId;
public array $roles = [];
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['userId', 'required'],
['userId', 'integer'],
['roles', 'each', 'rule' => ['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);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\services\RbacService;
use Yii;
use yii\base\Model;
/**
* Permission form model.
*/
class PermissionForm extends Model
{
public ?string $name = null;
public ?string $description = null;
public ?string $originalName = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['name', 'required'],
['name', 'string', 'max' => 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;
}
}

155
src/models/RoleForm.php Normal file
View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\services\RbacService;
use Yii;
use yii\base\Model;
/**
* Role form model.
*/
class RoleForm extends Model
{
public ?string $name = null;
public ?string $description = null;
public array $permissions = [];
public array $childRoles = [];
public ?string $originalName = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['name', 'required'],
['name', 'string', 'max' => 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;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\models\query\SocialAccountQuery;
use Yii;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
/**
* Social account ActiveRecord model.
*
* @property int $id
* @property int|null $user_id
* @property string $provider
* @property string $provider_id
* @property string|null $data
* @property string|null $email
* @property string|null $username
* @property string $created_at
*
* @property-read User|null $user
* @property-read array $decodedData
*/
class SocialAccount extends ActiveRecord
{
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user_social_account}}';
}
/**
* {@inheritdoc}
* @return SocialAccountQuery
*/
public static function find(): SocialAccountQuery
{
return new SocialAccountQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['provider', 'provider_id'], 'required'],
[['user_id'], 'integer'],
[['provider'], 'string', 'max' => 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();
}
}

119
src/models/TwoFactor.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\models\query\TwoFactorQuery;
use Yii;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
/**
* Two-factor authentication ActiveRecord model.
*
* @property int $id
* @property int $user_id
* @property string $secret
* @property string|null $enabled_at
* @property array|null $backup_codes
* @property string $created_at
* @property string $updated_at
*
* @property-read User $user
* @property-read bool $isEnabled
*/
class TwoFactor extends ActiveRecord
{
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user_two_factor}}';
}
/**
* {@inheritdoc}
* @return TwoFactorQuery
*/
public static function find(): TwoFactorQuery
{
return new TwoFactorQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['user_id', 'secret'], 'required'],
[['user_id'], 'integer'],
[['secret'], 'string', 'max' => 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;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use Yii;
use yii\base\Model;
/**
* Two-factor authentication verification form.
*/
class TwoFactorForm extends Model
{
public ?string $code = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['code', 'required'],
['code', 'string', 'min' => 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'),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use Yii;
use yii\base\Model;
/**
* Two-factor authentication setup form.
*/
class TwoFactorSetupForm extends Model
{
public ?string $code = null;
public ?string $secret = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['code', 'required'],
['code', 'string', 'length' => 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'),
];
}
}

View File

@@ -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
/**

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\SocialAccount;
use yii\db\ActiveQuery;
/**
* Query class for SocialAccount model.
*
* @method SocialAccount|null one($db = null)
* @method SocialAccount[] all($db = null)
*/
class SocialAccountQuery extends ActiveQuery
{
/**
* Filter by user ID.
*/
public function byUser(int $userId): self
{
return $this->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]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\TwoFactor;
use yii\db\ActiveQuery;
/**
* Query class for TwoFactor model.
*
* @method TwoFactor|null one($db = null)
* @method TwoFactor[] all($db = null)
*/
class TwoFactorQuery extends ActiveQuery
{
/**
* Filter by user ID.
*/
public function byUser(int $userId): self
{
return $this->andWhere(['user_id' => $userId]);
}
/**
* Filter enabled only.
*/
public function enabled(): self
{
return $this->andWhere(['not', ['enabled_at' => null]]);
}
}

View File

@@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\rbac\Item;
use yii\rbac\Permission;
use yii\rbac\Role;
/**
* Service for RBAC management.
*/
class RbacService
{
public function __construct(
private readonly Module $module
) {
}
/**
* Get the auth manager.
*/
public function getAuthManager(): ?\yii\rbac\ManagerInterface
{
return Yii::$app->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();
}
}

View File

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\events\SocialAuthEvent;
use cgsmith\user\models\SocialAccount;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\authclient\ClientInterface;
use yii\db\Expression;
/**
* Service for social authentication management.
*/
class SocialAuthService
{
public const EVENT_BEFORE_LOGIN = 'beforeSocialLogin';
public const EVENT_AFTER_LOGIN = 'afterSocialLogin';
public const EVENT_BEFORE_REGISTER = 'beforeSocialRegister';
public const EVENT_AFTER_REGISTER = 'afterSocialRegister';
public const EVENT_BEFORE_CONNECT = 'beforeSocialConnect';
public const EVENT_AFTER_CONNECT = 'afterSocialConnect';
public const EVENT_BEFORE_DISCONNECT = 'beforeSocialDisconnect';
public const EVENT_AFTER_DISCONNECT = 'afterSocialDisconnect';
public function __construct(
private readonly Module $module
) {
}
/**
* Handle OAuth callback.
*/
public function handleCallback(ClientInterface $client): ?User
{
$attributes = $client->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);
}
}

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\events\TwoFactorEvent;
use cgsmith\user\models\TwoFactor;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\db\Expression;
/**
* Service for two-factor authentication management.
*/
class TwoFactorService
{
public const EVENT_BEFORE_ENABLE = 'beforeTwoFactorEnable';
public const EVENT_AFTER_ENABLE = 'afterTwoFactorEnable';
public const EVENT_BEFORE_DISABLE = 'beforeTwoFactorDisable';
public const EVENT_AFTER_DISABLE = 'afterTwoFactorDisable';
public const EVENT_VERIFIED = 'twoFactorVerified';
public const EVENT_BACKUP_USED = 'twoFactorBackupUsed';
private const SESSION_2FA_USER_ID = '__2fa_user_id';
private const SESSION_2FA_REMEMBER = '__2fa_remember';
public function __construct(
private readonly Module $module
) {
}
/**
* Generate a new secret key for TOTP.
*/
public function generateSecret(): string
{
if (!class_exists('\PragmaRX\Google2FA\Google2FA')) {
throw new \RuntimeException('pragmarx/google2fa package is required for 2FA support');
}
$google2fa = new \PragmaRX\Google2FA\Google2FA();
return $google2fa->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();
}
}

View File

@@ -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;
?>
<?php $this->beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?>
<div class="user-alert user-alert-info">
<?= Yii::t('user', 'You can assign multiple roles or permissions to user by using the form below') ?>
<?php if (Yii::$app->authManager === null): ?>
<div class="alert alert-warning">
<?= Yii::t('user', 'RBAC is not configured. Configure authManager in your application to use this feature.') ?>
</div>
<?php elseif (empty($roles)): ?>
<div class="alert alert-info">
<?= Yii::t('user', 'No roles have been created yet.') ?>
<?php if ($module->enableRbacManagement): ?>
<?= Html::a(Yii::t('user', 'Create roles'), ['/' . $module->urlPrefix . '/rbac/roles'], ['class' => 'alert-link']) ?>
<?php endif; ?>
</div>
<?php else: ?>
<?php $form = ActiveForm::begin([
'action' => ['update-assignments', 'id' => $user->id],
]); ?>
<div class="mb-3">
<label class="form-label"><strong><?= Yii::t('user', 'Assigned Roles') ?></strong></label>
<p class="text-muted small"><?= Yii::t('user', 'Select the roles you want to assign to this user.') ?></p>
<div class="row">
<?php foreach ($roles as $role): ?>
<div class="col-md-4 mb-2">
<div class="form-check">
<?= Html::checkbox(
'AssignmentForm[roles][]',
in_array($role->name, $model->roles),
[
'value' => $role->name,
'id' => 'role-' . $role->name,
'class' => 'form-check-input',
]
) ?>
<label class="form-check-label" for="role-<?= Html::encode($role->name) ?>">
<strong><?= Html::encode($role->name) ?></strong>
<?php if ($role->description): ?>
<br><small class="text-muted"><?= Html::encode($role->description) ?></small>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if (Yii::$app->authManager !== null): ?>
<p><?= Yii::t('user', 'RBAC assignment widget would go here. Configure your RBAC module to enable this feature.') ?></p>
<?php else: ?>
<p class="text-muted"><?= Yii::t('user', 'RBAC is not configured. Configure authManager in your application to use this feature.') ?></p>
<div class="form-group">
<?= Html::submitButton(Yii::t('user', 'Update Assignments'), ['class' => 'btn btn-primary']) ?>
</div>
<?php ActiveForm::end(); ?>
<?php endif ?>
<?php $this->endContent() ?>

80
src/views/rbac/index.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
/**
* @var yii\web\View $this
* @var yii\rbac\Role[] $roles
* @var yii\rbac\Permission[] $permissions
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->title = Yii::t('user', 'RBAC Management');
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="rbac-index">
<h1><?= Html::encode($this->title) ?></h1>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><?= Yii::t('user', 'Roles') ?></h3>
<div class="card-tools">
<?= Html::a(Yii::t('user', 'Manage Roles'), ['roles'], ['class' => 'btn btn-sm btn-primary']) ?>
</div>
</div>
<div class="card-body">
<?php if (empty($roles)): ?>
<p class="text-muted"><?= Yii::t('user', 'No roles have been created yet.') ?></p>
<?php else: ?>
<ul class="list-group">
<?php foreach ($roles as $role): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong><?= Html::encode($role->name) ?></strong>
<?php if ($role->description): ?>
<br><small class="text-muted"><?= Html::encode($role->description) ?></small>
<?php endif; ?>
</div>
<?= Html::a(Yii::t('user', 'Edit'), ['update-role', 'name' => $role->name], ['class' => 'btn btn-sm btn-outline-secondary']) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><?= Yii::t('user', 'Permissions') ?></h3>
<div class="card-tools">
<?= Html::a(Yii::t('user', 'Manage Permissions'), ['permissions'], ['class' => 'btn btn-sm btn-primary']) ?>
</div>
</div>
<div class="card-body">
<?php if (empty($permissions)): ?>
<p class="text-muted"><?= Yii::t('user', 'No permissions have been created yet.') ?></p>
<?php else: ?>
<ul class="list-group">
<?php foreach ($permissions as $permission): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong><?= Html::encode($permission->name) ?></strong>
<?php if ($permission->description): ?>
<br><small class="text-muted"><?= Html::encode($permission->description) ?></small>
<?php endif; ?>
</div>
<?= Html::a(Yii::t('user', 'Edit'), ['update-permission', 'name' => $permission->name], ['class' => 'btn btn-sm btn-outline-secondary']) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\PermissionForm $model
* @var cgsmith\user\Module $module
* @var bool $isNew
*/
use yii\helpers\Html;
use yii\widgets\ActiveForm;
$this->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;
?>
<div class="rbac-permission-form">
<h1><?= Html::encode($this->title) ?></h1>
<div class="card">
<div class="card-body">
<?php $form = ActiveForm::begin(); ?>
<?= $form->field($model, 'name')->textInput(['maxlength' => 64]) ?>
<p class="text-muted small">
<?= Yii::t('user', 'Permission names can contain letters, numbers, underscores, hyphens, and dots.') ?>
</p>
<?= $form->field($model, 'description')->textarea(['rows' => 3, 'maxlength' => 255]) ?>
<div class="form-group">
<?= Html::submitButton(
$isNew ? Yii::t('user', 'Create') : Yii::t('user', 'Update'),
['class' => $isNew ? 'btn btn-success' : 'btn btn-primary']
) ?>
<?= Html::a(Yii::t('user', 'Cancel'), ['permissions'], ['class' => 'btn btn-secondary']) ?>
</div>
<?php ActiveForm::end(); ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<?php
/**
* @var yii\web\View $this
* @var yii\rbac\Permission[] $permissions
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->title = Yii::t('user', 'Permissions');
$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'RBAC Management'), 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="rbac-permissions">
<h1><?= Html::encode($this->title) ?></h1>
<p>
<?= Html::a(Yii::t('user', 'Create Permission'), ['create-permission'], ['class' => 'btn btn-success']) ?>
</p>
<div class="card">
<div class="card-body">
<?php if (empty($permissions)): ?>
<p class="text-muted"><?= Yii::t('user', 'No permissions have been created yet.') ?></p>
<?php else: ?>
<table class="table table-striped">
<thead>
<tr>
<th><?= Yii::t('user', 'Name') ?></th>
<th><?= Yii::t('user', 'Description') ?></th>
<th><?= Yii::t('user', 'Created At') ?></th>
<th class="text-end"><?= Yii::t('user', 'Actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($permissions as $permission): ?>
<tr>
<td><strong><?= Html::encode($permission->name) ?></strong></td>
<td><?= Html::encode($permission->description) ?></td>
<td>
<?php if ($permission->createdAt): ?>
<?= Yii::$app->formatter->asDatetime($permission->createdAt) ?>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-end">
<?= Html::a(Yii::t('user', 'Edit'), ['update-permission', 'name' => $permission->name], ['class' => 'btn btn-sm btn-primary']) ?>
<?= Html::a(Yii::t('user', 'Delete'), ['delete-permission', 'name' => $permission->name], [
'class' => 'btn btn-sm btn-danger',
'data' => [
'confirm' => Yii::t('user', 'Are you sure you want to delete this permission?'),
'method' => 'post',
],
]) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,103 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\RoleForm $model
* @var yii\rbac\Permission[] $permissions
* @var yii\rbac\Role[] $roles
* @var cgsmith\user\Module $module
* @var bool $isNew
*/
use yii\helpers\ArrayHelper;
use yii\helpers\Html;
use yii\widgets\ActiveForm;
$this->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;
?>
<div class="rbac-role-form">
<h1><?= Html::encode($this->title) ?></h1>
<div class="card">
<div class="card-body">
<?php $form = ActiveForm::begin(); ?>
<?= $form->field($model, 'name')->textInput(['maxlength' => 64, 'readonly' => !$isNew]) ?>
<?= $form->field($model, 'description')->textarea(['rows' => 3, 'maxlength' => 255]) ?>
<?php if (!empty($permissions)): ?>
<div class="mb-3">
<label class="form-label"><?= Yii::t('user', 'Permissions') ?></label>
<div class="row">
<?php foreach ($permissions as $permission): ?>
<div class="col-md-4">
<div class="form-check">
<?= Html::checkbox(
'RoleForm[permissions][]',
in_array($permission->name, $model->permissions),
[
'value' => $permission->name,
'id' => 'permission-' . $permission->name,
'class' => 'form-check-input',
]
) ?>
<label class="form-check-label" for="permission-<?= $permission->name ?>">
<?= Html::encode($permission->name) ?>
<?php if ($permission->description): ?>
<br><small class="text-muted"><?= Html::encode($permission->description) ?></small>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($roles)): ?>
<div class="mb-3">
<label class="form-label"><?= Yii::t('user', 'Child Roles') ?></label>
<p class="text-muted small"><?= Yii::t('user', 'This role will inherit all permissions from selected child roles.') ?></p>
<div class="row">
<?php foreach ($roles as $role): ?>
<div class="col-md-4">
<div class="form-check">
<?= Html::checkbox(
'RoleForm[childRoles][]',
in_array($role->name, $model->childRoles),
[
'value' => $role->name,
'id' => 'child-role-' . $role->name,
'class' => 'form-check-input',
]
) ?>
<label class="form-check-label" for="child-role-<?= $role->name ?>">
<?= Html::encode($role->name) ?>
<?php if ($role->description): ?>
<br><small class="text-muted"><?= Html::encode($role->description) ?></small>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="form-group">
<?= Html::submitButton(
$isNew ? Yii::t('user', 'Create') : Yii::t('user', 'Update'),
['class' => $isNew ? 'btn btn-success' : 'btn btn-primary']
) ?>
<?= Html::a(Yii::t('user', 'Cancel'), ['roles'], ['class' => 'btn btn-secondary']) ?>
</div>
<?php ActiveForm::end(); ?>
</div>
</div>
</div>

66
src/views/rbac/roles.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
/**
* @var yii\web\View $this
* @var yii\rbac\Role[] $roles
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->title = Yii::t('user', 'Roles');
$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'RBAC Management'), 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="rbac-roles">
<h1><?= Html::encode($this->title) ?></h1>
<p>
<?= Html::a(Yii::t('user', 'Create Role'), ['create-role'], ['class' => 'btn btn-success']) ?>
</p>
<div class="card">
<div class="card-body">
<?php if (empty($roles)): ?>
<p class="text-muted"><?= Yii::t('user', 'No roles have been created yet.') ?></p>
<?php else: ?>
<table class="table table-striped">
<thead>
<tr>
<th><?= Yii::t('user', 'Name') ?></th>
<th><?= Yii::t('user', 'Description') ?></th>
<th><?= Yii::t('user', 'Created At') ?></th>
<th class="text-end"><?= Yii::t('user', 'Actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($roles as $role): ?>
<tr>
<td><strong><?= Html::encode($role->name) ?></strong></td>
<td><?= Html::encode($role->description) ?></td>
<td>
<?php if ($role->createdAt): ?>
<?= Yii::$app->formatter->asDatetime($role->createdAt) ?>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-end">
<?= Html::a(Yii::t('user', 'Edit'), ['update-role', 'name' => $role->name], ['class' => 'btn btn-sm btn-primary']) ?>
<?= Html::a(Yii::t('user', 'Delete'), ['delete-role', 'name' => $role->name], [
'class' => 'btn btn-sm btn-danger',
'data' => [
'confirm' => Yii::t('user', 'Are you sure you want to delete this role?'),
'method' => 'post',
],
]) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,79 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\SocialAccount[] $connectedAccounts
* @var yii\authclient\ClientInterface[] $availableClients
* @var string[] $connectedProviders
* @var cgsmith\user\Module $module
*/
use yii\authclient\widgets\AuthChoice;
use yii\helpers\Html;
$this->title = Yii::t('user', 'Connected Networks');
$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Settings'), 'url' => ['settings/account']];
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="user-social-networks">
<h1><?= Html::encode($this->title) ?></h1>
<p class="text-muted"><?= Yii::t('user', 'Connect your social accounts to enable quick sign-in.') ?></p>
<?php if (!empty($connectedAccounts)): ?>
<h3><?= Yii::t('user', 'Connected Accounts') ?></h3>
<div class="connected-accounts mb-4">
<?php foreach ($connectedAccounts as $account): ?>
<div class="card mb-2">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<strong><?= Html::encode(ucfirst($account->provider)) ?></strong>
<?php if ($account->username): ?>
<span class="text-muted">- <?= Html::encode($account->username) ?></span>
<?php elseif ($account->email): ?>
<span class="text-muted">- <?= Html::encode($account->email) ?></span>
<?php endif; ?>
<br>
<small class="text-muted"><?= Yii::t('user', 'Connected {date}', [
'date' => Yii::$app->formatter->asRelativeTime($account->created_at)
]) ?></small>
</div>
<?= Html::a(
Yii::t('user', 'Disconnect'),
['disconnect', 'id' => $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?'),
]
) ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($availableClients)): ?>
<?php
$unconnectedClients = array_filter($availableClients, fn($client) => !in_array($client->getId(), $connectedProviders));
?>
<?php if (!empty($unconnectedClients)): ?>
<h3><?= Yii::t('user', 'Connect a New Account') ?></h3>
<div class="available-clients">
<?= AuthChoice::widget([
'baseAuthUrl' => ['/' . $module->urlPrefix . '/auth'],
'popupMode' => false,
'clients' => $unconnectedClients,
]) ?>
</div>
<?php else: ?>
<div class="alert alert-info">
<?= Yii::t('user', 'All available social networks are already connected.') ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="alert alert-info">
<?= Yii::t('user', 'No social networks are configured.') ?>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,70 @@
<?php
/**
* @var yii\web\View $this
* @var array|null $backupCodes
* @var int $backupCodesCount
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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;
?>
<div class="user-two-factor-backup-codes">
<h1><?= Html::encode($this->title) ?></h1>
<?php if ($backupCodes): ?>
<div class="alert alert-warning">
<strong><?= Yii::t('user', 'Save these backup codes!') ?></strong>
<p><?= Yii::t('user', 'Store these codes in a safe place. You can use them to sign in if you lose access to your authenticator app. Each code can only be used once.') ?></p>
</div>
<div class="backup-codes-list card">
<div class="card-body">
<div class="row">
<?php foreach ($backupCodes as $code): ?>
<div class="col-6 col-md-4 mb-2">
<code class="d-block text-center py-2 bg-light"><?= Html::encode($code) ?></code>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<p class="text-muted mt-3">
<?= Yii::t('user', 'These codes will not be shown again. Make sure to save them now.') ?>
</p>
<?php else: ?>
<p><?= Yii::t('user', 'You have {count} backup codes remaining.', ['count' => $backupCodesCount]) ?></p>
<?php if ($backupCodesCount < 3): ?>
<div class="alert alert-warning">
<?= Yii::t('user', 'You are running low on backup codes. Consider regenerating them.') ?>
</div>
<?php endif; ?>
<?php endif; ?>
<hr>
<h3><?= Yii::t('user', 'Regenerate Backup Codes') ?></h3>
<p class="text-muted"><?= Yii::t('user', 'Regenerating will invalidate all existing backup codes.') ?></p>
<?= Html::beginForm(['regenerate-backup-codes'], 'post') ?>
<?= Html::submitButton(
Yii::t('user', 'Regenerate Backup Codes'),
[
'class' => 'btn btn-warning',
'data-confirm' => Yii::t('user', 'Are you sure? This will invalidate all existing backup codes.'),
]
) ?>
<?= Html::endForm() ?>
<div class="mt-3">
<?= Html::a(Yii::t('user', 'Back to Two-Factor Settings'), ['index'], ['class' => 'btn btn-secondary']) ?>
</div>
</div>

View File

@@ -0,0 +1,96 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\Module $module
* @var bool $isEnabled
* @var int $backupCodesCount
* @var cgsmith\user\models\TwoFactorSetupForm|null $setupForm
* @var string|null $secret
* @var string|null $qrCodeDataUri
*/
use yii\helpers\Html;
$formClass = $module->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;
?>
<div class="user-two-factor-index">
<h1><?= Html::encode($this->title) ?></h1>
<?php if ($isEnabled): ?>
<div class="alert alert-success">
<strong><?= Yii::t('user', 'Two-factor authentication is enabled.') ?></strong>
</div>
<p><?= Yii::t('user', 'You have {count} backup codes remaining.', ['count' => $backupCodesCount]) ?></p>
<div class="mb-3">
<?= Html::a(
Yii::t('user', 'View Backup Codes'),
['backup-codes'],
['class' => 'btn btn-secondary']
) ?>
</div>
<hr>
<h3><?= Yii::t('user', 'Disable Two-Factor Authentication') ?></h3>
<p class="text-muted"><?= Yii::t('user', 'Disabling two-factor authentication will make your account less secure.') ?></p>
<?= Html::beginForm(['disable'], 'post') ?>
<?= Html::submitButton(
Yii::t('user', 'Disable Two-Factor Authentication'),
[
'class' => 'btn btn-danger',
'data-confirm' => Yii::t('user', 'Are you sure you want to disable two-factor authentication?'),
]
) ?>
<?= Html::endForm() ?>
<?php else: ?>
<div class="alert alert-warning">
<?= Yii::t('user', 'Two-factor authentication is not enabled. Enable it to add an extra layer of security to your account.') ?>
</div>
<h3><?= Yii::t('user', 'Set Up Two-Factor Authentication') ?></h3>
<ol>
<li><?= Yii::t('user', 'Install an authenticator app on your phone (e.g., Google Authenticator, Authy, 1Password).') ?></li>
<li><?= Yii::t('user', 'Scan the QR code below with your authenticator app.') ?></li>
<li><?= Yii::t('user', 'Enter the 6-digit code from your app to verify setup.') ?></li>
</ol>
<?php if ($qrCodeDataUri): ?>
<div class="qr-code mb-3">
<img src="<?= $qrCodeDataUri ?>" alt="QR Code" style="max-width: 200px;">
</div>
<?php endif; ?>
<p class="text-muted">
<?= Yii::t('user', "Can't scan the code? Enter this key manually:") ?>
<code><?= Html::encode($secret) ?></code>
</p>
<?php $form = $formClass::begin(['action' => ['enable']] + $module->formFieldConfig) ?>
<?= Html::activeHiddenInput($setupForm, 'secret') ?>
<?= $form->field($setupForm, 'code')->textInput([
'autofocus' => true,
'autocomplete' => 'one-time-code',
'inputmode' => 'numeric',
'pattern' => '[0-9]*',
'maxlength' => 6,
]) ?>
<div class="form-group">
<?= Html::submitButton(Yii::t('user', 'Enable Two-Factor Authentication'), ['class' => 'btn btn-primary']) ?>
</div>
<?php $formClass::end() ?>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,40 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\TwoFactorForm $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$formClass = $module->activeFormClass;
$this->title = Yii::t('user', 'Two-Factor Verification');
?>
<div class="user-two-factor-verify">
<h1><?= Html::encode($this->title) ?></h1>
<p><?= Yii::t('user', 'Enter the 6-digit code from your authenticator app, or use one of your backup codes.') ?></p>
<?php $form = $formClass::begin(['id' => 'two-factor-form'] + $module->formFieldConfig) ?>
<?= $form->field($model, 'code')->textInput([
'autofocus' => true,
'autocomplete' => 'one-time-code',
'inputmode' => 'numeric',
'placeholder' => Yii::t('user', 'Enter code'),
]) ?>
<div class="form-group">
<?= Html::submitButton(Yii::t('user', 'Verify'), ['class' => 'btn btn-primary btn-block']) ?>
</div>
<?php $formClass::end() ?>
<p class="text-muted mt-3">
<small>
<?= Yii::t('user', "Lost your phone? Use one of your backup codes to sign in.") ?>
</small>
</p>
</div>

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\widgets;
use cgsmith\user\Module;
use cgsmith\user\services\SocialAuthService;
use Yii;
use yii\authclient\widgets\AuthChoice;
use yii\base\Widget;
/**
* Widget for displaying social authentication buttons.
*
* Usage:
* ```php
* <?= SocialConnect::widget() ?>
* ```
*/
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,
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/_bootstrap.php';
return [
'id' => '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,
],
],
];

49
tests/UnitBootstrap.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/_bootstrap.php';
$config = [
'id' => '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);

12
tests/_bootstrap.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'test');
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
Yii::setAlias('@cgsmith/user', dirname(__DIR__) . '/src');
Yii::setAlias('@tests', __DIR__);

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace tests\_support\Helper;
use Codeception\Module;
class Functional extends Module
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace tests\_support\Helper;
use Codeception\Module;
class Unit extends Module
{
}

View File

@@ -0,0 +1,8 @@
actor: FunctionalTester
modules:
enabled:
- Yii2:
part: [orm, fixtures]
configFile: 'FunctionalConfig.php'
- Asserts
- \tests\_support\Helper\Functional

6
tests/unit.suite.yml Normal file
View File

@@ -0,0 +1,6 @@
actor: UnitTester
modules:
enabled:
- Asserts
- \tests\_support\Helper\Unit
bootstrap: UnitBootstrap.php

185
tests/unit/ModuleTest.php Normal file
View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace tests\unit;
use Codeception\Test\Unit;
use cgsmith\user\Module;
class ModuleTest extends Unit
{
private Module $module;
protected function _before(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace tests\unit\helpers;
use Codeception\Test\Unit;
use cgsmith\user\helpers\Password;
class PasswordTest extends Unit
{
public function testHashCreatesValidHash(): void
{
$password = 'testPassword123!';
$hash = Password::hash($password);
$this->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']);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace tests\unit\services;
use Codeception\Test\Unit;
use cgsmith\user\Module;
use cgsmith\user\services\CaptchaService;
class CaptchaServiceTest extends Unit
{
private Module $module;
private CaptchaService $service;
protected function _before(): void
{
$this->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);
}
}