From 98a0e33939238660613f87f6aeec2e32bd0329e9 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Wed, 28 Jan 2026 20:40:38 +0100 Subject: [PATCH] Add gdpr, session history, captcha --- src/Bootstrap.php | 19 ++ src/Module.php | 110 ++++++++- src/components/BackendUser.php | 95 ++++++++ src/controllers/GdprController.php | 46 +++- src/controllers/SecurityController.php | 19 ++ src/controllers/SettingsController.php | 79 ++++++ src/events/GdprEvent.php | 24 ++ src/filters/BackendAccessControl.php | 73 ++++++ src/filters/GdprConsentFilter.php | 58 +++++ .../m250128_000001_create_session_table.php | 43 ++++ ...m250128_000002_add_gdpr_consent_fields.php | 23 ++ ...m250128_000003_create_two_factor_table.php | 39 +++ src/models/GdprConsentForm.php | 65 +++++ src/models/LoginForm.php | 28 ++- src/models/RecoveryForm.php | 40 ++- src/models/RegistrationForm.php | 39 +++ src/models/Session.php | 135 +++++++++++ src/models/User.php | 11 + src/models/query/SessionQuery.php | 49 ++++ src/services/CaptchaService.php | 139 +++++++++++ src/services/GdprService.php | 167 +++++++++++++ src/services/SessionService.php | 201 +++++++++++++++ src/validators/HCaptchaValidator.php | 44 ++++ src/validators/ReCaptchaValidator.php | 44 ++++ src/views/gdpr/consent.php | 54 +++++ src/views/registration/_gdpr_consent.php | 38 +++ src/views/settings/sessions.php | 87 +++++++ src/widgets/Captcha.php | 229 ++++++++++++++++++ 28 files changed, 1994 insertions(+), 4 deletions(-) create mode 100644 src/components/BackendUser.php create mode 100644 src/events/GdprEvent.php create mode 100644 src/filters/BackendAccessControl.php create mode 100644 src/filters/GdprConsentFilter.php create mode 100644 src/migrations/m250128_000001_create_session_table.php create mode 100644 src/migrations/m250128_000002_add_gdpr_consent_fields.php create mode 100644 src/migrations/m250128_000003_create_two_factor_table.php create mode 100644 src/models/GdprConsentForm.php create mode 100644 src/models/Session.php create mode 100644 src/models/query/SessionQuery.php create mode 100644 src/services/CaptchaService.php create mode 100644 src/services/GdprService.php create mode 100644 src/services/SessionService.php create mode 100644 src/validators/HCaptchaValidator.php create mode 100644 src/validators/ReCaptchaValidator.php create mode 100644 src/views/gdpr/consent.php create mode 100644 src/views/registration/_gdpr_consent.php create mode 100644 src/views/settings/sessions.php create mode 100644 src/widgets/Captcha.php diff --git a/src/Bootstrap.php b/src/Bootstrap.php index bb27e94..b7137a0 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -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/' => '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; } } diff --git a/src/Module.php b/src/Module.php index 0da691c..11449da 100644 --- a/src/Module.php +++ b/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/' => '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; } diff --git a/src/components/BackendUser.php b/src/components/BackendUser.php new file mode 100644 index 0000000..4f0121f --- /dev/null +++ b/src/components/BackendUser.php @@ -0,0 +1,95 @@ + [ + * '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; + } +} diff --git a/src/controllers/GdprController.php b/src/controllers/GdprController.php index 64ad8e5..8751877 100644 --- a/src/controllers/GdprController.php +++ b/src/controllers/GdprController.php @@ -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. */ diff --git a/src/controllers/SecurityController.php b/src/controllers/SecurityController.php index bcc3ff2..1e2cdca 100644 --- a/src/controllers/SecurityController.php +++ b/src/controllers/SecurityController.php @@ -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 diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 61dc085..f763ee8 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -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']); + } } diff --git a/src/events/GdprEvent.php b/src/events/GdprEvent.php new file mode 100644 index 0000000..5a5354f --- /dev/null +++ b/src/events/GdprEvent.php @@ -0,0 +1,24 @@ + [ + * '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); + } +} diff --git a/src/filters/GdprConsentFilter.php b/src/filters/GdprConsentFilter.php new file mode 100644 index 0000000..08ad989 --- /dev/null +++ b/src/filters/GdprConsentFilter.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/src/migrations/m250128_000001_create_session_table.php b/src/migrations/m250128_000001_create_session_table.php new file mode 100644 index 0000000..e10105b --- /dev/null +++ b/src/migrations/m250128_000001_create_session_table.php @@ -0,0 +1,43 @@ +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}}'); + } +} diff --git a/src/migrations/m250128_000002_add_gdpr_consent_fields.php b/src/migrations/m250128_000002_add_gdpr_consent_fields.php new file mode 100644 index 0000000..c4b4f2f --- /dev/null +++ b/src/migrations/m250128_000002_add_gdpr_consent_fields.php @@ -0,0 +1,23 @@ +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'); + } +} diff --git a/src/migrations/m250128_000003_create_two_factor_table.php b/src/migrations/m250128_000003_create_two_factor_table.php new file mode 100644 index 0000000..2dc60ce --- /dev/null +++ b/src/migrations/m250128_000003_create_two_factor_table.php @@ -0,0 +1,39 @@ +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}}'); + } +} diff --git a/src/models/GdprConsentForm.php b/src/models/GdprConsentForm.php new file mode 100644 index 0000000..5a1742c --- /dev/null +++ b/src/models/GdprConsentForm.php @@ -0,0 +1,65 @@ +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; + } +} diff --git a/src/models/LoginForm.php b/src/models/LoginForm.php index ad0c6f3..ab14f79 100644 --- a/src/models/LoginForm.php +++ b/src/models/LoginForm.php @@ -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'], + }; } /** diff --git a/src/models/RecoveryForm.php b/src/models/RecoveryForm.php index 2ba24f5..d6d71c2 100644 --- a/src/models/RecoveryForm.php +++ b/src/models/RecoveryForm.php @@ -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; + } } diff --git a/src/models/RegistrationForm.php b/src/models/RegistrationForm.php index 124f53a..f460ba3 100644 --- a/src/models/RegistrationForm.php +++ b/src/models/RegistrationForm.php @@ -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'), ]; } diff --git a/src/models/Session.php b/src/models/Session.php new file mode 100644 index 0000000..fa714f4 --- /dev/null +++ b/src/models/Session.php @@ -0,0 +1,135 @@ + 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}"; + } +} diff --git a/src/models/User.php b/src/models/User.php index f4359b2..1a9445b 100644 --- a/src/models/User.php +++ b/src/models/User.php @@ -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 /** diff --git a/src/models/query/SessionQuery.php b/src/models/query/SessionQuery.php new file mode 100644 index 0000000..1181d76 --- /dev/null +++ b/src/models/query/SessionQuery.php @@ -0,0 +1,49 @@ +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)]); + } +} diff --git a/src/services/CaptchaService.php b/src/services/CaptchaService.php new file mode 100644 index 0000000..8e9619e --- /dev/null +++ b/src/services/CaptchaService.php @@ -0,0 +1,139 @@ +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', + }; + } +} diff --git a/src/services/GdprService.php b/src/services/GdprService.php new file mode 100644 index 0000000..e35c1c2 --- /dev/null +++ b/src/services/GdprService.php @@ -0,0 +1,167 @@ + $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']); + } +} diff --git a/src/services/SessionService.php b/src/services/SessionService.php new file mode 100644 index 0000000..6a85ea5 --- /dev/null +++ b/src/services/SessionService.php @@ -0,0 +1,201 @@ +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]); + } +} diff --git a/src/validators/HCaptchaValidator.php b/src/validators/HCaptchaValidator.php new file mode 100644 index 0000000..e0a468f --- /dev/null +++ b/src/validators/HCaptchaValidator.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/src/validators/ReCaptchaValidator.php b/src/validators/ReCaptchaValidator.php new file mode 100644 index 0000000..6ca026e --- /dev/null +++ b/src/validators/ReCaptchaValidator.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/src/views/gdpr/consent.php b/src/views/gdpr/consent.php new file mode 100644 index 0000000..26be431 --- /dev/null +++ b/src/views/gdpr/consent.php @@ -0,0 +1,54 @@ +activeFormClass; +$this->title = Yii::t('user', 'Privacy Consent Required'); +?> + + diff --git a/src/views/registration/_gdpr_consent.php b/src/views/registration/_gdpr_consent.php new file mode 100644 index 0000000..52871c9 --- /dev/null +++ b/src/views/registration/_gdpr_consent.php @@ -0,0 +1,38 @@ + + +enableGdprConsent && $module->requireGdprConsentBeforeRegistration): ?> + + diff --git a/src/views/settings/sessions.php b/src/views/settings/sessions.php new file mode 100644 index 0000000..60f9096 --- /dev/null +++ b/src/views/settings/sessions.php @@ -0,0 +1,87 @@ +title = Yii::t('user', 'Active Sessions'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Settings'), 'url' => ['account']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +
+

title) ?>

+

+ + 1): ?> +

+ '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?'), + ] + ) ?> +

+ + +
+ +
+
+
+
+
+ device_name) ?> + isCurrent): ?> + + +
+

+ + Html::encode($session->ip ?? 'Unknown')]) ?> + +

+

+ + Yii::$app->formatter->asRelativeTime($session->last_activity_at) + ]) ?> + · + Yii::$app->formatter->asRelativeTime($session->created_at) + ]) ?> + +

+
+ isCurrent): ?> +
+ $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?'), + ] + ) ?> +
+ +
+
+
+ +
+ + +
+ +
+ +
diff --git a/src/widgets/Captcha.php b/src/widgets/Captcha.php new file mode 100644 index 0000000..fde6255 --- /dev/null +++ b/src/widgets/Captcha.php @@ -0,0 +1,229 @@ + $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 = '
'; + $html .= '
'; + $html .= Html::activeHiddenInput($this->model, $this->attribute, ['id' => Html::getInputId($this->model, $this->attribute)]); + $html .= '
'; + + $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 = '
'; + $html .= '
'; + $html .= Html::activeHiddenInput($this->model, $this->attribute, ['id' => Html::getInputId($this->model, $this->attribute)]); + $html .= '
'; + + $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 = <<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 = <<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 = <<view->registerJs($js); + } +}