mirror of
https://github.com/cgsmith/yii2-user.git
synced 2026-02-03 15:52:37 -06:00
Add gdpr, session history, captcha
This commit is contained in:
@@ -102,6 +102,18 @@ class Bootstrap implements BootstrapInterface
|
||||
return new \cgsmith\user\services\MailerService($module);
|
||||
});
|
||||
|
||||
$container->setSingleton('cgsmith\user\services\SessionService', function () use ($module) {
|
||||
return new \cgsmith\user\services\SessionService($module);
|
||||
});
|
||||
|
||||
$container->setSingleton('cgsmith\user\services\GdprService', function () use ($module) {
|
||||
return new \cgsmith\user\services\GdprService($module);
|
||||
});
|
||||
|
||||
$container->setSingleton('cgsmith\user\services\CaptchaService', function () use ($module) {
|
||||
return new \cgsmith\user\services\CaptchaService($module);
|
||||
});
|
||||
|
||||
// Bind module for injection
|
||||
$container->setSingleton(Module::class, function () use ($module) {
|
||||
return $module;
|
||||
@@ -131,6 +143,9 @@ class Bootstrap implements BootstrapInterface
|
||||
'settings' => 'settings/account',
|
||||
'settings/account' => 'settings/account',
|
||||
'settings/profile' => 'settings/profile',
|
||||
'settings/sessions' => 'settings/sessions',
|
||||
'settings/sessions/terminate/<id:\d+>' => 'settings/terminate-session',
|
||||
'settings/sessions/terminate-all' => 'settings/terminate-all-sessions',
|
||||
|
||||
// Admin
|
||||
'admin' => 'admin/index',
|
||||
@@ -151,6 +166,10 @@ class Bootstrap implements BootstrapInterface
|
||||
$rules['gdpr/delete'] = 'gdpr/delete';
|
||||
}
|
||||
|
||||
if ($module->enableGdprConsent) {
|
||||
$rules['gdpr/consent'] = 'gdpr/consent';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
|
||||
110
src/Module.php
110
src/Module.php
@@ -49,10 +49,34 @@ class Module extends BaseModule implements BootstrapInterface
|
||||
|
||||
/**
|
||||
* Whether to enable GDPR features (data export, account deletion).
|
||||
* @todo GDPR is not yet fully implemented - planned for v2
|
||||
*/
|
||||
public bool $enableGdpr = false;
|
||||
|
||||
/**
|
||||
* Whether to enable GDPR consent tracking.
|
||||
*/
|
||||
public bool $enableGdprConsent = false;
|
||||
|
||||
/**
|
||||
* Whether to require GDPR consent during registration.
|
||||
*/
|
||||
public bool $requireGdprConsentBeforeRegistration = true;
|
||||
|
||||
/**
|
||||
* Current GDPR consent version. When updated, users will be prompted to re-consent.
|
||||
*/
|
||||
public ?string $gdprConsentVersion = '1.0';
|
||||
|
||||
/**
|
||||
* URL to the privacy policy page.
|
||||
*/
|
||||
public ?string $gdprConsentUrl = null;
|
||||
|
||||
/**
|
||||
* Routes exempt from GDPR consent check. Supports wildcards (e.g., 'site/*').
|
||||
*/
|
||||
public array $gdprExemptRoutes = [];
|
||||
|
||||
/**
|
||||
* Whether to enable user impersonation by admins.
|
||||
*/
|
||||
@@ -83,6 +107,71 @@ class Module extends BaseModule implements BootstrapInterface
|
||||
*/
|
||||
public bool $enableAccountDelete = true;
|
||||
|
||||
/**
|
||||
* Whether to enable session history tracking.
|
||||
*/
|
||||
public bool $enableSessionHistory = false;
|
||||
|
||||
/**
|
||||
* Maximum number of sessions to track per user.
|
||||
*/
|
||||
public int $sessionHistoryLimit = 10;
|
||||
|
||||
/**
|
||||
* Whether to enable separate sessions for frontend and backend.
|
||||
*/
|
||||
public bool $enableSessionSeparation = false;
|
||||
|
||||
/**
|
||||
* Session name for backend when session separation is enabled.
|
||||
*/
|
||||
public string $backendSessionName = 'BACKENDSESSID';
|
||||
|
||||
/**
|
||||
* Session name for frontend when session separation is enabled.
|
||||
*/
|
||||
public string $frontendSessionName = 'PHPSESSID';
|
||||
|
||||
/**
|
||||
* Whether to enable CAPTCHA on forms.
|
||||
*/
|
||||
public bool $enableCaptcha = false;
|
||||
|
||||
/**
|
||||
* CAPTCHA type: 'yii', 'recaptcha-v2', 'recaptcha-v3', 'hcaptcha'.
|
||||
*/
|
||||
public string $captchaType = 'yii';
|
||||
|
||||
/**
|
||||
* Google reCAPTCHA site key.
|
||||
*/
|
||||
public ?string $reCaptchaSiteKey = null;
|
||||
|
||||
/**
|
||||
* Google reCAPTCHA secret key.
|
||||
*/
|
||||
public ?string $reCaptchaSecretKey = null;
|
||||
|
||||
/**
|
||||
* reCAPTCHA v3 score threshold (0.0 - 1.0). Default: 0.5.
|
||||
*/
|
||||
public float $reCaptchaV3Threshold = 0.5;
|
||||
|
||||
/**
|
||||
* hCaptcha site key.
|
||||
*/
|
||||
public ?string $hCaptchaSiteKey = null;
|
||||
|
||||
/**
|
||||
* hCaptcha secret key.
|
||||
*/
|
||||
public ?string $hCaptchaSecretKey = null;
|
||||
|
||||
/**
|
||||
* Forms to enable CAPTCHA on: 'login', 'register', 'recovery'.
|
||||
*/
|
||||
public array $captchaForms = ['register'];
|
||||
|
||||
/**
|
||||
* Email change strategy.
|
||||
*/
|
||||
@@ -325,6 +414,18 @@ class Module extends BaseModule implements BootstrapInterface
|
||||
return new \cgsmith\user\services\MailerService($this);
|
||||
});
|
||||
|
||||
$container->setSingleton('cgsmith\user\services\SessionService', function () {
|
||||
return new \cgsmith\user\services\SessionService($this);
|
||||
});
|
||||
|
||||
$container->setSingleton('cgsmith\user\services\GdprService', function () {
|
||||
return new \cgsmith\user\services\GdprService($this);
|
||||
});
|
||||
|
||||
$container->setSingleton('cgsmith\user\services\CaptchaService', function () {
|
||||
return new \cgsmith\user\services\CaptchaService($this);
|
||||
});
|
||||
|
||||
$container->setSingleton(Module::class, function () {
|
||||
return $this;
|
||||
});
|
||||
@@ -346,6 +447,9 @@ class Module extends BaseModule implements BootstrapInterface
|
||||
'settings' => 'settings/account',
|
||||
'settings/account' => 'settings/account',
|
||||
'settings/profile' => 'settings/profile',
|
||||
'settings/sessions' => 'settings/sessions',
|
||||
'settings/sessions/terminate/<id:\d+>' => 'settings/terminate-session',
|
||||
'settings/sessions/terminate-all' => 'settings/terminate-all-sessions',
|
||||
'admin' => 'admin/index',
|
||||
'admin/index' => 'admin/index',
|
||||
'admin/create' => 'admin/create',
|
||||
@@ -363,6 +467,10 @@ class Module extends BaseModule implements BootstrapInterface
|
||||
$rules['gdpr/delete'] = 'gdpr/delete';
|
||||
}
|
||||
|
||||
if ($this->enableGdprConsent) {
|
||||
$rules['gdpr/consent'] = 'gdpr/consent';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
|
||||
95
src/components/BackendUser.php
Normal file
95
src/components/BackendUser.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\components;
|
||||
|
||||
use Yii;
|
||||
use yii\web\User;
|
||||
|
||||
/**
|
||||
* Backend user component with separate session handling.
|
||||
*
|
||||
* This component allows separate authentication states for frontend and backend.
|
||||
* Users can be logged in to the frontend without being logged in to the backend,
|
||||
* and vice versa.
|
||||
*
|
||||
* Usage in config:
|
||||
* ```php
|
||||
* 'components' => [
|
||||
* 'backendUser' => [
|
||||
* 'class' => \cgsmith\user\components\BackendUser::class,
|
||||
* 'identityClass' => 'app\models\User',
|
||||
* 'enableAutoLogin' => true,
|
||||
* 'identityCookie' => ['name' => '_backendIdentity', 'httpOnly' => true],
|
||||
* ],
|
||||
* ],
|
||||
* ```
|
||||
*/
|
||||
class BackendUser extends User
|
||||
{
|
||||
/**
|
||||
* @var string the session key used to store the backend user ID
|
||||
*/
|
||||
public $idParam = '__backendId';
|
||||
|
||||
/**
|
||||
* @var string the session key used to store the backend auth key
|
||||
*/
|
||||
public $authKeyParam = '__backendAuthKey';
|
||||
|
||||
/**
|
||||
* @var string the session key used to store the backend auth timeout
|
||||
*/
|
||||
public $authTimeoutParam = '__backendAuthTimeout';
|
||||
|
||||
/**
|
||||
* @var string the cookie name for backend auto login
|
||||
*/
|
||||
public $identityCookie = ['name' => '_backendIdentity', 'httpOnly' => true];
|
||||
|
||||
/**
|
||||
* Initializes the backend session if session separation is enabled.
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
parent::init();
|
||||
|
||||
$module = Yii::$app->getModule('user');
|
||||
if ($module !== null && $module->enableSessionSeparation) {
|
||||
$this->switchToBackendSession();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to the backend session.
|
||||
*/
|
||||
protected function switchToBackendSession(): void
|
||||
{
|
||||
$module = Yii::$app->getModule('user');
|
||||
if ($module === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session = Yii::$app->session;
|
||||
if (!$session->isActive) {
|
||||
$session->setName($module->backendSessionName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the return URL for backend.
|
||||
*/
|
||||
public function getReturnUrl($defaultUrl = null)
|
||||
{
|
||||
$url = Yii::$app->session->get($this->returnUrlParam, $defaultUrl);
|
||||
if (is_array($url)) {
|
||||
if (isset($url[0])) {
|
||||
return Yii::$app->urlManager->createUrl($url);
|
||||
}
|
||||
return Yii::$app->urlManager->createUrl(['']);
|
||||
}
|
||||
|
||||
return $url === null ? Yii::$app->homeUrl : $url;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\controllers;
|
||||
|
||||
use cgsmith\user\models\GdprConsentForm;
|
||||
use cgsmith\user\models\User;
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\GdprService;
|
||||
use Yii;
|
||||
use yii\db\Expression;
|
||||
use yii\filters\AccessControl;
|
||||
@@ -48,13 +50,55 @@ class GdprController extends Controller
|
||||
/** @var Module $module */
|
||||
$module = $this->module;
|
||||
|
||||
if (!$module->enableGdpr) {
|
||||
if ($action->id === 'consent') {
|
||||
if (!$module->enableGdprConsent) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
} elseif (!$module->enableGdpr) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return parent::beforeAction($action);
|
||||
}
|
||||
|
||||
/**
|
||||
* GDPR consent page.
|
||||
*/
|
||||
public function actionConsent(): Response|string
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = $this->module;
|
||||
|
||||
if (!$module->enableGdprConsent) {
|
||||
return $this->redirect(['/']);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = Yii::$app->user->identity;
|
||||
|
||||
/** @var GdprService $gdprService */
|
||||
$gdprService = Yii::$container->get(GdprService::class);
|
||||
|
||||
if ($gdprService->hasValidConsent($user)) {
|
||||
return $this->redirect(['/']);
|
||||
}
|
||||
|
||||
$model = new GdprConsentForm();
|
||||
|
||||
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
|
||||
if ($gdprService->recordConsent($user, $model->marketingConsent)) {
|
||||
Yii::$app->session->setFlash('success', Yii::t('user', 'Thank you for your consent.'));
|
||||
return $this->goHome();
|
||||
}
|
||||
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while recording your consent.'));
|
||||
}
|
||||
|
||||
return $this->render('consent', [
|
||||
'model' => $model,
|
||||
'module' => $module,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GDPR overview page.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace cgsmith\user\controllers;
|
||||
|
||||
use cgsmith\user\events\FormEvent;
|
||||
use cgsmith\user\models\LoginForm;
|
||||
use cgsmith\user\models\User;
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\SessionService;
|
||||
use Yii;
|
||||
use yii\filters\AccessControl;
|
||||
use yii\filters\VerbFilter;
|
||||
@@ -70,6 +72,13 @@ class SecurityController extends Controller
|
||||
$module->trigger(self::EVENT_BEFORE_LOGIN, $event);
|
||||
|
||||
if ($model->load(Yii::$app->request->post()) && $model->login()) {
|
||||
// Track session
|
||||
if ($module->enableSessionHistory) {
|
||||
/** @var SessionService $sessionService */
|
||||
$sessionService = Yii::$container->get(SessionService::class);
|
||||
$sessionService->trackSession(Yii::$app->user->identity);
|
||||
}
|
||||
|
||||
// Trigger after login event
|
||||
$event = new FormEvent(['form' => $model]);
|
||||
$module->trigger(self::EVENT_AFTER_LOGIN, $event);
|
||||
@@ -91,10 +100,20 @@ class SecurityController extends Controller
|
||||
/** @var Module $module */
|
||||
$module = $this->module;
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = Yii::$app->user->identity;
|
||||
|
||||
// Trigger before logout event
|
||||
$event = new FormEvent(['form' => null]);
|
||||
$module->trigger(self::EVENT_BEFORE_LOGOUT, $event);
|
||||
|
||||
// Remove session tracking
|
||||
if ($module->enableSessionHistory && $user !== null) {
|
||||
/** @var SessionService $sessionService */
|
||||
$sessionService = Yii::$container->get(SessionService::class);
|
||||
$sessionService->removeCurrentSession($user);
|
||||
}
|
||||
|
||||
Yii::$app->user->logout();
|
||||
|
||||
// Trigger after logout event
|
||||
|
||||
@@ -10,6 +10,7 @@ use cgsmith\user\models\Token;
|
||||
use cgsmith\user\models\User;
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\MailerService;
|
||||
use cgsmith\user\services\SessionService;
|
||||
use cgsmith\user\services\TokenService;
|
||||
use Yii;
|
||||
use yii\filters\AccessControl;
|
||||
@@ -39,6 +40,8 @@ class SettingsController extends Controller
|
||||
'class' => VerbFilter::class,
|
||||
'actions' => [
|
||||
'delete-avatar' => ['post'],
|
||||
'terminate-session' => ['post'],
|
||||
'terminate-all-sessions' => ['post'],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -230,4 +233,80 @@ class SettingsController extends Controller
|
||||
|
||||
return $this->redirect(['account']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display active sessions.
|
||||
*/
|
||||
public function actionSessions(): Response|string
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = $this->module;
|
||||
|
||||
if (!$module->enableSessionHistory) {
|
||||
return $this->redirect(['account']);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = Yii::$app->user->identity;
|
||||
|
||||
/** @var SessionService $sessionService */
|
||||
$sessionService = Yii::$container->get(SessionService::class);
|
||||
$sessions = $sessionService->getUserSessions($user);
|
||||
|
||||
return $this->render('sessions', [
|
||||
'sessions' => $sessions,
|
||||
'module' => $module,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a specific session.
|
||||
*/
|
||||
public function actionTerminateSession(int $id): Response
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = $this->module;
|
||||
|
||||
if (!$module->enableSessionHistory) {
|
||||
return $this->redirect(['account']);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = Yii::$app->user->identity;
|
||||
|
||||
/** @var SessionService $sessionService */
|
||||
$sessionService = Yii::$container->get(SessionService::class);
|
||||
|
||||
if ($sessionService->terminateSession($id, $user)) {
|
||||
Yii::$app->session->setFlash('success', Yii::t('user', 'Session has been terminated.'));
|
||||
} else {
|
||||
Yii::$app->session->setFlash('danger', Yii::t('user', 'Session not found.'));
|
||||
}
|
||||
|
||||
return $this->redirect(['sessions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all sessions except the current one.
|
||||
*/
|
||||
public function actionTerminateAllSessions(): Response
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = $this->module;
|
||||
|
||||
if (!$module->enableSessionHistory) {
|
||||
return $this->redirect(['account']);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = Yii::$app->user->identity;
|
||||
|
||||
/** @var SessionService $sessionService */
|
||||
$sessionService = Yii::$container->get(SessionService::class);
|
||||
$count = $sessionService->terminateOtherSessions($user);
|
||||
|
||||
Yii::$app->session->setFlash('success', Yii::t('user', '{count, plural, =0{No sessions} =1{1 session} other{# sessions}} terminated.', ['count' => $count]));
|
||||
|
||||
return $this->redirect(['sessions']);
|
||||
}
|
||||
}
|
||||
|
||||
24
src/events/GdprEvent.php
Normal file
24
src/events/GdprEvent.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\events;
|
||||
|
||||
use cgsmith\user\models\User;
|
||||
use yii\base\Event;
|
||||
|
||||
/**
|
||||
* GDPR-related event.
|
||||
*/
|
||||
class GdprEvent extends Event
|
||||
{
|
||||
public const TYPE_CONSENT = 'consent';
|
||||
public const TYPE_WITHDRAW = 'withdraw';
|
||||
public const TYPE_EXPORT = 'export';
|
||||
public const TYPE_DELETE = 'delete';
|
||||
|
||||
public ?User $user = null;
|
||||
public ?string $type = null;
|
||||
public ?string $consentVersion = null;
|
||||
public bool $marketingConsent = false;
|
||||
}
|
||||
73
src/filters/BackendAccessControl.php
Normal file
73
src/filters/BackendAccessControl.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\filters;
|
||||
|
||||
use Yii;
|
||||
use yii\filters\AccessControl;
|
||||
use yii\web\User;
|
||||
|
||||
/**
|
||||
* Access control filter for backend with session separation support.
|
||||
*
|
||||
* This filter uses the backendUser component instead of the default user component
|
||||
* when session separation is enabled.
|
||||
*
|
||||
* Usage in controller:
|
||||
* ```php
|
||||
* public function behaviors(): array
|
||||
* {
|
||||
* return [
|
||||
* 'access' => [
|
||||
* 'class' => BackendAccessControl::class,
|
||||
* 'rules' => [
|
||||
* ['allow' => true, 'roles' => ['@']],
|
||||
* ],
|
||||
* ],
|
||||
* ];
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class BackendAccessControl extends AccessControl
|
||||
{
|
||||
/**
|
||||
* @var User|string|null the user component to use for access checking.
|
||||
* If null, will use 'backendUser' if available, otherwise 'user'.
|
||||
*/
|
||||
public $user = null;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
if ($this->user === null) {
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
if ($module !== null && $module->enableSessionSeparation && Yii::$app->has('backendUser')) {
|
||||
$this->user = Yii::$app->get('backendUser');
|
||||
} else {
|
||||
$this->user = Yii::$app->user;
|
||||
}
|
||||
}
|
||||
|
||||
parent::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can access the action.
|
||||
*/
|
||||
protected function isActive($action): bool
|
||||
{
|
||||
if ($this->user instanceof User) {
|
||||
return parent::isActive($action);
|
||||
}
|
||||
|
||||
if (is_string($this->user)) {
|
||||
$this->user = Yii::$app->get($this->user);
|
||||
}
|
||||
|
||||
return parent::isActive($action);
|
||||
}
|
||||
}
|
||||
58
src/filters/GdprConsentFilter.php
Normal file
58
src/filters/GdprConsentFilter.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\filters;
|
||||
|
||||
use cgsmith\user\models\User;
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\GdprService;
|
||||
use Yii;
|
||||
use yii\base\ActionFilter;
|
||||
use yii\web\Response;
|
||||
|
||||
/**
|
||||
* Filter that ensures users have given GDPR consent.
|
||||
*/
|
||||
class GdprConsentFilter extends ActionFilter
|
||||
{
|
||||
public ?string $consentRoute = null;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function beforeAction($action): bool
|
||||
{
|
||||
/** @var Module|null $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
if ($module === null || !$module->enableGdprConsent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Yii::$app->user->isGuest) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var GdprService $gdprService */
|
||||
$gdprService = Yii::$container->get(GdprService::class);
|
||||
|
||||
$route = Yii::$app->requestedRoute;
|
||||
if ($gdprService->isRouteExempt($route)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = Yii::$app->user->identity;
|
||||
|
||||
if (!$gdprService->needsConsentUpdate($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$consentRoute = $this->consentRoute ?? '/' . $module->urlPrefix . '/gdpr/consent';
|
||||
Yii::$app->response->redirect($consentRoute);
|
||||
Yii::$app->end();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
43
src/migrations/m250128_000001_create_session_table.php
Normal file
43
src/migrations/m250128_000001_create_session_table.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use yii\db\Migration;
|
||||
|
||||
/**
|
||||
* Creates session tracking table.
|
||||
*/
|
||||
class m250128_000001_create_session_table extends Migration
|
||||
{
|
||||
public function safeUp(): void
|
||||
{
|
||||
$this->createTable('{{%user_session}}', [
|
||||
'id' => $this->primaryKey()->unsigned(),
|
||||
'user_id' => $this->integer()->unsigned()->notNull(),
|
||||
'session_id' => $this->string(128)->notNull()->unique(),
|
||||
'ip' => $this->string(45)->null(),
|
||||
'user_agent' => $this->text()->null(),
|
||||
'device_name' => $this->string(255)->null(),
|
||||
'last_activity_at' => $this->dateTime()->notNull(),
|
||||
'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'),
|
||||
]);
|
||||
|
||||
$this->addForeignKey(
|
||||
'fk_user_session_user',
|
||||
'{{%user_session}}',
|
||||
'user_id',
|
||||
'{{%user}}',
|
||||
'id',
|
||||
'CASCADE',
|
||||
'CASCADE'
|
||||
);
|
||||
|
||||
$this->createIndex('idx_user_session_user_id', '{{%user_session}}', 'user_id');
|
||||
$this->createIndex('idx_user_session_last_activity', '{{%user_session}}', 'last_activity_at');
|
||||
}
|
||||
|
||||
public function safeDown(): void
|
||||
{
|
||||
$this->dropTable('{{%user_session}}');
|
||||
}
|
||||
}
|
||||
23
src/migrations/m250128_000002_add_gdpr_consent_fields.php
Normal file
23
src/migrations/m250128_000002_add_gdpr_consent_fields.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use yii\db\Migration;
|
||||
|
||||
/**
|
||||
* Adds enhanced GDPR consent fields to user table.
|
||||
*/
|
||||
class m250128_000002_add_gdpr_consent_fields extends Migration
|
||||
{
|
||||
public function safeUp(): void
|
||||
{
|
||||
$this->addColumn('{{%user}}', 'gdpr_consent_version', $this->string(50)->null()->after('gdpr_consent_at'));
|
||||
$this->addColumn('{{%user}}', 'gdpr_marketing_consent_at', $this->dateTime()->null()->after('gdpr_consent_version'));
|
||||
}
|
||||
|
||||
public function safeDown(): void
|
||||
{
|
||||
$this->dropColumn('{{%user}}', 'gdpr_marketing_consent_at');
|
||||
$this->dropColumn('{{%user}}', 'gdpr_consent_version');
|
||||
}
|
||||
}
|
||||
39
src/migrations/m250128_000003_create_two_factor_table.php
Normal file
39
src/migrations/m250128_000003_create_two_factor_table.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use yii\db\Migration;
|
||||
|
||||
/**
|
||||
* Creates two-factor authentication table.
|
||||
*/
|
||||
class m250128_000003_create_two_factor_table extends Migration
|
||||
{
|
||||
public function safeUp(): void
|
||||
{
|
||||
$this->createTable('{{%user_two_factor}}', [
|
||||
'id' => $this->primaryKey()->unsigned(),
|
||||
'user_id' => $this->integer()->unsigned()->notNull()->unique(),
|
||||
'secret' => $this->string(64)->notNull(),
|
||||
'enabled_at' => $this->dateTime()->null(),
|
||||
'backup_codes' => $this->json()->null(),
|
||||
'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'),
|
||||
'updated_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
|
||||
]);
|
||||
|
||||
$this->addForeignKey(
|
||||
'fk_user_two_factor_user',
|
||||
'{{%user_two_factor}}',
|
||||
'user_id',
|
||||
'{{%user}}',
|
||||
'id',
|
||||
'CASCADE',
|
||||
'CASCADE'
|
||||
);
|
||||
}
|
||||
|
||||
public function safeDown(): void
|
||||
{
|
||||
$this->dropTable('{{%user_two_factor}}');
|
||||
}
|
||||
}
|
||||
65
src/models/GdprConsentForm.php
Normal file
65
src/models/GdprConsentForm.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\models;
|
||||
|
||||
use cgsmith\user\Module;
|
||||
use Yii;
|
||||
use yii\base\Model;
|
||||
|
||||
/**
|
||||
* GDPR consent form model.
|
||||
*/
|
||||
class GdprConsentForm extends Model
|
||||
{
|
||||
public bool $consent = false;
|
||||
public bool $marketingConsent = false;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
[['consent'], 'required'],
|
||||
[['consent'], 'boolean'],
|
||||
[['consent'], 'validateConsentRequired'],
|
||||
[['marketingConsent'], 'boolean'],
|
||||
];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that consent is given.
|
||||
*/
|
||||
public function validateConsentRequired(string $attribute): void
|
||||
{
|
||||
if (!$this->consent) {
|
||||
$this->addError($attribute, Yii::t('user', 'You must accept the terms to continue.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function attributeLabels(): array
|
||||
{
|
||||
return [
|
||||
'consent' => Yii::t('user', 'I have read and accept the privacy policy'),
|
||||
'marketingConsent' => Yii::t('user', 'I agree to receive marketing communications'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the module instance.
|
||||
*/
|
||||
protected function getModule(): Module
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
||||
namespace cgsmith\user\models;
|
||||
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\CaptchaService;
|
||||
use cgsmith\user\validators\HCaptchaValidator;
|
||||
use cgsmith\user\validators\ReCaptchaValidator;
|
||||
use Yii;
|
||||
use yii\base\Model;
|
||||
|
||||
@@ -16,6 +19,7 @@ class LoginForm extends Model
|
||||
public ?string $login = null;
|
||||
public ?string $password = null;
|
||||
public bool $rememberMe = false;
|
||||
public ?string $captcha = null;
|
||||
|
||||
private ?User $_user = null;
|
||||
|
||||
@@ -24,13 +28,35 @@ class LoginForm extends Model
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$module = $this->getModule();
|
||||
|
||||
$rules = [
|
||||
[['login', 'password'], 'required'],
|
||||
[['login'], 'string'],
|
||||
[['password'], 'string'],
|
||||
[['rememberMe'], 'boolean'],
|
||||
[['password'], 'validatePassword'],
|
||||
[['captcha'], 'safe'],
|
||||
];
|
||||
|
||||
if ($module->enableCaptcha && in_array('login', $module->captchaForms, true)) {
|
||||
$rules[] = $this->getCaptchaRule($module);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CAPTCHA validation rule based on type.
|
||||
*/
|
||||
protected function getCaptchaRule(Module $module): array
|
||||
{
|
||||
return match ($module->captchaType) {
|
||||
CaptchaService::TYPE_YII => ['captcha', 'captcha', 'captchaAction' => '/user/security/captcha'],
|
||||
CaptchaService::TYPE_RECAPTCHA_V2, CaptchaService::TYPE_RECAPTCHA_V3 => ['captcha', ReCaptchaValidator::class],
|
||||
CaptchaService::TYPE_HCAPTCHA => ['captcha', HCaptchaValidator::class],
|
||||
default => ['captcha', 'safe'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\models;
|
||||
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\CaptchaService;
|
||||
use cgsmith\user\validators\HCaptchaValidator;
|
||||
use cgsmith\user\validators\ReCaptchaValidator;
|
||||
use Yii;
|
||||
use yii\base\Model;
|
||||
|
||||
@@ -13,6 +17,7 @@ use yii\base\Model;
|
||||
class RecoveryForm extends Model
|
||||
{
|
||||
public ?string $email = null;
|
||||
public ?string $captcha = null;
|
||||
|
||||
private ?User $_user = null;
|
||||
|
||||
@@ -21,12 +26,34 @@ class RecoveryForm extends Model
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$module = $this->getModule();
|
||||
|
||||
$rules = [
|
||||
['email', 'trim'],
|
||||
['email', 'required'],
|
||||
['email', 'email'],
|
||||
['email', 'validateEmail'],
|
||||
['captcha', 'safe'],
|
||||
];
|
||||
|
||||
if ($module->enableCaptcha && in_array('recovery', $module->captchaForms, true)) {
|
||||
$rules[] = $this->getCaptchaRule($module);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CAPTCHA validation rule based on type.
|
||||
*/
|
||||
protected function getCaptchaRule(Module $module): array
|
||||
{
|
||||
return match ($module->captchaType) {
|
||||
CaptchaService::TYPE_YII => ['captcha', 'captcha', 'captchaAction' => '/user/recovery/captcha'],
|
||||
CaptchaService::TYPE_RECAPTCHA_V2, CaptchaService::TYPE_RECAPTCHA_V3 => ['captcha', ReCaptchaValidator::class],
|
||||
CaptchaService::TYPE_HCAPTCHA => ['captcha', HCaptchaValidator::class],
|
||||
default => ['captcha', 'safe'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,4 +94,15 @@ class RecoveryForm extends Model
|
||||
|
||||
return $this->_user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user module.
|
||||
*/
|
||||
protected function getModule(): Module
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
||||
namespace cgsmith\user\models;
|
||||
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\CaptchaService;
|
||||
use cgsmith\user\validators\HCaptchaValidator;
|
||||
use cgsmith\user\validators\ReCaptchaValidator;
|
||||
use Yii;
|
||||
use yii\base\Model;
|
||||
|
||||
@@ -16,6 +19,9 @@ class RegistrationForm extends Model
|
||||
public ?string $email = null;
|
||||
public ?string $username = null;
|
||||
public ?string $password = null;
|
||||
public bool $gdprConsent = false;
|
||||
public bool $gdprMarketingConsent = false;
|
||||
public ?string $captcha = null;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
@@ -37,6 +43,13 @@ class RegistrationForm extends Model
|
||||
['username', 'string', 'min' => 3, 'max' => 255],
|
||||
['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.]+$/', 'message' => Yii::t('user', 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.')],
|
||||
['username', 'unique', 'targetClass' => User::class, 'message' => Yii::t('user', 'This username has already been taken.')],
|
||||
|
||||
// GDPR consent
|
||||
['gdprConsent', 'boolean'],
|
||||
['gdprMarketingConsent', 'boolean'],
|
||||
|
||||
// Captcha
|
||||
['captcha', 'safe'],
|
||||
];
|
||||
|
||||
// Password rules (unless generated)
|
||||
@@ -45,9 +58,33 @@ class RegistrationForm extends Model
|
||||
$rules[] = ['password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength];
|
||||
}
|
||||
|
||||
// GDPR consent required if enabled
|
||||
if ($module->enableGdprConsent && $module->requireGdprConsentBeforeRegistration) {
|
||||
$rules[] = ['gdprConsent', 'required'];
|
||||
$rules[] = ['gdprConsent', 'compare', 'compareValue' => true, 'message' => Yii::t('user', 'You must accept the privacy policy to register.')];
|
||||
}
|
||||
|
||||
// CAPTCHA required if enabled
|
||||
if ($module->enableCaptcha && in_array('register', $module->captchaForms, true)) {
|
||||
$rules[] = $this->getCaptchaRule($module);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CAPTCHA validation rule based on type.
|
||||
*/
|
||||
protected function getCaptchaRule(Module $module): array
|
||||
{
|
||||
return match ($module->captchaType) {
|
||||
CaptchaService::TYPE_YII => ['captcha', 'captcha', 'captchaAction' => '/user/registration/captcha'],
|
||||
CaptchaService::TYPE_RECAPTCHA_V2, CaptchaService::TYPE_RECAPTCHA_V3 => ['captcha', ReCaptchaValidator::class],
|
||||
CaptchaService::TYPE_HCAPTCHA => ['captcha', HCaptchaValidator::class],
|
||||
default => ['captcha', 'safe'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -57,6 +94,8 @@ class RegistrationForm extends Model
|
||||
'email' => Yii::t('user', 'Email'),
|
||||
'username' => Yii::t('user', 'Username'),
|
||||
'password' => Yii::t('user', 'Password'),
|
||||
'gdprConsent' => Yii::t('user', 'I have read and accept the privacy policy'),
|
||||
'gdprMarketingConsent' => Yii::t('user', 'I agree to receive marketing communications'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
135
src/models/Session.php
Normal file
135
src/models/Session.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\models;
|
||||
|
||||
use cgsmith\user\models\query\SessionQuery;
|
||||
use Yii;
|
||||
use yii\db\ActiveQuery;
|
||||
use yii\db\ActiveRecord;
|
||||
|
||||
/**
|
||||
* User session ActiveRecord model.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $session_id
|
||||
* @property string|null $ip
|
||||
* @property string|null $user_agent
|
||||
* @property string|null $device_name
|
||||
* @property string $last_activity_at
|
||||
* @property string $created_at
|
||||
*
|
||||
* @property-read User $user
|
||||
* @property-read bool $isCurrent
|
||||
*/
|
||||
class Session extends ActiveRecord
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function tableName(): string
|
||||
{
|
||||
return '{{%user_session}}';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @return SessionQuery
|
||||
*/
|
||||
public static function find(): SessionQuery
|
||||
{
|
||||
return new SessionQuery(static::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
[['user_id', 'session_id', 'last_activity_at'], 'required'],
|
||||
[['user_id'], 'integer'],
|
||||
[['session_id'], 'string', 'max' => 128],
|
||||
[['ip'], 'string', 'max' => 45],
|
||||
[['device_name'], 'string', 'max' => 255],
|
||||
[['user_agent'], 'string'],
|
||||
[['session_id'], 'unique'],
|
||||
[['user_id'], 'exist', 'targetClass' => User::class, 'targetAttribute' => 'id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function attributeLabels(): array
|
||||
{
|
||||
return [
|
||||
'id' => Yii::t('user', 'ID'),
|
||||
'user_id' => Yii::t('user', 'User'),
|
||||
'session_id' => Yii::t('user', 'Session ID'),
|
||||
'ip' => Yii::t('user', 'IP Address'),
|
||||
'user_agent' => Yii::t('user', 'User Agent'),
|
||||
'device_name' => Yii::t('user', 'Device'),
|
||||
'last_activity_at' => Yii::t('user', 'Last Activity'),
|
||||
'created_at' => Yii::t('user', 'Created At'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user relation.
|
||||
*/
|
||||
public function getUser(): ActiveQuery
|
||||
{
|
||||
return $this->hasOne(User::class, ['id' => 'user_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is the current session.
|
||||
*/
|
||||
public function getIsCurrent(): bool
|
||||
{
|
||||
return $this->session_id === Yii::$app->session->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user agent to determine device name.
|
||||
*/
|
||||
public static function parseDeviceName(?string $userAgent): string
|
||||
{
|
||||
if ($userAgent === null) {
|
||||
return Yii::t('user', 'Unknown Device');
|
||||
}
|
||||
|
||||
$userAgent = strtolower($userAgent);
|
||||
|
||||
$os = 'Unknown OS';
|
||||
if (str_contains($userAgent, 'windows')) {
|
||||
$os = 'Windows';
|
||||
} elseif (str_contains($userAgent, 'macintosh') || str_contains($userAgent, 'mac os')) {
|
||||
$os = 'macOS';
|
||||
} elseif (str_contains($userAgent, 'linux')) {
|
||||
$os = 'Linux';
|
||||
} elseif (str_contains($userAgent, 'android')) {
|
||||
$os = 'Android';
|
||||
} elseif (str_contains($userAgent, 'iphone') || str_contains($userAgent, 'ipad')) {
|
||||
$os = 'iOS';
|
||||
}
|
||||
|
||||
$browser = 'Unknown Browser';
|
||||
if (str_contains($userAgent, 'edg/') || str_contains($userAgent, 'edge/')) {
|
||||
$browser = 'Edge';
|
||||
} elseif (str_contains($userAgent, 'chrome')) {
|
||||
$browser = 'Chrome';
|
||||
} elseif (str_contains($userAgent, 'firefox')) {
|
||||
$browser = 'Firefox';
|
||||
} elseif (str_contains($userAgent, 'safari') && !str_contains($userAgent, 'chrome')) {
|
||||
$browser = 'Safari';
|
||||
} elseif (str_contains($userAgent, 'opera') || str_contains($userAgent, 'opr/')) {
|
||||
$browser = 'Opera';
|
||||
}
|
||||
|
||||
return "{$browser} on {$os}";
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ use yii\web\IdentityInterface;
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property string|null $gdpr_consent_at
|
||||
* @property string|null $gdpr_consent_version
|
||||
* @property string|null $gdpr_marketing_consent_at
|
||||
* @property string|null $gdpr_deleted_at
|
||||
*
|
||||
* @property-read bool $isAdmin
|
||||
@@ -40,6 +42,7 @@ use yii\web\IdentityInterface;
|
||||
* @property-read bool $isConfirmed
|
||||
* @property-read Profile $profile
|
||||
* @property-read Token[] $tokens
|
||||
* @property-read Session[] $sessions
|
||||
*/
|
||||
class User extends ActiveRecord implements IdentityInterface, UserInterface
|
||||
{
|
||||
@@ -262,6 +265,14 @@ class User extends ActiveRecord implements IdentityInterface, UserInterface
|
||||
return $this->hasMany(Token::class, ['user_id' => 'id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user sessions relation.
|
||||
*/
|
||||
public function getSessions(): ActiveQuery
|
||||
{
|
||||
return $this->hasMany(Session::class, ['user_id' => 'id']);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
/**
|
||||
|
||||
49
src/models/query/SessionQuery.php
Normal file
49
src/models/query/SessionQuery.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\models\query;
|
||||
|
||||
use cgsmith\user\models\Session;
|
||||
use yii\db\ActiveQuery;
|
||||
|
||||
/**
|
||||
* Query class for Session model.
|
||||
*
|
||||
* @method Session|null one($db = null)
|
||||
* @method Session[] all($db = null)
|
||||
*/
|
||||
class SessionQuery extends ActiveQuery
|
||||
{
|
||||
/**
|
||||
* Filter by user ID.
|
||||
*/
|
||||
public function byUser(int $userId): self
|
||||
{
|
||||
return $this->andWhere(['user_id' => $userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by session ID.
|
||||
*/
|
||||
public function bySessionId(string $sessionId): self
|
||||
{
|
||||
return $this->andWhere(['session_id' => $sessionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Order by last activity descending.
|
||||
*/
|
||||
public function latestFirst(): self
|
||||
{
|
||||
return $this->orderBy(['last_activity_at' => SORT_DESC]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter sessions active within the given time period.
|
||||
*/
|
||||
public function activeWithin(int $seconds): self
|
||||
{
|
||||
return $this->andWhere(['>=', 'last_activity_at', date('Y-m-d H:i:s', time() - $seconds)]);
|
||||
}
|
||||
}
|
||||
139
src/services/CaptchaService.php
Normal file
139
src/services/CaptchaService.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\services;
|
||||
|
||||
use cgsmith\user\Module;
|
||||
use Yii;
|
||||
|
||||
/**
|
||||
* Service for CAPTCHA management and verification.
|
||||
*/
|
||||
class CaptchaService
|
||||
{
|
||||
public const TYPE_YII = 'yii';
|
||||
public const TYPE_RECAPTCHA_V2 = 'recaptcha-v2';
|
||||
public const TYPE_RECAPTCHA_V3 = 'recaptcha-v3';
|
||||
public const TYPE_HCAPTCHA = 'hcaptcha';
|
||||
|
||||
public function __construct(
|
||||
private readonly Module $module
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CAPTCHA is enabled for a specific form.
|
||||
*/
|
||||
public function isEnabledForForm(string $formType): bool
|
||||
{
|
||||
if (!$this->module->enableCaptcha) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($formType, $this->module->captchaForms, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current CAPTCHA type.
|
||||
*/
|
||||
public function getCaptchaType(): string
|
||||
{
|
||||
return $this->module->captchaType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify reCAPTCHA response.
|
||||
*/
|
||||
public function verifyReCaptcha(string $response, ?string $remoteIp = null): bool
|
||||
{
|
||||
if (empty($this->module->reCaptchaSecretKey)) {
|
||||
Yii::warning('reCAPTCHA secret key is not configured', __METHOD__);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!class_exists('\ReCaptcha\ReCaptcha')) {
|
||||
Yii::warning('google/recaptcha package is not installed', __METHOD__);
|
||||
return false;
|
||||
}
|
||||
|
||||
$recaptcha = new \ReCaptcha\ReCaptcha($this->module->reCaptchaSecretKey);
|
||||
|
||||
if ($remoteIp === null && Yii::$app->request instanceof \yii\web\Request) {
|
||||
$remoteIp = Yii::$app->request->userIP;
|
||||
}
|
||||
|
||||
$result = $recaptcha->verify($response, $remoteIp);
|
||||
|
||||
if ($this->module->captchaType === self::TYPE_RECAPTCHA_V3) {
|
||||
return $result->isSuccess() && $result->getScore() >= $this->module->reCaptchaV3Threshold;
|
||||
}
|
||||
|
||||
return $result->isSuccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hCaptcha response.
|
||||
*/
|
||||
public function verifyHCaptcha(string $response, ?string $remoteIp = null): bool
|
||||
{
|
||||
if (empty($this->module->hCaptchaSecretKey)) {
|
||||
Yii::warning('hCaptcha secret key is not configured', __METHOD__);
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'secret' => $this->module->hCaptchaSecretKey,
|
||||
'response' => $response,
|
||||
];
|
||||
|
||||
if ($remoteIp !== null) {
|
||||
$data['remoteip'] = $remoteIp;
|
||||
} elseif (Yii::$app->request instanceof \yii\web\Request) {
|
||||
$data['remoteip'] = Yii::$app->request->userIP;
|
||||
}
|
||||
|
||||
$ch = curl_init('https://hcaptcha.com/siteverify');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($result === false) {
|
||||
Yii::warning('hCaptcha verification request failed', __METHOD__);
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = json_decode($result, true);
|
||||
|
||||
return isset($json['success']) && $json['success'] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site key for the current CAPTCHA type.
|
||||
*/
|
||||
public function getSiteKey(): ?string
|
||||
{
|
||||
return match ($this->module->captchaType) {
|
||||
self::TYPE_RECAPTCHA_V2, self::TYPE_RECAPTCHA_V3 => $this->module->reCaptchaSiteKey,
|
||||
self::TYPE_HCAPTCHA => $this->module->hCaptchaSiteKey,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reCAPTCHA v3 action name for a form.
|
||||
*/
|
||||
public function getReCaptchaAction(string $formType): string
|
||||
{
|
||||
return match ($formType) {
|
||||
'login' => 'login',
|
||||
'register' => 'register',
|
||||
'recovery' => 'recovery',
|
||||
default => 'submit',
|
||||
};
|
||||
}
|
||||
}
|
||||
167
src/services/GdprService.php
Normal file
167
src/services/GdprService.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\services;
|
||||
|
||||
use cgsmith\user\events\GdprEvent;
|
||||
use cgsmith\user\models\User;
|
||||
use cgsmith\user\Module;
|
||||
use Yii;
|
||||
use yii\db\Expression;
|
||||
|
||||
/**
|
||||
* Service for GDPR consent management.
|
||||
*/
|
||||
class GdprService
|
||||
{
|
||||
public const EVENT_BEFORE_CONSENT = 'beforeGdprConsent';
|
||||
public const EVENT_AFTER_CONSENT = 'afterGdprConsent';
|
||||
public const EVENT_BEFORE_WITHDRAW = 'beforeGdprWithdraw';
|
||||
public const EVENT_AFTER_WITHDRAW = 'afterGdprWithdraw';
|
||||
|
||||
public function __construct(
|
||||
private readonly Module $module
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record user consent.
|
||||
*/
|
||||
public function recordConsent(User $user, bool $marketingConsent = false): bool
|
||||
{
|
||||
$event = new GdprEvent([
|
||||
'user' => $user,
|
||||
'type' => GdprEvent::TYPE_CONSENT,
|
||||
'consentVersion' => $this->module->gdprConsentVersion,
|
||||
'marketingConsent' => $marketingConsent,
|
||||
]);
|
||||
|
||||
$this->module->trigger(self::EVENT_BEFORE_CONSENT, $event);
|
||||
|
||||
$user->gdpr_consent_at = new Expression('NOW()');
|
||||
$user->gdpr_consent_version = $this->module->gdprConsentVersion;
|
||||
|
||||
if ($marketingConsent) {
|
||||
$user->gdpr_marketing_consent_at = new Expression('NOW()');
|
||||
} else {
|
||||
$user->gdpr_marketing_consent_at = null;
|
||||
}
|
||||
|
||||
$result = $user->save(false, ['gdpr_consent_at', 'gdpr_consent_version', 'gdpr_marketing_consent_at']);
|
||||
|
||||
if ($result) {
|
||||
$this->module->trigger(self::EVENT_AFTER_CONSENT, $event);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw user consent.
|
||||
*/
|
||||
public function withdrawConsent(User $user): bool
|
||||
{
|
||||
$event = new GdprEvent([
|
||||
'user' => $user,
|
||||
'type' => GdprEvent::TYPE_WITHDRAW,
|
||||
]);
|
||||
|
||||
$this->module->trigger(self::EVENT_BEFORE_WITHDRAW, $event);
|
||||
|
||||
$user->gdpr_consent_at = null;
|
||||
$user->gdpr_consent_version = null;
|
||||
$user->gdpr_marketing_consent_at = null;
|
||||
|
||||
$result = $user->save(false, ['gdpr_consent_at', 'gdpr_consent_version', 'gdpr_marketing_consent_at']);
|
||||
|
||||
if ($result) {
|
||||
$this->module->trigger(self::EVENT_AFTER_WITHDRAW, $event);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has given consent to the current version.
|
||||
*/
|
||||
public function hasValidConsent(User $user): bool
|
||||
{
|
||||
if (!$this->module->enableGdprConsent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user->gdpr_consent_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->module->gdprConsentVersion === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->gdpr_consent_version === $this->module->gdprConsentVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user needs to update their consent.
|
||||
*/
|
||||
public function needsConsentUpdate(User $user): bool
|
||||
{
|
||||
if (!$this->module->enableGdprConsent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->hasValidConsent($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is exempt from GDPR consent requirement.
|
||||
*/
|
||||
public function isRouteExempt(string $route): bool
|
||||
{
|
||||
$exemptRoutes = array_merge(
|
||||
$this->module->gdprExemptRoutes,
|
||||
[
|
||||
'user/security/login',
|
||||
'user/security/logout',
|
||||
'user/gdpr/consent',
|
||||
'user/gdpr/index',
|
||||
'user/gdpr/export',
|
||||
'user/gdpr/delete',
|
||||
]
|
||||
);
|
||||
|
||||
foreach ($exemptRoutes as $exempt) {
|
||||
if ($exempt === $route) {
|
||||
return true;
|
||||
}
|
||||
if (str_ends_with($exempt, '*') && str_starts_with($route, rtrim($exempt, '*'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current marketing consent status.
|
||||
*/
|
||||
public function hasMarketingConsent(User $user): bool
|
||||
{
|
||||
return $user->gdpr_marketing_consent_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update marketing consent only.
|
||||
*/
|
||||
public function updateMarketingConsent(User $user, bool $consent): bool
|
||||
{
|
||||
if ($consent) {
|
||||
$user->gdpr_marketing_consent_at = new Expression('NOW()');
|
||||
} else {
|
||||
$user->gdpr_marketing_consent_at = null;
|
||||
}
|
||||
|
||||
return $user->save(false, ['gdpr_marketing_consent_at']);
|
||||
}
|
||||
}
|
||||
201
src/services/SessionService.php
Normal file
201
src/services/SessionService.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\services;
|
||||
|
||||
use cgsmith\user\models\Session;
|
||||
use cgsmith\user\models\User;
|
||||
use cgsmith\user\Module;
|
||||
use Yii;
|
||||
use yii\db\Expression;
|
||||
|
||||
/**
|
||||
* Service for managing user sessions.
|
||||
*/
|
||||
class SessionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Module $module
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a session record for the user.
|
||||
*/
|
||||
public function trackSession(User $user): ?Session
|
||||
{
|
||||
if (!$this->module->enableSessionHistory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sessionId = Yii::$app->session->id;
|
||||
if (empty($sessionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session = Session::find()
|
||||
->bySessionId($sessionId)
|
||||
->one();
|
||||
|
||||
if ($session === null) {
|
||||
$session = new Session();
|
||||
$session->user_id = $user->id;
|
||||
$session->session_id = $sessionId;
|
||||
$session->created_at = new Expression('NOW()');
|
||||
}
|
||||
|
||||
$request = Yii::$app->request;
|
||||
$session->ip = $request->userIP;
|
||||
$session->user_agent = $request->userAgent;
|
||||
$session->device_name = Session::parseDeviceName($request->userAgent);
|
||||
$session->last_activity_at = new Expression('NOW()');
|
||||
|
||||
if ($session->save()) {
|
||||
$this->enforceSessionLimit($user);
|
||||
return $session;
|
||||
}
|
||||
|
||||
Yii::error('Failed to track session: ' . json_encode($session->errors), __METHOD__);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activity for current session.
|
||||
*/
|
||||
public function updateActivity(User $user): bool
|
||||
{
|
||||
if (!$this->module->enableSessionHistory) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$sessionId = Yii::$app->session->id;
|
||||
if (empty($sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Session::updateAll(
|
||||
['last_activity_at' => new Expression('NOW()')],
|
||||
['session_id' => $sessionId, 'user_id' => $user->id]
|
||||
) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions for a user.
|
||||
*
|
||||
* @return Session[]
|
||||
*/
|
||||
public function getUserSessions(User $user): array
|
||||
{
|
||||
return Session::find()
|
||||
->byUser($user->id)
|
||||
->latestFirst()
|
||||
->limit($this->module->sessionHistoryLimit)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a specific session.
|
||||
*/
|
||||
public function terminateSession(int $sessionId, User $user): bool
|
||||
{
|
||||
$session = Session::find()
|
||||
->byUser($user->id)
|
||||
->andWhere(['id' => $sessionId])
|
||||
->one();
|
||||
|
||||
if ($session === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($session->getIsCurrent()) {
|
||||
Yii::$app->user->logout();
|
||||
}
|
||||
|
||||
return $session->delete() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all sessions except the current one.
|
||||
*/
|
||||
public function terminateOtherSessions(User $user): int
|
||||
{
|
||||
$currentSessionId = Yii::$app->session->id;
|
||||
|
||||
return Session::deleteAll([
|
||||
'and',
|
||||
['user_id' => $user->id],
|
||||
['!=', 'session_id', $currentSessionId],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all sessions for a user.
|
||||
*/
|
||||
public function terminateAllSessions(User $user): int
|
||||
{
|
||||
return Session::deleteAll(['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove session on logout.
|
||||
*/
|
||||
public function removeCurrentSession(User $user): bool
|
||||
{
|
||||
if (!$this->module->enableSessionHistory) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$sessionId = Yii::$app->session->id;
|
||||
if (empty($sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Session::deleteAll([
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $user->id,
|
||||
]) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce session limit by removing oldest sessions.
|
||||
*/
|
||||
private function enforceSessionLimit(User $user): void
|
||||
{
|
||||
$limit = $this->module->sessionHistoryLimit;
|
||||
if ($limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sessions = Session::find()
|
||||
->byUser($user->id)
|
||||
->orderBy(['last_activity_at' => SORT_ASC])
|
||||
->all();
|
||||
|
||||
$toDelete = count($sessions) - $limit;
|
||||
if ($toDelete <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$idsToDelete = [];
|
||||
for ($i = 0; $i < $toDelete; $i++) {
|
||||
if (!$sessions[$i]->getIsCurrent()) {
|
||||
$idsToDelete[] = $sessions[$i]->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($idsToDelete)) {
|
||||
Session::deleteAll(['id' => $idsToDelete]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions (older than rememberFor duration).
|
||||
*/
|
||||
public function cleanupExpiredSessions(): int
|
||||
{
|
||||
$expireTime = date('Y-m-d H:i:s', time() - $this->module->rememberFor);
|
||||
|
||||
return Session::deleteAll(['<', 'last_activity_at', $expireTime]);
|
||||
}
|
||||
}
|
||||
44
src/validators/HCaptchaValidator.php
Normal file
44
src/validators/HCaptchaValidator.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\validators;
|
||||
|
||||
use cgsmith\user\services\CaptchaService;
|
||||
use Yii;
|
||||
use yii\validators\Validator;
|
||||
|
||||
/**
|
||||
* Validator for hCaptcha.
|
||||
*/
|
||||
class HCaptchaValidator extends Validator
|
||||
{
|
||||
public bool $skipOnEmpty = false;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function validateValue($value): ?array
|
||||
{
|
||||
if (empty($value)) {
|
||||
return [Yii::t('user', 'Please complete the CAPTCHA verification.'), []];
|
||||
}
|
||||
|
||||
/** @var CaptchaService $captchaService */
|
||||
$captchaService = Yii::$container->get(CaptchaService::class);
|
||||
|
||||
if (!$captchaService->verifyHCaptcha($value)) {
|
||||
return [Yii::t('user', 'CAPTCHA verification failed. Please try again.'), []];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function clientValidateAttribute($model, $attribute, $view): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
44
src/validators/ReCaptchaValidator.php
Normal file
44
src/validators/ReCaptchaValidator.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\validators;
|
||||
|
||||
use cgsmith\user\services\CaptchaService;
|
||||
use Yii;
|
||||
use yii\validators\Validator;
|
||||
|
||||
/**
|
||||
* Validator for Google reCAPTCHA v2 and v3.
|
||||
*/
|
||||
class ReCaptchaValidator extends Validator
|
||||
{
|
||||
public bool $skipOnEmpty = false;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function validateValue($value): ?array
|
||||
{
|
||||
if (empty($value)) {
|
||||
return [Yii::t('user', 'Please complete the CAPTCHA verification.'), []];
|
||||
}
|
||||
|
||||
/** @var CaptchaService $captchaService */
|
||||
$captchaService = Yii::$container->get(CaptchaService::class);
|
||||
|
||||
if (!$captchaService->verifyReCaptcha($value)) {
|
||||
return [Yii::t('user', 'CAPTCHA verification failed. Please try again.'), []];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function clientValidateAttribute($model, $attribute, $view): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
54
src/views/gdpr/consent.php
Normal file
54
src/views/gdpr/consent.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var yii\web\View $this
|
||||
* @var cgsmith\user\models\GdprConsentForm $model
|
||||
* @var cgsmith\user\Module $module
|
||||
*/
|
||||
|
||||
use yii\helpers\Html;
|
||||
|
||||
$formClass = $module->activeFormClass;
|
||||
$this->title = Yii::t('user', 'Privacy Consent Required');
|
||||
?>
|
||||
|
||||
<div class="user-gdpr-consent">
|
||||
<h1><?= Html::encode($this->title) ?></h1>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<?= Yii::t('user', 'We have updated our privacy policy. Please review and accept the terms to continue using our service.') ?>
|
||||
</div>
|
||||
|
||||
<?php if ($module->gdprConsentUrl): ?>
|
||||
<p>
|
||||
<?= Html::a(
|
||||
Yii::t('user', 'Read our Privacy Policy'),
|
||||
$module->gdprConsentUrl,
|
||||
['target' => '_blank', 'rel' => 'noopener']
|
||||
) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $form = $formClass::begin(['id' => 'gdpr-consent-form'] + $module->formFieldConfig) ?>
|
||||
|
||||
<?= $form->field($model, 'consent')->checkbox() ?>
|
||||
|
||||
<?= $form->field($model, 'marketingConsent')->checkbox() ?>
|
||||
|
||||
<div class="form-group">
|
||||
<?= Html::submitButton(Yii::t('user', 'Accept and Continue'), ['class' => 'btn btn-primary']) ?>
|
||||
</div>
|
||||
|
||||
<?php $formClass::end() ?>
|
||||
|
||||
<p class="text-muted mt-3">
|
||||
<small>
|
||||
<?= Yii::t('user', 'If you do not wish to accept these terms, you may {logout} or {delete} your account.', [
|
||||
'logout' => Html::a(Yii::t('user', 'log out'), ['/' . $module->urlPrefix . '/logout'], ['data-method' => 'post']),
|
||||
'delete' => $module->enableAccountDelete
|
||||
? Html::a(Yii::t('user', 'delete'), ['/' . $module->urlPrefix . '/gdpr/delete'])
|
||||
: Yii::t('user', 'contact support to delete'),
|
||||
]) ?>
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
38
src/views/registration/_gdpr_consent.php
Normal file
38
src/views/registration/_gdpr_consent.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GDPR consent partial for registration form.
|
||||
*
|
||||
* @var yii\web\View $this
|
||||
* @var yii\widgets\ActiveForm $form
|
||||
* @var cgsmith\user\models\RegistrationForm $model
|
||||
* @var cgsmith\user\Module $module
|
||||
*/
|
||||
|
||||
use yii\helpers\Html;
|
||||
|
||||
?>
|
||||
|
||||
<?php if ($module->enableGdprConsent && $module->requireGdprConsentBeforeRegistration): ?>
|
||||
<div class="gdpr-consent-fields">
|
||||
<?php if ($module->gdprConsentUrl): ?>
|
||||
<p class="text-muted">
|
||||
<?= Yii::t('user', 'By registering, you agree to our {privacy_policy}.', [
|
||||
'privacy_policy' => Html::a(
|
||||
Yii::t('user', 'Privacy Policy'),
|
||||
$module->gdprConsentUrl,
|
||||
['target' => '_blank', 'rel' => 'noopener']
|
||||
),
|
||||
]) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?= $form->field($model, 'gdprConsent')->checkbox([
|
||||
'label' => Yii::t('user', 'I have read and accept the privacy policy'),
|
||||
]) ?>
|
||||
|
||||
<?= $form->field($model, 'gdprMarketingConsent')->checkbox([
|
||||
'label' => Yii::t('user', 'I agree to receive marketing communications (optional)'),
|
||||
]) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
87
src/views/settings/sessions.php
Normal file
87
src/views/settings/sessions.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var yii\web\View $this
|
||||
* @var cgsmith\user\models\Session[] $sessions
|
||||
* @var cgsmith\user\Module $module
|
||||
*/
|
||||
|
||||
use yii\helpers\Html;
|
||||
|
||||
$this->title = Yii::t('user', 'Active Sessions');
|
||||
$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Settings'), 'url' => ['account']];
|
||||
$this->params['breadcrumbs'][] = $this->title;
|
||||
?>
|
||||
|
||||
<div class="user-sessions">
|
||||
<h1><?= Html::encode($this->title) ?></h1>
|
||||
<p class="text-muted"><?= Yii::t('user', 'These are the devices that are currently logged in to your account.') ?></p>
|
||||
|
||||
<?php if (count($sessions) > 1): ?>
|
||||
<p>
|
||||
<?= Html::a(
|
||||
Yii::t('user', 'Sign out all other sessions'),
|
||||
['terminate-all-sessions'],
|
||||
[
|
||||
'class' => 'btn btn-outline-danger btn-sm',
|
||||
'data-method' => 'post',
|
||||
'data-confirm' => Yii::t('user', 'Are you sure you want to sign out all other sessions?'),
|
||||
]
|
||||
) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="session-list">
|
||||
<?php foreach ($sessions as $session): ?>
|
||||
<div class="card mb-3 <?= $session->isCurrent ? 'border-primary' : '' ?>">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">
|
||||
<?= Html::encode($session->device_name) ?>
|
||||
<?php if ($session->isCurrent): ?>
|
||||
<span class="badge bg-primary"><?= Yii::t('user', 'Current') ?></span>
|
||||
<?php endif; ?>
|
||||
</h5>
|
||||
<p class="card-text text-muted mb-1">
|
||||
<small>
|
||||
<?= Yii::t('user', 'IP: {ip}', ['ip' => Html::encode($session->ip ?? 'Unknown')]) ?>
|
||||
</small>
|
||||
</p>
|
||||
<p class="card-text text-muted mb-0">
|
||||
<small>
|
||||
<?= Yii::t('user', 'Last active: {time}', [
|
||||
'time' => Yii::$app->formatter->asRelativeTime($session->last_activity_at)
|
||||
]) ?>
|
||||
·
|
||||
<?= Yii::t('user', 'Started: {time}', [
|
||||
'time' => Yii::$app->formatter->asRelativeTime($session->created_at)
|
||||
]) ?>
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
<?php if (!$session->isCurrent): ?>
|
||||
<div>
|
||||
<?= Html::a(
|
||||
Yii::t('user', 'Sign out'),
|
||||
['terminate-session', 'id' => $session->id],
|
||||
[
|
||||
'class' => 'btn btn-outline-secondary btn-sm',
|
||||
'data-method' => 'post',
|
||||
'data-confirm' => Yii::t('user', 'Are you sure you want to sign out this session?'),
|
||||
]
|
||||
) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if (empty($sessions)): ?>
|
||||
<div class="alert alert-info">
|
||||
<?= Yii::t('user', 'No active sessions found.') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
229
src/widgets/Captcha.php
Normal file
229
src/widgets/Captcha.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace cgsmith\user\widgets;
|
||||
|
||||
use cgsmith\user\Module;
|
||||
use cgsmith\user\services\CaptchaService;
|
||||
use Yii;
|
||||
use yii\base\Widget;
|
||||
use yii\helpers\Html;
|
||||
|
||||
/**
|
||||
* Universal CAPTCHA widget that renders the appropriate CAPTCHA type.
|
||||
*
|
||||
* Usage:
|
||||
* ```php
|
||||
* <?= Captcha::widget(['form' => $form, 'model' => $model, 'attribute' => 'captcha', 'formType' => 'login']) ?>
|
||||
* ```
|
||||
*/
|
||||
class Captcha extends Widget
|
||||
{
|
||||
public $form;
|
||||
public $model;
|
||||
public string $attribute = 'captcha';
|
||||
public string $formType = 'login';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function run(): string
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
if (!$module->enableCaptcha) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @var CaptchaService $captchaService */
|
||||
$captchaService = Yii::$container->get(CaptchaService::class);
|
||||
|
||||
if (!$captchaService->isEnabledForForm($this->formType)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match ($module->captchaType) {
|
||||
CaptchaService::TYPE_YII => $this->renderYiiCaptcha(),
|
||||
CaptchaService::TYPE_RECAPTCHA_V2 => $this->renderReCaptchaV2(),
|
||||
CaptchaService::TYPE_RECAPTCHA_V3 => $this->renderReCaptchaV3(),
|
||||
CaptchaService::TYPE_HCAPTCHA => $this->renderHCaptcha(),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Yii's built-in CAPTCHA.
|
||||
*/
|
||||
protected function renderYiiCaptcha(): string
|
||||
{
|
||||
if ($this->form === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->form->field($this->model, $this->attribute)->widget(\yii\captcha\Captcha::class, [
|
||||
'captchaAction' => '/user/security/captcha',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Google reCAPTCHA v2 checkbox.
|
||||
*/
|
||||
protected function renderReCaptchaV2(): string
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
if (empty($module->reCaptchaSiteKey)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->registerReCaptchaScript();
|
||||
|
||||
$html = '<div class="form-group">';
|
||||
$html .= '<div class="g-recaptcha" data-sitekey="' . Html::encode($module->reCaptchaSiteKey) . '"></div>';
|
||||
$html .= Html::activeHiddenInput($this->model, $this->attribute, ['id' => Html::getInputId($this->model, $this->attribute)]);
|
||||
$html .= '</div>';
|
||||
|
||||
$this->registerReCaptchaV2Callback();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Google reCAPTCHA v3 (invisible).
|
||||
*/
|
||||
protected function renderReCaptchaV3(): string
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
if (empty($module->reCaptchaSiteKey)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->registerReCaptchaV3Script();
|
||||
|
||||
/** @var CaptchaService $captchaService */
|
||||
$captchaService = Yii::$container->get(CaptchaService::class);
|
||||
$action = $captchaService->getReCaptchaAction($this->formType);
|
||||
|
||||
$html = Html::activeHiddenInput($this->model, $this->attribute, [
|
||||
'id' => Html::getInputId($this->model, $this->attribute),
|
||||
]);
|
||||
|
||||
$this->registerReCaptchaV3Callback($action);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render hCaptcha.
|
||||
*/
|
||||
protected function renderHCaptcha(): string
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
if (empty($module->hCaptchaSiteKey)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->registerHCaptchaScript();
|
||||
|
||||
$html = '<div class="form-group">';
|
||||
$html .= '<div class="h-captcha" data-sitekey="' . Html::encode($module->hCaptchaSiteKey) . '"></div>';
|
||||
$html .= Html::activeHiddenInput($this->model, $this->attribute, ['id' => Html::getInputId($this->model, $this->attribute)]);
|
||||
$html .= '</div>';
|
||||
|
||||
$this->registerHCaptchaCallback();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register reCAPTCHA v2 script.
|
||||
*/
|
||||
protected function registerReCaptchaScript(): void
|
||||
{
|
||||
$this->view->registerJsFile('https://www.google.com/recaptcha/api.js', [
|
||||
'async' => true,
|
||||
'defer' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register reCAPTCHA v2 callback.
|
||||
*/
|
||||
protected function registerReCaptchaV2Callback(): void
|
||||
{
|
||||
$inputId = Html::getInputId($this->model, $this->attribute);
|
||||
$js = <<<JS
|
||||
window.recaptchaCallback = function(response) {
|
||||
document.getElementById('{$inputId}').value = response;
|
||||
};
|
||||
JS;
|
||||
$this->view->registerJs($js);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register reCAPTCHA v3 script.
|
||||
*/
|
||||
protected function registerReCaptchaV3Script(): void
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
$this->view->registerJsFile(
|
||||
'https://www.google.com/recaptcha/api.js?render=' . Html::encode($module->reCaptchaSiteKey),
|
||||
['async' => true, 'defer' => true]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register reCAPTCHA v3 callback.
|
||||
*/
|
||||
protected function registerReCaptchaV3Callback(string $action): void
|
||||
{
|
||||
/** @var Module $module */
|
||||
$module = Yii::$app->getModule('user');
|
||||
|
||||
$siteKey = Html::encode($module->reCaptchaSiteKey);
|
||||
$inputId = Html::getInputId($this->model, $this->attribute);
|
||||
|
||||
$js = <<<JS
|
||||
grecaptcha.ready(function() {
|
||||
grecaptcha.execute('{$siteKey}', {action: '{$action}'}).then(function(token) {
|
||||
document.getElementById('{$inputId}').value = token;
|
||||
});
|
||||
});
|
||||
JS;
|
||||
$this->view->registerJs($js);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hCaptcha script.
|
||||
*/
|
||||
protected function registerHCaptchaScript(): void
|
||||
{
|
||||
$this->view->registerJsFile('https://js.hcaptcha.com/1/api.js', [
|
||||
'async' => true,
|
||||
'defer' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hCaptcha callback.
|
||||
*/
|
||||
protected function registerHCaptchaCallback(): void
|
||||
{
|
||||
$inputId = Html::getInputId($this->model, $this->attribute);
|
||||
$js = <<<JS
|
||||
window.hcaptchaCallback = function(response) {
|
||||
document.getElementById('{$inputId}').value = response;
|
||||
};
|
||||
JS;
|
||||
$this->view->registerJs($js);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user