Add gdpr, session history, captcha

This commit is contained in:
2026-01-28 20:40:38 +01:00
parent 4389470233
commit 98a0e33939
28 changed files with 1994 additions and 4 deletions

View File

@@ -102,6 +102,18 @@ class Bootstrap implements BootstrapInterface
return new \cgsmith\user\services\MailerService($module); 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 // Bind module for injection
$container->setSingleton(Module::class, function () use ($module) { $container->setSingleton(Module::class, function () use ($module) {
return $module; return $module;
@@ -131,6 +143,9 @@ class Bootstrap implements BootstrapInterface
'settings' => 'settings/account', 'settings' => 'settings/account',
'settings/account' => 'settings/account', 'settings/account' => 'settings/account',
'settings/profile' => 'settings/profile', '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' => 'admin/index', 'admin' => 'admin/index',
@@ -151,6 +166,10 @@ class Bootstrap implements BootstrapInterface
$rules['gdpr/delete'] = 'gdpr/delete'; $rules['gdpr/delete'] = 'gdpr/delete';
} }
if ($module->enableGdprConsent) {
$rules['gdpr/consent'] = 'gdpr/consent';
}
return $rules; return $rules;
} }
} }

View File

@@ -49,10 +49,34 @@ class Module extends BaseModule implements BootstrapInterface
/** /**
* Whether to enable GDPR features (data export, account deletion). * Whether to enable GDPR features (data export, account deletion).
* @todo GDPR is not yet fully implemented - planned for v2
*/ */
public bool $enableGdpr = false; 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. * Whether to enable user impersonation by admins.
*/ */
@@ -83,6 +107,71 @@ class Module extends BaseModule implements BootstrapInterface
*/ */
public bool $enableAccountDelete = true; 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. * Email change strategy.
*/ */
@@ -325,6 +414,18 @@ class Module extends BaseModule implements BootstrapInterface
return new \cgsmith\user\services\MailerService($this); 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 () { $container->setSingleton(Module::class, function () {
return $this; return $this;
}); });
@@ -346,6 +447,9 @@ class Module extends BaseModule implements BootstrapInterface
'settings' => 'settings/account', 'settings' => 'settings/account',
'settings/account' => 'settings/account', 'settings/account' => 'settings/account',
'settings/profile' => 'settings/profile', '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' => 'admin/index',
'admin/index' => 'admin/index', 'admin/index' => 'admin/index',
'admin/create' => 'admin/create', 'admin/create' => 'admin/create',
@@ -363,6 +467,10 @@ class Module extends BaseModule implements BootstrapInterface
$rules['gdpr/delete'] = 'gdpr/delete'; $rules['gdpr/delete'] = 'gdpr/delete';
} }
if ($this->enableGdprConsent) {
$rules['gdpr/consent'] = 'gdpr/consent';
}
return $rules; return $rules;
} }

View 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;
}
}

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace cgsmith\user\controllers; namespace cgsmith\user\controllers;
use cgsmith\user\models\GdprConsentForm;
use cgsmith\user\models\User; use cgsmith\user\models\User;
use cgsmith\user\Module; use cgsmith\user\Module;
use cgsmith\user\services\GdprService;
use Yii; use Yii;
use yii\db\Expression; use yii\db\Expression;
use yii\filters\AccessControl; use yii\filters\AccessControl;
@@ -48,13 +50,55 @@ class GdprController extends Controller
/** @var Module $module */ /** @var Module $module */
$module = $this->module; $module = $this->module;
if (!$module->enableGdpr) { if ($action->id === 'consent') {
if (!$module->enableGdprConsent) {
throw new NotFoundHttpException();
}
} elseif (!$module->enableGdpr) {
throw new NotFoundHttpException(); throw new NotFoundHttpException();
} }
return parent::beforeAction($action); 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. * GDPR overview page.
*/ */

View File

@@ -6,7 +6,9 @@ namespace cgsmith\user\controllers;
use cgsmith\user\events\FormEvent; use cgsmith\user\events\FormEvent;
use cgsmith\user\models\LoginForm; use cgsmith\user\models\LoginForm;
use cgsmith\user\models\User;
use cgsmith\user\Module; use cgsmith\user\Module;
use cgsmith\user\services\SessionService;
use Yii; use Yii;
use yii\filters\AccessControl; use yii\filters\AccessControl;
use yii\filters\VerbFilter; use yii\filters\VerbFilter;
@@ -70,6 +72,13 @@ class SecurityController extends Controller
$module->trigger(self::EVENT_BEFORE_LOGIN, $event); $module->trigger(self::EVENT_BEFORE_LOGIN, $event);
if ($model->load(Yii::$app->request->post()) && $model->login()) { if ($model->load(Yii::$app->request->post()) && $model->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 // Trigger after login event
$event = new FormEvent(['form' => $model]); $event = new FormEvent(['form' => $model]);
$module->trigger(self::EVENT_AFTER_LOGIN, $event); $module->trigger(self::EVENT_AFTER_LOGIN, $event);
@@ -91,10 +100,20 @@ class SecurityController extends Controller
/** @var Module $module */ /** @var Module $module */
$module = $this->module; $module = $this->module;
/** @var User|null $user */
$user = Yii::$app->user->identity;
// Trigger before logout event // Trigger before logout event
$event = new FormEvent(['form' => null]); $event = new FormEvent(['form' => null]);
$module->trigger(self::EVENT_BEFORE_LOGOUT, $event); $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(); Yii::$app->user->logout();
// Trigger after logout event // Trigger after logout event

View File

@@ -10,6 +10,7 @@ use cgsmith\user\models\Token;
use cgsmith\user\models\User; use cgsmith\user\models\User;
use cgsmith\user\Module; use cgsmith\user\Module;
use cgsmith\user\services\MailerService; use cgsmith\user\services\MailerService;
use cgsmith\user\services\SessionService;
use cgsmith\user\services\TokenService; use cgsmith\user\services\TokenService;
use Yii; use Yii;
use yii\filters\AccessControl; use yii\filters\AccessControl;
@@ -39,6 +40,8 @@ class SettingsController extends Controller
'class' => VerbFilter::class, 'class' => VerbFilter::class,
'actions' => [ 'actions' => [
'delete-avatar' => ['post'], 'delete-avatar' => ['post'],
'terminate-session' => ['post'],
'terminate-all-sessions' => ['post'],
], ],
], ],
]; ];
@@ -230,4 +233,80 @@ class SettingsController extends Controller
return $this->redirect(['account']); 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
View 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;
}

View 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);
}
}

View 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;
}
}

View 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}}');
}
}

View 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');
}
}

View 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}}');
}
}

View 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;
}
}

View File

@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace cgsmith\user\models; namespace cgsmith\user\models;
use cgsmith\user\Module; use cgsmith\user\Module;
use cgsmith\user\services\CaptchaService;
use cgsmith\user\validators\HCaptchaValidator;
use cgsmith\user\validators\ReCaptchaValidator;
use Yii; use Yii;
use yii\base\Model; use yii\base\Model;
@@ -16,6 +19,7 @@ class LoginForm extends Model
public ?string $login = null; public ?string $login = null;
public ?string $password = null; public ?string $password = null;
public bool $rememberMe = false; public bool $rememberMe = false;
public ?string $captcha = null;
private ?User $_user = null; private ?User $_user = null;
@@ -24,13 +28,35 @@ class LoginForm extends Model
*/ */
public function rules(): array public function rules(): array
{ {
return [ $module = $this->getModule();
$rules = [
[['login', 'password'], 'required'], [['login', 'password'], 'required'],
[['login'], 'string'], [['login'], 'string'],
[['password'], 'string'], [['password'], 'string'],
[['rememberMe'], 'boolean'], [['rememberMe'], 'boolean'],
[['password'], 'validatePassword'], [['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'],
};
} }
/** /**

View File

@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace cgsmith\user\models; 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;
use yii\base\Model; use yii\base\Model;
@@ -13,6 +17,7 @@ use yii\base\Model;
class RecoveryForm extends Model class RecoveryForm extends Model
{ {
public ?string $email = null; public ?string $email = null;
public ?string $captcha = null;
private ?User $_user = null; private ?User $_user = null;
@@ -21,12 +26,34 @@ class RecoveryForm extends Model
*/ */
public function rules(): array public function rules(): array
{ {
return [ $module = $this->getModule();
$rules = [
['email', 'trim'], ['email', 'trim'],
['email', 'required'], ['email', 'required'],
['email', 'email'], ['email', 'email'],
['email', 'validateEmail'], ['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; return $this->_user;
} }
/**
* Get the user module.
*/
protected function getModule(): Module
{
/** @var Module $module */
$module = Yii::$app->getModule('user');
return $module;
}
} }

View File

@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace cgsmith\user\models; namespace cgsmith\user\models;
use cgsmith\user\Module; use cgsmith\user\Module;
use cgsmith\user\services\CaptchaService;
use cgsmith\user\validators\HCaptchaValidator;
use cgsmith\user\validators\ReCaptchaValidator;
use Yii; use Yii;
use yii\base\Model; use yii\base\Model;
@@ -16,6 +19,9 @@ class RegistrationForm extends Model
public ?string $email = null; public ?string $email = null;
public ?string $username = null; public ?string $username = null;
public ?string $password = null; public ?string $password = null;
public bool $gdprConsent = false;
public bool $gdprMarketingConsent = false;
public ?string $captcha = null;
/** /**
* {@inheritdoc} * {@inheritdoc}
@@ -37,6 +43,13 @@ class RegistrationForm extends Model
['username', 'string', 'min' => 3, 'max' => 255], ['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', '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.')], ['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) // Password rules (unless generated)
@@ -45,9 +58,33 @@ class RegistrationForm extends Model
$rules[] = ['password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength]; $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; 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} * {@inheritdoc}
*/ */
@@ -57,6 +94,8 @@ class RegistrationForm extends Model
'email' => Yii::t('user', 'Email'), 'email' => Yii::t('user', 'Email'),
'username' => Yii::t('user', 'Username'), 'username' => Yii::t('user', 'Username'),
'password' => Yii::t('user', 'Password'), '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
View 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}";
}
}

View File

@@ -33,6 +33,8 @@ use yii\web\IdentityInterface;
* @property string $created_at * @property string $created_at
* @property string $updated_at * @property string $updated_at
* @property string|null $gdpr_consent_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 string|null $gdpr_deleted_at
* *
* @property-read bool $isAdmin * @property-read bool $isAdmin
@@ -40,6 +42,7 @@ use yii\web\IdentityInterface;
* @property-read bool $isConfirmed * @property-read bool $isConfirmed
* @property-read Profile $profile * @property-read Profile $profile
* @property-read Token[] $tokens * @property-read Token[] $tokens
* @property-read Session[] $sessions
*/ */
class User extends ActiveRecord implements IdentityInterface, UserInterface 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']); 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 // Helper methods
/** /**

View 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)]);
}
}

View 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',
};
}
}

View 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']);
}
}

View 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]);
}
}

View 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;
}
}

View 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;
}
}

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

View 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; ?>

View 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)
]) ?>
&middot;
<?= 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
View 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);
}
}