mirror of
https://github.com/cgsmith/yii2-user.git
synced 2026-02-04 00:02:37 -06:00
RBAC and social controller
This commit is contained in:
386
README.md
386
README.md
@@ -16,6 +16,24 @@ A modern, actively maintained user management module for Yii2. Built as a spirit
|
|||||||
composer require cgsmith/yii2-user
|
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
|
### Local Development
|
||||||
|
|
||||||
Add to your `composer.json`:
|
Add to your `composer.json`:
|
||||||
@@ -108,19 +126,24 @@ return [
|
|||||||
| Last Login Tracking | ✅ | ✅ | ✅ |
|
| Last Login Tracking | ✅ | ✅ | ✅ |
|
||||||
| Email Change Strategies | ✅ | ✅ | ✅ |
|
| Email Change Strategies | ✅ | ✅ | ✅ |
|
||||||
| CSRF Protection | ✅ | ✅ | ✅ |
|
| CSRF Protection | ✅ | ✅ | ✅ |
|
||||||
|
| Two-Factor Auth (TOTP) | ❌ | ❌ | ✅ |
|
||||||
|
| Session History | ❌ | ❌ | ✅ |
|
||||||
|
| CAPTCHA Support | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
### Advanced Features
|
### Advanced Features
|
||||||
|
|
||||||
| Feature | dektrium | usuario | cgsmith |
|
| Feature | dektrium | usuario | cgsmith |
|
||||||
|-------------------------|:--------:|:-------:|:-------:|
|
|-------------------------|:--------:|:-------:|:-------:|
|
||||||
| Social Authentication | ✅ | ✅ | 🔄 v2 |
|
| Social Authentication | ✅ | ✅ | ✅ |
|
||||||
| Two-Factor Auth (2FA) | ❌ | ❌ | 🔄 v2 |
|
| GDPR Compliance | ❌ | ✅ | ✅ |
|
||||||
| GDPR Compliance | ❌ | ✅ | 🔄 v2 |
|
| GDPR Consent Management | ❌ | ❌ | ✅ |
|
||||||
| Data Export | ❌ | ✅ | 🔄 v2 |
|
| Data Export | ❌ | ✅ | ✅ |
|
||||||
| Account Deletion | ❌ | ✅ | 🔄 v2 |
|
| Account Deletion | ❌ | ✅ | ✅ |
|
||||||
| User Impersonation | ✅ | ✅ | ✅ |
|
| User Impersonation | ✅ | ✅ | ✅ |
|
||||||
| Gravatar Support | ✅ | ✅ | ✅ |
|
| Gravatar Support | ✅ | ✅ | ✅ |
|
||||||
| Avatar Upload | ❌ | ❌ | ✅ |
|
| Avatar Upload | ❌ | ❌ | ✅ |
|
||||||
|
| RBAC Management UI | ❌ | ❌ | ✅ |
|
||||||
|
| Session Separation | ❌ | ❌ | ✅ |
|
||||||
| Migration from dektrium | N/A | ✅ | ✅ |
|
| Migration from dektrium | N/A | ✅ | ✅ |
|
||||||
| Migration from usuario | N/A | N/A | ✅ |
|
| Migration from usuario | N/A | N/A | ✅ |
|
||||||
|
|
||||||
@@ -136,17 +159,19 @@ return [
|
|||||||
|
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
|
|
||||||
|
### Core Options
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|---------------------------|--------|-----------------------------------------|-------------------------------------|
|
|---------------------------|--------|-----------------------------------------|-------------------------------------|
|
||||||
| `enableRegistration` | bool | `true` | Enable/disable user registration |
|
| `enableRegistration` | bool | `true` | Enable/disable user registration |
|
||||||
| `enableConfirmation` | bool | `true` | Require email confirmation |
|
| `enableConfirmation` | bool | `true` | Require email confirmation |
|
||||||
| `enableUnconfirmedLogin` | bool | `false` | Allow login without confirmation |
|
| `enableUnconfirmedLogin` | bool | `false` | Allow login without confirmation |
|
||||||
| `enablePasswordRecovery` | bool | `true` | Enable password recovery |
|
| `enablePasswordRecovery` | bool | `true` | Enable password recovery |
|
||||||
| `enableGdpr` | bool | `false` | Enable GDPR features (v2) |
|
|
||||||
| `enableImpersonation` | bool | `true` | Enable admin impersonation |
|
| `enableImpersonation` | bool | `true` | Enable admin impersonation |
|
||||||
| `enableGeneratedPassword` | bool | `false` | Auto-generate passwords |
|
| `enableGeneratedPassword` | bool | `false` | Auto-generate passwords |
|
||||||
| `enableGravatar` | bool | `true` | Enable Gravatar support |
|
| `enableGravatar` | bool | `true` | Enable Gravatar support |
|
||||||
| `enableAvatarUpload` | bool | `true` | Enable local avatar uploads |
|
| `enableAvatarUpload` | bool | `true` | Enable local avatar uploads |
|
||||||
|
| `enableAccountDelete` | bool | `true` | Allow users to delete accounts |
|
||||||
| `emailChangeStrategy` | int | `1` | Email change strategy (0-2) |
|
| `emailChangeStrategy` | int | `1` | Email change strategy (0-2) |
|
||||||
| `rememberFor` | int | `1209600` | Remember me duration (seconds) |
|
| `rememberFor` | int | `1209600` | Remember me duration (seconds) |
|
||||||
| `confirmWithin` | int | `86400` | Confirmation token expiry (seconds) |
|
| `confirmWithin` | int | `86400` | Confirmation token expiry (seconds) |
|
||||||
@@ -163,6 +188,298 @@ return [
|
|||||||
| `maxAvatarSize` | int | `2097152` | Max avatar file size (bytes) |
|
| `maxAvatarSize` | int | `2097152` | Max avatar file size (bytes) |
|
||||||
| `avatarExtensions` | array | `['jpg', 'jpeg', 'png', 'gif', 'webp']` | Allowed avatar extensions |
|
| `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
|
## Console Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -224,6 +541,14 @@ Available events:
|
|||||||
- `RecoveryController::EVENT_AFTER_REQUEST`
|
- `RecoveryController::EVENT_AFTER_REQUEST`
|
||||||
- `RecoveryController::EVENT_BEFORE_RESET`
|
- `RecoveryController::EVENT_BEFORE_RESET`
|
||||||
- `RecoveryController::EVENT_AFTER_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
|
## 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
|
## Migration from dektrium/yii2-user
|
||||||
|
|
||||||
1. Install cgsmith/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
|
### Authentication & Security
|
||||||
|
|
||||||
- [ ] **Two-Factor Authentication (2FA)**
|
|
||||||
- TOTP (Google Authenticator, Authy)
|
|
||||||
- SMS verification
|
|
||||||
- Backup codes
|
|
||||||
- Per-user 2FA enforcement
|
|
||||||
- [ ] **Passwordless Authentication**
|
- [ ] **Passwordless Authentication**
|
||||||
- Magic link login
|
- Magic link login
|
||||||
- WebAuthn/FIDO2 support
|
- WebAuthn/FIDO2 support
|
||||||
- [ ] **Enhanced Session Management**
|
- [ ] **Enhanced Brute Force Protection**
|
||||||
- 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**
|
|
||||||
- Rate limiting per IP/user
|
- Rate limiting per IP/user
|
||||||
- Progressive delays
|
- Progressive delays
|
||||||
- CAPTCHA integration (reCAPTCHA v3, hCaptcha)
|
|
||||||
- [ ] **Password Policies**
|
- [ ] **Password Policies**
|
||||||
- Password strength meter
|
- Password strength meter
|
||||||
- Common password blocklist
|
- Common password blocklist
|
||||||
@@ -434,11 +721,6 @@ The following features are planned for version 2.0:
|
|||||||
|
|
||||||
### Compliance
|
### Compliance
|
||||||
|
|
||||||
- [ ] **Enhanced GDPR**
|
|
||||||
- Right to be forgotten workflow
|
|
||||||
- Data retention policies
|
|
||||||
- Consent management
|
|
||||||
- Cookie consent integration
|
|
||||||
- [ ] **Accessibility**
|
- [ ] **Accessibility**
|
||||||
- WCAG 2.1 AA compliance
|
- WCAG 2.1 AA compliance
|
||||||
- Screen reader support
|
- Screen reader support
|
||||||
|
|||||||
21
codeception.yml
Normal file
21
codeception.yml
Normal 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/*
|
||||||
@@ -19,8 +19,18 @@
|
|||||||
"yiisoft/yii2": "~2.0.0"
|
"yiisoft/yii2": "~2.0.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^10.0",
|
"codeception/codeception": "^5.0",
|
||||||
"phpstan/phpstan": "^1.10"
|
"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": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
19
phpstan.neon
Normal file
19
phpstan.neon
Normal 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
|
||||||
@@ -114,6 +114,14 @@ class Bootstrap implements BootstrapInterface
|
|||||||
return new \cgsmith\user\services\CaptchaService($module);
|
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
|
// Bind module for injection
|
||||||
$container->setSingleton(Module::class, function () use ($module) {
|
$container->setSingleton(Module::class, function () use ($module) {
|
||||||
return $module;
|
return $module;
|
||||||
@@ -170,6 +178,21 @@ class Bootstrap implements BootstrapInterface
|
|||||||
$rules['gdpr/consent'] = 'gdpr/consent';
|
$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;
|
return $rules;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,52 @@ class Module extends BaseModule implements BootstrapInterface
|
|||||||
*/
|
*/
|
||||||
public array $captchaForms = ['register'];
|
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.
|
* Email change strategy.
|
||||||
*/
|
*/
|
||||||
@@ -426,6 +472,18 @@ class Module extends BaseModule implements BootstrapInterface
|
|||||||
return new \cgsmith\user\services\CaptchaService($this);
|
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 () {
|
$container->setSingleton(Module::class, function () {
|
||||||
return $this;
|
return $this;
|
||||||
});
|
});
|
||||||
@@ -459,6 +517,7 @@ class Module extends BaseModule implements BootstrapInterface
|
|||||||
'admin/unblock/<id:\d+>' => 'admin/unblock',
|
'admin/unblock/<id:\d+>' => 'admin/unblock',
|
||||||
'admin/confirm/<id:\d+>' => 'admin/confirm',
|
'admin/confirm/<id:\d+>' => 'admin/confirm',
|
||||||
'admin/impersonate/<id:\d+>' => 'admin/impersonate',
|
'admin/impersonate/<id:\d+>' => 'admin/impersonate',
|
||||||
|
'admin/assignments/<id:\d+>' => 'admin/assignments',
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->enableGdpr) {
|
if ($this->enableGdpr) {
|
||||||
@@ -471,6 +530,33 @@ class Module extends BaseModule implements BootstrapInterface
|
|||||||
$rules['gdpr/consent'] = 'gdpr/consent';
|
$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;
|
return $rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace cgsmith\user\controllers;
|
namespace cgsmith\user\controllers;
|
||||||
|
|
||||||
use cgsmith\user\filters\AccessRule;
|
use cgsmith\user\filters\AccessRule;
|
||||||
|
use cgsmith\user\models\AssignmentForm;
|
||||||
use cgsmith\user\models\User;
|
use cgsmith\user\models\User;
|
||||||
use cgsmith\user\models\UserSearch;
|
use cgsmith\user\models\UserSearch;
|
||||||
use cgsmith\user\Module;
|
use cgsmith\user\Module;
|
||||||
use cgsmith\user\services\RegistrationService;
|
|
||||||
use cgsmith\user\services\MailerService;
|
use cgsmith\user\services\MailerService;
|
||||||
|
use cgsmith\user\services\RbacService;
|
||||||
|
use cgsmith\user\services\RegistrationService;
|
||||||
use cgsmith\user\services\UserService;
|
use cgsmith\user\services\UserService;
|
||||||
use Yii;
|
use Yii;
|
||||||
use yii\filters\AccessControl;
|
use yii\filters\AccessControl;
|
||||||
@@ -340,6 +342,49 @@ class AdminController extends Controller
|
|||||||
return $this->redirect(['index']);
|
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.
|
* Find user by ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
243
src/controllers/RbacController.php
Normal file
243
src/controllers/RbacController.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use cgsmith\user\models\LoginForm;
|
|||||||
use cgsmith\user\models\User;
|
use cgsmith\user\models\User;
|
||||||
use cgsmith\user\Module;
|
use cgsmith\user\Module;
|
||||||
use cgsmith\user\services\SessionService;
|
use cgsmith\user\services\SessionService;
|
||||||
|
use cgsmith\user\services\TwoFactorService;
|
||||||
use Yii;
|
use Yii;
|
||||||
use yii\filters\AccessControl;
|
use yii\filters\AccessControl;
|
||||||
use yii\filters\VerbFilter;
|
use yii\filters\VerbFilter;
|
||||||
@@ -71,20 +72,32 @@ class SecurityController extends Controller
|
|||||||
$event = new FormEvent(['form' => $model]);
|
$event = new FormEvent(['form' => $model]);
|
||||||
$module->trigger(self::EVENT_BEFORE_LOGIN, $event);
|
$module->trigger(self::EVENT_BEFORE_LOGIN, $event);
|
||||||
|
|
||||||
if ($model->load(Yii::$app->request->post()) && $model->login()) {
|
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
|
||||||
// Track session
|
$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) {
|
if ($module->enableSessionHistory) {
|
||||||
/** @var SessionService $sessionService */
|
/** @var SessionService $sessionService */
|
||||||
$sessionService = Yii::$container->get(SessionService::class);
|
$sessionService = Yii::$container->get(SessionService::class);
|
||||||
$sessionService->trackSession(Yii::$app->user->identity);
|
$sessionService->trackSession(Yii::$app->user->identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger after login event
|
|
||||||
$event = new FormEvent(['form' => $model]);
|
$event = new FormEvent(['form' => $model]);
|
||||||
$module->trigger(self::EVENT_AFTER_LOGIN, $event);
|
$module->trigger(self::EVENT_AFTER_LOGIN, $event);
|
||||||
|
|
||||||
return $this->goBack();
|
return $this->goBack();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $this->render('login', [
|
return $this->render('login', [
|
||||||
'model' => $model,
|
'model' => $model,
|
||||||
|
|||||||
150
src/controllers/SocialController.php
Normal file
150
src/controllers/SocialController.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/controllers/TwoFactorController.php
Normal file
248
src/controllers/TwoFactorController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/events/SocialAuthEvent.php
Normal file
26
src/events/SocialAuthEvent.php
Normal 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;
|
||||||
|
}
|
||||||
22
src/events/TwoFactorEvent.php
Normal file
22
src/events/TwoFactorEvent.php
Normal 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;
|
||||||
|
}
|
||||||
@@ -190,4 +190,161 @@ return [
|
|||||||
'Add uppercase letters.' => 'Add uppercase letters.',
|
'Add uppercase letters.' => 'Add uppercase letters.',
|
||||||
'Add numbers.' => 'Add numbers.',
|
'Add numbers.' => 'Add numbers.',
|
||||||
'Add special characters.' => 'Add special characters.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|||||||
70
src/models/AssignmentForm.php
Normal file
70
src/models/AssignmentForm.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/models/PermissionForm.php
Normal file
101
src/models/PermissionForm.php
Normal 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
155
src/models/RoleForm.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/models/SocialAccount.php
Normal file
128
src/models/SocialAccount.php
Normal 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
119
src/models/TwoFactor.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/models/TwoFactorForm.php
Normal file
38
src/models/TwoFactorForm.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/models/TwoFactorSetupForm.php
Normal file
42
src/models/TwoFactorSetupForm.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ use yii\web\IdentityInterface;
|
|||||||
* @property-read Profile $profile
|
* @property-read Profile $profile
|
||||||
* @property-read Token[] $tokens
|
* @property-read Token[] $tokens
|
||||||
* @property-read Session[] $sessions
|
* @property-read Session[] $sessions
|
||||||
|
* @property-read TwoFactor|null $twoFactor
|
||||||
|
* @property-read SocialAccount[] $socialAccounts
|
||||||
*/
|
*/
|
||||||
class User extends ActiveRecord implements IdentityInterface, UserInterface
|
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']);
|
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
|
// Helper methods
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
57
src/models/query/SocialAccountQuery.php
Normal file
57
src/models/query/SocialAccountQuery.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/models/query/TwoFactorQuery.php
Normal file
33
src/models/query/TwoFactorQuery.php
Normal 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]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
379
src/services/RbacService.php
Normal file
379
src/services/RbacService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
306
src/services/SocialAuthService.php
Normal file
306
src/services/SocialAuthService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
320
src/services/TwoFactorService.php
Normal file
320
src/services/TwoFactorService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,69 @@
|
|||||||
/**
|
/**
|
||||||
* @var yii\web\View $this
|
* @var yii\web\View $this
|
||||||
* @var cgsmith\user\models\User $user
|
* @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]) ?>
|
<?php $this->beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?>
|
||||||
|
|
||||||
<div class="user-alert user-alert-info">
|
<?php if (Yii::$app->authManager === null): ?>
|
||||||
<?= Yii::t('user', 'You can assign multiple roles or permissions to user by using the form below') ?>
|
<div class="alert alert-warning">
|
||||||
</div>
|
<?= Yii::t('user', 'RBAC is not configured. Configure authManager in your application to use this feature.') ?>
|
||||||
|
</div>
|
||||||
<?php if (Yii::$app->authManager !== null): ?>
|
<?php elseif (empty($roles)): ?>
|
||||||
<p><?= Yii::t('user', 'RBAC assignment widget would go here. Configure your RBAC module to enable this feature.') ?></p>
|
<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 else: ?>
|
||||||
<p class="text-muted"><?= Yii::t('user', 'RBAC is not configured. Configure authManager in your application to use this feature.') ?></p>
|
|
||||||
|
<?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>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<?= Html::submitButton(Yii::t('user', 'Update Assignments'), ['class' => 'btn btn-primary']) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php ActiveForm::end(); ?>
|
||||||
|
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<?php $this->endContent() ?>
|
<?php $this->endContent() ?>
|
||||||
|
|||||||
80
src/views/rbac/index.php
Normal file
80
src/views/rbac/index.php
Normal 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>
|
||||||
45
src/views/rbac/permission-form.php
Normal file
45
src/views/rbac/permission-form.php
Normal 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>
|
||||||
66
src/views/rbac/permissions.php
Normal file
66
src/views/rbac/permissions.php
Normal 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>
|
||||||
103
src/views/rbac/role-form.php
Normal file
103
src/views/rbac/role-form.php
Normal 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
66
src/views/rbac/roles.php
Normal 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>
|
||||||
79
src/views/social/networks.php
Normal file
79
src/views/social/networks.php
Normal 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>
|
||||||
70
src/views/two-factor/backup-codes.php
Normal file
70
src/views/two-factor/backup-codes.php
Normal 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>
|
||||||
96
src/views/two-factor/index.php
Normal file
96
src/views/two-factor/index.php
Normal 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>
|
||||||
40
src/views/two-factor/verify.php
Normal file
40
src/views/two-factor/verify.php
Normal 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>
|
||||||
54
src/widgets/SocialConnect.php
Normal file
54
src/widgets/SocialConnect.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/FunctionalConfig.php
Normal file
48
tests/FunctionalConfig.php
Normal 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
49
tests/UnitBootstrap.php
Normal 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
12
tests/_bootstrap.php
Normal 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__);
|
||||||
11
tests/_support/Helper/Functional.php
Normal file
11
tests/_support/Helper/Functional.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace tests\_support\Helper;
|
||||||
|
|
||||||
|
use Codeception\Module;
|
||||||
|
|
||||||
|
class Functional extends Module
|
||||||
|
{
|
||||||
|
}
|
||||||
11
tests/_support/Helper/Unit.php
Normal file
11
tests/_support/Helper/Unit.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace tests\_support\Helper;
|
||||||
|
|
||||||
|
use Codeception\Module;
|
||||||
|
|
||||||
|
class Unit extends Module
|
||||||
|
{
|
||||||
|
}
|
||||||
8
tests/functional.suite.yml
Normal file
8
tests/functional.suite.yml
Normal 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
6
tests/unit.suite.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
actor: UnitTester
|
||||||
|
modules:
|
||||||
|
enabled:
|
||||||
|
- Asserts
|
||||||
|
- \tests\_support\Helper\Unit
|
||||||
|
bootstrap: UnitBootstrap.php
|
||||||
185
tests/unit/ModuleTest.php
Normal file
185
tests/unit/ModuleTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
tests/unit/helpers/PasswordTest.php
Normal file
139
tests/unit/helpers/PasswordTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
tests/unit/services/CaptchaServiceTest.php
Normal file
108
tests/unit/services/CaptchaServiceTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user