diff --git a/docs/helpful-guides/how-to-use-session-history.md b/docs/helpful-guides/how-to-use-session-history.md new file mode 100755 index 0000000..508c49f --- /dev/null +++ b/docs/helpful-guides/how-to-use-session-history.md @@ -0,0 +1,60 @@ +How to enable session history +============================ + +Session history is list user sessions. + +User can delete all session except current. + +Configure Module and Application +-------------------------------- + +```php + +// ... + +'modules' => [ + 'user' => [ + 'class' => Da\User\Module::class, + 'enableSessionHistory' => true, + ] +], + +// ... + +'components' => [ + 'session' => Da\User\Service\SessionHistory\SessionHistoryDecorator::class, +] + +// ... + +'container' => [ + 'singletons' => [ + Da\User\Service\SessionHistory\TerminateSessionsServiceInterface::class => Da\User\Service\SessionHistory\TerminateSessionsService::class + ] +] + +// ... + +'controllerMap' => [ + 'migrate' => [ + ... + 'migrationNamespaces' => [ + 'Da\User\Migration\Session', + ], + ], +], + +``` + +Additionally for upping migration can use +``` +./yii migrate --migrationNamespaces=Da\\User\\Migration\Session +``` + +Setting user screenshot: +![Settings user screenshot](./session-history/settings.png) + +Admin screenshot: +![Admin screenshot](./session-history/admin.png) + +© [2amigos](http://www.2amigos.us/) 2013-2019 diff --git a/docs/helpful-guides/session-history/admin.png b/docs/helpful-guides/session-history/admin.png new file mode 100755 index 0000000..7328174 Binary files /dev/null and b/docs/helpful-guides/session-history/admin.png differ diff --git a/docs/helpful-guides/session-history/settings.png b/docs/helpful-guides/session-history/settings.png new file mode 100755 index 0000000..d226ce1 Binary files /dev/null and b/docs/helpful-guides/session-history/settings.png differ diff --git a/docs/index.md b/docs/index.md old mode 100644 new mode 100755 index 3b55242..839b1ac --- a/docs/index.md +++ b/docs/index.md @@ -187,6 +187,7 @@ Helpful Guides - [How to Switch Identities](helpful-guides/how-to-swith-identities.md) - [Separate Frontend and Backend Sessions](helpful-guides/separate-frontend-and-backend-sessions.md) - [Social Network Authentication](helpful-guides/social-network-authentication.md) +- [How to Enable session history](helpful-guides/how-to-use-session-history.md) Contributing ------------ diff --git a/docs/installation/configuration-options.md b/docs/installation/configuration-options.md old mode 100644 new mode 100755 index e166aac..9332a6c --- a/docs/installation/configuration-options.md +++ b/docs/installation/configuration-options.md @@ -3,6 +3,22 @@ Configuration Options The module comes with a set of attributes to configure. The following is the list of all available options: +#### enableSessionHistory (Type: `boolean, integer`, Default value: `false`) + +If this option is to `true`, session history will be kept, [more](../helpful-guides/how-to-use-session-history.md). + +#### numberSessionHistory (Type: `boolean, integer`, Default value: `false`) + +Number of expired storing records `session history`, values: +- `false` Store all records without deleting +- `integer` Count of records for storing + +#### timeoutSessionHistory (Type: `boolean, integer`, Default value: `false`) + +How long store `session history` after expiring, values: +- `false` Store all records without deleting +- `integer` Time for storing after expiring + #### enableTwoFactorAuthentication (type: `boolean`, default: `false`) Setting this attribute will allow users to configure their login process with two-factor authentication. diff --git a/src/User/Bootstrap.php b/src/User/Bootstrap.php old mode 100644 new mode 100755 index fd51c33..487f448 --- a/src/User/Bootstrap.php +++ b/src/User/Bootstrap.php @@ -16,7 +16,9 @@ use Da\User\Contracts\AuthManagerInterface; use Da\User\Controller\SecurityController; use Da\User\Event\FormEvent; use Da\User\Helper\ClassMapHelper; +use Da\User\Model\SessionHistory; use Da\User\Model\User; +use Da\User\Search\SessionHistorySearch; use Yii; use yii\authclient\Collection; use yii\base\Application; @@ -128,7 +130,7 @@ class Bootstrap implements BootstrapInterface $model = is_array($definition) ? $definition['class'] : $definition; $name = substr($class, strrpos($class, '\\') + 1); $modelClassMap[$class] = $model; - if (in_array($name, ['User', 'Profile', 'Token', 'SocialNetworkAccount'])) { + if (in_array($name, ['User', 'Profile', 'Token', 'SocialNetworkAccount', 'SessionHistory'])) { $di->set( "Da\\User\\Query\\{$name}Query", function () use ($model) { @@ -315,10 +317,12 @@ class Bootstrap implements BootstrapInterface 'Assignment' => 'Da\User\Model\Assignment', 'Permission' => 'Da\User\Model\Permission', 'Role' => 'Da\User\Model\Role', + 'SessionHistory' => SessionHistory::class, // --- search 'UserSearch' => 'Da\User\Search\UserSearch', 'PermissionSearch' => 'Da\User\Search\PermissionSearch', 'RoleSearch' => 'Da\User\Search\RoleSearch', + 'SessionHistorySearch' => SessionHistorySearch::class, // --- forms 'RegistrationForm' => 'Da\User\Form\RegistrationForm', 'ResendForm' => 'Da\User\Form\ResendForm', @@ -338,11 +342,13 @@ class Bootstrap implements BootstrapInterface 'Assignment', 'Permission', 'Role', + 'SessionHistory' ], 'Da\User\Search' => [ 'UserSearch', 'PermissionSearch', 'RoleSearch', + 'SessionHistorySearch', ], 'Da\User\Form' => [ 'RegistrationForm', diff --git a/src/User/Controller/AdminController.php b/src/User/Controller/AdminController.php old mode 100644 new mode 100755 index 912b8d8..7b8ebab --- a/src/User/Controller/AdminController.php +++ b/src/User/Controller/AdminController.php @@ -17,9 +17,11 @@ use Da\User\Filter\AccessRuleFilter; use Da\User\Model\Profile; use Da\User\Model\User; use Da\User\Query\UserQuery; +use Da\User\Search\SessionHistorySearch; use Da\User\Search\UserSearch; use Da\User\Service\PasswordExpireService; use Da\User\Service\PasswordRecoveryService; +use Da\User\Service\SessionHistory\TerminateUserSessionsService; use Da\User\Service\SwitchIdentityService; use Da\User\Service\UserBlockService; use Da\User\Service\UserConfirmationService; @@ -66,7 +68,7 @@ class AdminController extends Controller */ public function beforeAction($action) { - if (in_array($action->id, ['index', 'update', 'update-profile', 'info', 'assignments'], true)) { + if (in_array($action->id, ['index', 'update', 'update-profile', 'info', 'assignments', 'session-history'], true)) { Url::remember('', 'actions-redirect'); } @@ -88,6 +90,7 @@ class AdminController extends Controller 'switch-identity' => ['post'], 'password-reset' => ['post'], 'force-password-change' => ['post'], + 'terminate-sessions' => ['post'], ], ], 'access' => [ @@ -346,4 +349,33 @@ class AdminController extends Controller } $this->redirect(['index']); } + + /** + * Display list session history + */ + public function actionSessionHistory($id) + { + $searchModel = new SessionHistorySearch([ + 'user_id' => $id, + ]); + $dataProvider = $searchModel->search(Yii::$app->request->queryParams); + + $user = $this->userQuery->where(['id' => $id])->one(); + + return $this->render('_session-history', [ + 'searchModel' => $searchModel, + 'dataProvider' => $dataProvider, + 'user' => $user, + ]); + } + + /** + * Terminate all session user + */ + public function actionTerminateSessions($id) + { + $this->make(TerminateUserSessionsService::class, [$id])->run(); + + return $this->redirect(Url::previous('actions-redirect')); + } } diff --git a/src/User/Controller/SettingsController.php b/src/User/Controller/SettingsController.php old mode 100644 new mode 100755 index 3b0aef3..e40c30b --- a/src/User/Controller/SettingsController.php +++ b/src/User/Controller/SettingsController.php @@ -26,7 +26,9 @@ use Da\User\Module; use Da\User\Query\ProfileQuery; use Da\User\Query\SocialNetworkAccountQuery; use Da\User\Query\UserQuery; +use Da\User\Search\SessionHistorySearch; use Da\User\Service\EmailChangeService; +use Da\User\Service\SessionHistory\TerminateUserSessionsService; use Da\User\Service\TwoFactorQrCodeUriGeneratorService; use Da\User\Traits\ContainerAwareTrait; use Da\User\Traits\ModuleAwareTrait; @@ -91,7 +93,8 @@ class SettingsController extends Controller 'actions' => [ 'disconnect' => ['post'], 'delete' => ['post'], - 'two-factor-disable' => ['post'] + 'two-factor-disable' => ['post'], + 'terminate-sessions' => ['post'], ], ], 'access' => [ @@ -111,7 +114,9 @@ class SettingsController extends Controller 'delete', 'two-factor', 'two-factor-enable', - 'two-factor-disable' + 'two-factor-disable', + 'session-history', + 'terminate-sessions', ], 'roles' => ['@'], ], @@ -463,6 +468,32 @@ class SettingsController extends Controller $this->redirect(['account']); } + /** + * Display list session history. + */ + public function actionSessionHistory() + { + $searchModel = new SessionHistorySearch([ + 'user_id' => Yii::$app->user->id, + ]); + $dataProvider = $searchModel->search(Yii::$app->request->queryParams); + + return $this->render('session-history', [ + 'searchModel' => $searchModel, + 'dataProvider' => $dataProvider, + ]); + } + + /** + * Terminate all session user + */ + public function actionTerminateSessions() + { + $this->make(TerminateUserSessionsService::class, [Yii::$app->user->id])->run(); + + return $this->redirect(['session-history']); + } + /** * @param $id * @throws ForbiddenHttpException diff --git a/src/User/Event/SessionEvent.php b/src/User/Event/SessionEvent.php new file mode 100755 index 0000000..196d8e1 --- /dev/null +++ b/src/User/Event/SessionEvent.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Event; + +use Da\User\Model\User; +use yii\base\Event; + +/** + * @property-read User $user + */ +class SessionEvent extends Event +{ + const EVENT_BEFORE_TERMINATE_USER_SESSIONS = 'beforeTerminateUserSessions'; + const EVENT_AFTER_TERMINATE_USER_SESSIONS = 'afterTerminateUserSessions'; + + protected $user; + + public function __construct(User $user, array $config = []) + { + $this->user = $user; + parent::__construct($config); + } + + public function getUser() + { + return $this->user; + } +} diff --git a/src/User/Migration/Session/m000000_000001_create_session_history_table.php b/src/User/Migration/Session/m000000_000001_create_session_history_table.php new file mode 100755 index 0000000..a33ccad --- /dev/null +++ b/src/User/Migration/Session/m000000_000001_create_session_history_table.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Migration\Session; + +use Da\User\Helper\MigrationHelper; +use yii\db\Migration; + + +class m000000_000001_create_session_history_table extends Migration +{ + const SESSION_HISTORY_TABLE = '{{%session_history}}'; + const USER_TABLE = '{{%user}}'; + + /** + * {@inheritdoc} + */ + public function safeUp() + { + $this->createTable(self::SESSION_HISTORY_TABLE, [ + 'user_id' => $this->integer(), + 'session_id' => $this->string()->null(), + 'user_agent' => $this->string()->notNull(), + 'ip' => $this->string(45)->notNull(), + 'created_at' => $this->integer()->notNull(), + 'updated_at' => $this->integer()->notNull(), + ]); + + $this->createIndex( + '{{%session_history_user_id}}', + self::SESSION_HISTORY_TABLE, + ['user_id'] + ); + + $this->createIndex( + '{{%session_history_session_id}}', + self::SESSION_HISTORY_TABLE, + ['session_id'] + ); + + $this->createIndex( + '{{%session_history_updated_at}}', + self::SESSION_HISTORY_TABLE, + ['updated_at'] + ); + + $this->addForeignKey( + '{{%fk_user_session_history}}', + self::SESSION_HISTORY_TABLE, + 'user_id', + self::USER_TABLE, + 'id', + 'CASCADE', + MigrationHelper::isMicrosoftSQLServer($this->db->driverName) ? 'NO ACTION' : 'RESTRICT' + ); + } + + /** + * {@inheritdoc} + */ + public function safeDown() + { + $this->dropTable(self::SESSION_HISTORY_TABLE); + } +} diff --git a/src/User/Model/SessionHistory.php b/src/User/Model/SessionHistory.php new file mode 100755 index 0000000..4331e55 --- /dev/null +++ b/src/User/Model/SessionHistory.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Model; + +use Da\User\Module; +use Da\User\Query\SessionHistoryQuery; +use Da\User\Traits\ModuleAwareTrait; +use Yii; +use yii\behaviors\TimestampBehavior; +use yii\db\ActiveRecord; +use yii\db\ActiveQuery; + +/** + * @property int $user_id + * @property string $session_id + * @property string $user_agent + * @property string $ip + * @property int $created_at + * @property int $updated_at + * + * @property User $user + * @property bool $isActive + * + * Dependencies: + * @property-read Module $module + */ +class SessionHistory extends ActiveRecord +{ + use ModuleAwareTrait; + + /** + * {@inheritdoc} + */ + public static function tableName() + { + return '{{%session_history}}'; + } + + /** @inheritdoc */ + public function behaviors() + { + return [ + [ + 'class' => TimestampBehavior::class, + 'updatedAtAttribute' => false, + ] + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels() + { + return [ + 'user_id' => Yii::t('usuario', 'User ID'), + 'session_id' => Yii::t('usuario', 'Session ID'), + 'user_agent' => Yii::t('usuario', 'User agent'), + 'ip' => Yii::t('usuario', 'IP'), + 'created_at' => Yii::t('usuario', 'Created at'), + 'updated_at' => Yii::t('usuario', 'Last activity'), + ]; + } + + /** + * @return bool Whether the session is an active or not. + */ + public function getIsActive() + { + return isset($this->session_id) && $this->updated_at + $this->getModule()->rememberLoginLifespan > time(); + } + + /** + * @return ActiveQuery + */ + public function getUser() + { + return $this->hasOne($this->module->classMap['User'], ['id' => 'user_id']); + } + + /** @inheritdoc */ + public function beforeSave($insert) + { + if ($insert && empty($this->session_id)) { + $this->setAttribute('session_id', Yii::$app->session->getId()); + } + + return parent::beforeSave($insert); + } + + /** @inheritdoc */ + public static function primaryKey() + { + return ['user_id', 'session_id']; + } + + public static function find() + { + return new SessionHistoryQuery(static::class); + } +} diff --git a/src/User/Module.php b/src/User/Module.php old mode 100644 new mode 100755 index 0f10f49..349f47e --- a/src/User/Module.php +++ b/src/User/Module.php @@ -22,6 +22,21 @@ use yii\helpers\Html; */ class Module extends BaseModule { + /** + * @var bool Enable the 'session history' function + * Using with {@see SessionHistoryDecorator} + */ + public $enableSessionHistory = false; + /** + * @var int|bool The number of 'session history' records will be stored for user + * if equals false records will not be deleted + */ + public $numberSessionHistory = false; + /** + * @var int|bool The time after which the expired 'session history' will be deleted + * if equals false records will not be deleted + */ + public $timeoutSessionHistory = false; /** * @var bool whether to enable european G.D.P.R. compliance. * This will add a few elements to comply with european general data protection regulation. @@ -226,4 +241,20 @@ class Module extends BaseModule return $this->gdprConsentMessage ?: $defaultConsentMessage; } + + /** + * @return bool + */ + public function hasNumberSessionHistory() + { + return $this->numberSessionHistory !== false && $this->numberSessionHistory > 0; + } + + /** + * @return bool + */ + public function hasTimeoutSessionHistory() + { + return $this->timeoutSessionHistory !== false && $this->timeoutSessionHistory > 0; + } } diff --git a/src/User/Query/SessionHistoryCondition.php b/src/User/Query/SessionHistoryCondition.php new file mode 100755 index 0000000..c774c66 --- /dev/null +++ b/src/User/Query/SessionHistoryCondition.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Query; + +use Da\User\Traits\ModuleAwareTrait; +use yii\web\Session; +use Yii; + +class SessionHistoryCondition +{ + use ModuleAwareTrait; + + private $session; + + public function __construct(Session $session) + { + $this->session = $session; + } + + public function unbindSession() + { + return ['session_id' => null]; + } + + public function bySession($sessionId) + { + return ['session_id' => $sessionId]; + } + + public function byUser($userId) + { + return [ + 'user_id' => $userId, + ]; + } + + public function byUserSession($userId, $sessionId) + { + return [ + 'user_id' => $userId, + 'session_id' => $sessionId, + ]; + } + + public function inactive($userId = null) + { + $where = [ + 'AND', + ['session_id' => null] + ]; + + if (isset($userId)) { + $where[] = $this->byUser($userId); + } + + return $where; + } + + public function expired($userId = null) + { + $where = [ + 'AND', + ['<', 'updated_at', $this->getExpiredTime()] + ]; + + if (isset($userId)) { + $where[] = $this->byUser($userId); + } + + return $where; + } + + public function expiredInactive($userId = null) + { + return [ + 'OR', + $this->expired($userId), + $this->inactive($userId), + ]; + } + + public function shouldDeleteBefore($updatedAt, $userId) + { + $condition = ['<', 'updated_at', $updatedAt]; + if ($updatedAt > $this->getExpiredTime()) { + $condition = [ + 'OR', + [ + 'AND', + $this->inactive(), + $condition, + ], + $this->expired() + ]; + } + + return [ + 'AND', + $this->byUser($userId), + $condition, + ]; + } + + /** + * @return int + */ + public function getExpiredTime() + { + $module = $this->getModule(); + $time = time() - max($module->rememberLoginLifespan, $this->session->getTimeout()); + if (false === $module->hasTimeoutSessionHistory()) { + return $time; + } + + return $time - $module->timeoutSessionHistory; + } + + public function inactiveData() + { + return [ + 'session_id' => null, + ]; + } + + /** + * @return array + */ + public function currentUserData() + { + return [ + 'user_id' => Yii::$app->user->id, + 'session_id' => Yii::$app->session->getId(), + 'user_agent' => Yii::$app->request->userAgent, + 'ip' => Yii::$app->request->userIP, + ]; + } + + /** + * @return array + */ + public function currentUserCondition() + { + return [ + 'user_id' => Yii::$app->user->id, + 'session_id' => Yii::$app->session->getId(), + 'user_agent' => Yii::$app->request->userAgent, + ]; + } +} diff --git a/src/User/Query/SessionHistoryQuery.php b/src/User/Query/SessionHistoryQuery.php new file mode 100755 index 0000000..d8ad71c --- /dev/null +++ b/src/User/Query/SessionHistoryQuery.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Query; + +use Da\User\Traits\ModuleAwareTrait; +use yii\db\ActiveQuery; +use Yii; + +class SessionHistoryQuery extends ActiveQuery +{ + use ModuleAwareTrait; + + public function whereUserId($userId) + { + return $this->andWhere($this->getCondition()->byUser($userId)); + } + + public function whereActive() + { + return $this->andWhere(['IS NOT', 'session_id', null]); + } + + public function whereInActive($userId) + { + return $this->andWhere($this->getCondition()->inactive($userId)); + } + + + public function whereExpired($userId) + { + return $this->andWhere($this->getCondition()->expired($userId)); + } + + public function whereExpiredInActive($userId) + { + return $this->andWhere($this->getCondition()->expiredInactive($userId)); + } + + public function selectSessionId() + { + return $this->select(['session_id']); + } + + public function whereUserSession($userId, $sessionId) + { + return $this->andWhere($this->getCondition()->byUserSession( + $userId, + $sessionId + )); + } + + public function whereCurrentUser() + { + return $this->andWhere($this->getCondition()->currentUserCondition()); + } + + public function oldestUpdatedTimeActiveSession($userId) + { + return $this->whereExpiredInActive($userId) + ->select(['updated_at']) + ->limit(1) + ->offset($this->getModule()->numberSessionHistory) + ->orderBy(['updated_at' => SORT_DESC])->scalar(); + } + + /** + * @return SessionHistoryCondition + */ + protected function getCondition() + { + return Yii::$container->get(SessionHistoryCondition::class); + } +} diff --git a/src/User/Search/SessionHistorySearch.php b/src/User/Search/SessionHistorySearch.php new file mode 100755 index 0000000..2775652 --- /dev/null +++ b/src/User/Search/SessionHistorySearch.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Search; + +use Da\User\Model\SessionHistory; +use Da\User\Traits\ContainerAwareTrait; +use yii\base\InvalidConfigException; +use yii\base\InvalidParamException; +use yii\data\ActiveDataProvider; + + +class SessionHistorySearch extends SessionHistory +{ + use ContainerAwareTrait; + + /** + * {@inheritdoc} + */ + public function rules() + { + return [ + [['user_agent', 'ip'], 'safe'], + ]; + } + + /** + * @param array $params + * + * @throws InvalidConfigException + * @throws InvalidParamException + * + * @return ActiveDataProvider + */ + public function search($params) + { + $query = SessionHistory::find()->andWhere([ + 'user_id' => $this->user_id, + ]); + + /** @var ActiveDataProvider $dataProvider */ + $dataProvider = $this->make( + ActiveDataProvider::class, + [], + [ + 'query' => $query, + 'sort' => [ + 'defaultOrder' => [ + 'updated_at' => SORT_DESC + ], + ] + ] + ); + + $this->load($params); + + if (!$this->validate()) { + return $dataProvider; + } + + $query->andFilterWhere(['like', 'user_agent', $this->user_agent]) + ->andFilterWhere(['like', 'ip', $this->ip]); + + return $dataProvider; + } +} diff --git a/src/User/Service/SessionHistory/DBTerminateSessionsService.php b/src/User/Service/SessionHistory/DBTerminateSessionsService.php new file mode 100755 index 0000000..ad0021a --- /dev/null +++ b/src/User/Service/SessionHistory/DBTerminateSessionsService.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Service\SessionHistory; + + +use yii\web\DbSession; + +class DBTerminateSessionsService implements TerminateSessionsServiceInterface +{ + protected $sessionIds; + protected $dbSession; + protected $fieldName; + + public function __construct(array $sessionIds, DbSession $dbSession, $fieldName = 'id') + { + $this->sessionIds = $sessionIds; + $this->dbSession = $dbSession; + $this->fieldName = $fieldName; + } + + public function run() + { + if (in_array(session_id(), $this->sessionIds)) { + session_write_close(); + } + + $this->dbSession->db->createCommand()->delete( + $this->dbSession->sessionTable, + [$this->fieldName => $this->sessionIds] + )->execute(); + + return true; + } +} \ No newline at end of file diff --git a/src/User/Service/SessionHistory/SessionHistoryDecorator.php b/src/User/Service/SessionHistory/SessionHistoryDecorator.php new file mode 100755 index 0000000..d337825 --- /dev/null +++ b/src/User/Service/SessionHistory/SessionHistoryDecorator.php @@ -0,0 +1,461 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Service\SessionHistory; + +use Da\User\Model\SessionHistory; +use Da\User\Query\SessionHistoryCondition; +use Da\User\Query\SessionHistoryQuery; +use Da\User\Traits\ModuleAwareTrait; +use Yii; +use yii\db\Exception; +use yii\web\Session; +use yii\base\InvalidArgumentException as BaseInvalidArgumentException; + +/** + * Decorator for the {@see Session} class for storing the 'session history' + * + * Not decorated methods: + * {@see Session::open()} + * {@see Session::close()} + * {@see Session::destroy()} + * {@see Session::get()} + * {@see Session::set()} + */ +class SessionHistoryDecorator extends Session +{ + use ModuleAwareTrait; + + public $sessionHistoryTable = '{{%session_history}}'; + + /** + * @var Session + */ + public $session; + + public $condition; + + public function __construct( + Session $session, + SessionHistoryCondition $historyCondition, + $config = [] + ) { + $this->session = $session; + $this->condition = $historyCondition; + + parent::__construct($config); + } + + /** @inheritdoc */ + public function getUseCustomStorage() + { + return $this->session->getUseCustomStorage(); + } + + /** @inheritdoc */ + public function getIsActive() + { + return $this->session->getIsActive(); + } + + /** @inheritdoc */ + public function getHasSessionId() + { + return $this->session->getHasSessionId(); + } + + /** @inheritdoc */ + public function setHasSessionId($value) + { + return $this->session->setHasSessionId($value); + } + + /** @inheritdoc */ + public function getId() + { + return $this->session->getId(); + } + + /** @inheritdoc */ + public function setId($value) + { + return $this->session->setId($value); + } + + /** @inheritdoc */ + public function regenerateID($deleteOldSession = false) + { + return $this->getDb()->transaction(function () use ($deleteOldSession) { + $oldSid = session_id(); + if (false === $this->session->regenerateID($deleteOldSession)) { + return false; + } + + if (false === $this->getModule()->enableSessionHistory) { + return true; + } + + $user = Yii::$app->user; + if ($user->getIsGuest()) { + $this->unbindSessionHistory($oldSid); + } else { + $this->getDB()->createCommand() + ->delete( + $this->sessionHistoryTable, + $this->condition->byUserSession($user->getId(), $oldSid) + )->execute(); + } + + return true; + }); + } + + /** @inheritdoc */ + public function getName() + { + return $this->session->getName(); + } + + /** @inheritdoc */ + public function setName($value) + { + return $this->session->setName($value); + } + + /** @inheritdoc */ + public function getSavePath() + { + return $this->session->getSavePath(); + } + + /** @inheritdoc */ + public function setSavePath($value) + { + return $this->session->setSavePath($value); + } + + /** @inheritdoc */ + public function getCookieParams() + { + return $this->session->getCookieParams(); + } + + /** @inheritdoc */ + public function setCookieParams(array $value) + { + return $this->session->setCookieParams($value); + } + + /** @inheritdoc */ + public function getUseCookies() + { + return $this->session->getUseCookies(); + } + + /** @inheritdoc */ + public function setUseCookies($value) + { + return $this->session->setUseCookies($value); + } + + /** @inheritdoc */ + public function getGCProbability() + { + return $this->session->getGCProbability(); + } + + /** @inheritdoc */ + public function setGCProbability($value) + { + return $this->session->setGCProbability($value); + } + + /** @inheritdoc */ + public function getUseTransparentSessionID() + { + return $this->session->getUseTransparentSessionID(); + } + + /** @inheritdoc */ + public function setUseTransparentSessionID($value) + { + return $this->session->setUseTransparentSessionID($value); + } + + /** @inheritdoc */ + public function getTimeout() + { + return $this->session->getTimeout(); + } + + /** @inheritdoc */ + public function setTimeout($value) + { + return $this->session->setTimeout($value); + } + + /** @inheritdoc */ + public function openSession($savePath, $sessionName) + { + return $this->session->openSession($savePath, $sessionName); + } + + /** @inheritdoc */ + public function closeSession() + { + return $this->session->closeSession(); + } + + /** @inheritdoc */ + public function readSession($id) + { + return $this->session->readSession($id); + } + + /** @inheritdoc */ + public function writeSession($id, $data) + { + return $this->session->writeSession($id, $data) && + ( + false === $this->getModule()->enableSessionHistory || + $this->getDb()->transaction(function () use ($id, $data) { + if (Yii::$app->user->getIsGuest()) { + return true; + } + + $updatedAt = ['updated_at' => time()]; + + $model = $this->getHistoryQuery() + ->whereCurrentUser() + ->one(); + if (isset($model)) { + $model->updateAttributes($updatedAt); + $result = true; + } else { + $model = Yii::createObject([ + 'class' => SessionHistory::class, + ] + $this->condition->currentUserData() + $updatedAt); + if (!$result = $model->save()) { + throw new BaseInvalidArgumentException( + print_r($model->errors, 1) + ); + } + + $this->displacementHistory($model->user_id); + } + + return $result; + }) + ); + + } + + /** @inheritdoc */ + public function destroySession($id) + { + return $this->session->destroySession($id) && + ( + false === $this->getModule()->enableSessionHistory || + $this->getDb()->transaction(function () use ($id) { + $this->unbindSessionHistory($id); + + return true; + }) + ); + } + + /** @inheritdoc */ + public function gcSession($maxLifetime) + { + return $this->session->gcSession($maxLifetime) && + ( + false === $this->getModule()->enableSessionHistory || + $this->getDb()->transaction(function () use ($maxLifetime) { + $this->getDb()->createCommand()->update( + $this->sessionHistoryTable, + $this->condition->inactiveData(), + $this->condition->expired() + )->execute(); + return true; + }) + ); + } + + /** @inheritdoc */ + public function getIterator() + { + return $this->session->getIterator(); + } + + /** @inheritdoc */ + public function getCount() + { + return $this->session->getCount(); + } + + /** @inheritdoc */ + public function count() + { + return $this->session->count(); + } + + /** @inheritdoc */ + public function remove($key) + { + return $this->session->remove($key); + } + + /** @inheritdoc */ + public function removeAll() + { + return $this->session->removeAll(); + } + + /** @inheritdoc */ + public function has($key) + { + return $this->session->has($key); + } + + /** @inheritdoc */ + public function getFlash($key, $defaultValue = null, $delete = false) + { + return $this->session->getFlash($key, $defaultValue, $delete); + } + + /** @inheritdoc */ + public function getAllFlashes($delete = false) + { + return $this->session->getAllFlashes($delete); + } + + /** @inheritdoc */ + public function setFlash($key, $value = true, $removeAfterAccess = true) + { + return $this->session->setFlash($key, $value, $removeAfterAccess); + } + + /** @inheritdoc */ + public function addFlash($key, $value = true, $removeAfterAccess = true) + { + return $this->session->addFlash($key, $value, $removeAfterAccess); + } + + /** @inheritdoc */ + public function removeFlash($key) + { + return $this->session->removeFlash($key); + } + + /** @inheritdoc */ + public function removeAllFlashes() + { + return $this->session->removeAllFlashes(); + } + + /** @inheritdoc */ + public function hasFlash($key) + { + return $this->session->hasFlash($key); + } + + /** @inheritdoc */ + public function offsetExists($offset) + { + return $this->session->offsetExists($offset); + } + + /** @inheritdoc */ + public function offsetGet($offset) + { + return $this->session->offsetGet($offset); + } + + /** @inheritdoc */ + public function offsetSet($offset, $item) + { + return $this->session->offsetSet($offset, $item); + } + + /** @inheritdoc */ + public function offsetUnset($offset) + { + return $this->session->offsetUnset($offset); + } + + /** @inheritdoc */ + public function setCacheLimiter($cacheLimiter) + { + return $this->session->setCacheLimiter($cacheLimiter); + } + + /** @inheritdoc */ + public function getCacheLimiter() + { + return $this->session->getCacheLimiter(); + } + + /** + * @param string $id + * @return bool + * @throws Exception + */ + protected function unbindSessionHistory($id) + { + return (bool)$this->getDb()->createCommand()->update( + $this->sessionHistoryTable, + $this->condition->unbindSession(), + $this->condition->bySession($id) + )->execute(); + } + + /** + * + * @param int $userId + * @return bool + * @throws Exception + */ + protected function displacementHistory($userId) + { + $module = $this->getModule(); + + if (false === $module->hasNumberSessionHistory()) { + return true; + } + + $updatedAt = $this->getHistoryQuery() + ->oldestUpdatedTimeActiveSession($userId); + + if (!$updatedAt) { + return true; + } + + $this->getDB()->createCommand()->delete( + $this->sessionHistoryTable, + $this->condition->shouldDeleteBefore(intval($updatedAt), $userId) + )->execute(); + + return true; + } + + /** + * @return SessionHistoryQuery + */ + protected function getHistoryQuery() + { + return Yii::$container->get(SessionHistoryQuery::class); + } + + protected function getDb() + { + return Yii::$app->getDb(); + } +} \ No newline at end of file diff --git a/src/User/Service/SessionHistory/TerminateSessionsService.php b/src/User/Service/SessionHistory/TerminateSessionsService.php new file mode 100755 index 0000000..d15f13d --- /dev/null +++ b/src/User/Service/SessionHistory/TerminateSessionsService.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Service\SessionHistory; + + +class TerminateSessionsService implements TerminateSessionsServiceInterface +{ + protected $sessionIds; + + public function __construct(array $sessionIds) + { + $this->sessionIds = $sessionIds; + } + + public function run() + { + $currentSessionId = session_id(); + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + foreach ($this->sessionIds as $sessionId) { + if ($sessionId === $currentSessionId) { + $currentSessionId = null; + } + + session_id($sessionId); + session_start(); + session_destroy(); + } + + if ($currentSessionId) { + session_id($currentSessionId); + } + session_start(); + + return true; + } +} \ No newline at end of file diff --git a/src/User/Service/SessionHistory/TerminateSessionsServiceInterface.php b/src/User/Service/SessionHistory/TerminateSessionsServiceInterface.php new file mode 100755 index 0000000..f12b112 --- /dev/null +++ b/src/User/Service/SessionHistory/TerminateSessionsServiceInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Service\SessionHistory; + + +use Da\User\Contracts\ServiceInterface; + +interface TerminateSessionsServiceInterface extends ServiceInterface +{ +} \ No newline at end of file diff --git a/src/User/Service/SessionHistory/TerminateUserSessionsService.php b/src/User/Service/SessionHistory/TerminateUserSessionsService.php new file mode 100755 index 0000000..7a91a94 --- /dev/null +++ b/src/User/Service/SessionHistory/TerminateUserSessionsService.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Service\SessionHistory; + + +use Da\User\Contracts\ServiceInterface; +use Da\User\Event\SessionEvent; +use Da\User\Model\SessionHistory; +use Da\User\Model\User; +use Da\User\Traits\ContainerAwareTrait; +use Da\User\Traits\ModuleAwareTrait; +use yii\web\Session; +use Yii; + +class TerminateUserSessionsService implements ServiceInterface +{ + use ContainerAwareTrait; + use ModuleAwareTrait; + + protected $userId; + protected $session; + protected $excludeCurrentSession; + + public function __construct($userId, Session $session, $excludeCurrentSession = true) + { + $this->userId = intval($userId); + $this->session = $session; + $this->excludeCurrentSession = $excludeCurrentSession; + } + + public function run() + { + $user = $this->getUser($this->userId); + $sessionIds = $this->getSessionIds($user->id); + + Yii::$app->db->transaction(function () use ($sessionIds, $user) { + /** @var SessionEvent $event */ + $event = $this->make(SessionEvent::class, [$user]); + + $user->trigger(SessionEvent::EVENT_BEFORE_TERMINATE_USER_SESSIONS, $event); + + $this->make(TerminateSessionsServiceInterface::class, [$sessionIds])->run(); + + $user->updateAttributes([ + 'auth_key' => Yii::$app->security->generateRandomString(), + ]); + + if ($this->excludeCurrentUser()) { + Yii::$app->user->switchIdentity( + $user, + $this->getModule()->rememberLoginLifespan + ); + } + + $user->trigger(SessionEvent::EVENT_AFTER_TERMINATE_USER_SESSIONS, $event); + }); + + return true; + } + + /** + * @param int $userId + * @return User + */ + protected function getUser($userId) + { + return ($this->make(User::class))::findOne($userId); + } + + /** + * @param $userId + * @return int[] + */ + protected function getSessionIds($userId) + { + /** @var SessionHistory $sessionHistory */ + $sessionHistory = $this->make(SessionHistory::class); + $sessionIds = $sessionHistory::find()->whereUserId($userId)->whereActive()->selectSessionId()->column(); + + if ($this->excludeCurrentUser()) { + foreach ($sessionIds as $key => $sessionId) { + if ($sessionId === $this->session->id) { + unset($sessionIds[$key]); + break; + } + } + } + + return $sessionIds; + } + + protected function excludeCurrentUser() + { + return $this->excludeCurrentSession && $this->userId === Yii::$app->user->id; + } +} \ No newline at end of file diff --git a/src/User/Widget/SessionStatusWidget.php b/src/User/Widget/SessionStatusWidget.php new file mode 100755 index 0000000..15bcfae --- /dev/null +++ b/src/User/Widget/SessionStatusWidget.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Widget; + +use Da\User\Model\SessionHistory; +use Da\User\Traits\ContainerAwareTrait; +use Yii; +use yii\base\InvalidConfigException; +use yii\base\InvalidParamException; +use yii\base\Widget; +use yii\helpers\ArrayHelper; + +class SessionStatusWidget extends Widget +{ + use ContainerAwareTrait; + + /** + * @var SessionHistory + */ + public $model; + + /** + * {@inheritdoc} + * + * @throws InvalidConfigException + */ + public function init() + { + parent::init(); + if (!$this->model instanceof SessionHistory) { + throw new InvalidConfigException( + __CLASS__ . '::$userId should be instanceof ' . SessionHistory::class + ); + } + } + + /** + * {@inheritdoc} + * + * @throws InvalidParamException + */ + public function run() + { + if ($this->model->getIsActive()) { + if ($this->model->session_id === Yii::$app->session->id) { + $value = Yii::t('usuario', 'Current'); + } else { + $value = Yii::t('usuario', 'Active'); + } + } else { + $value = Yii::t('usuario', 'Inactive'); + } + + return $value; + } + + /** + * Returns available auth items to be attached to the user. + * + * @param int|null type of auth items or null to return all + * + * @return array + */ + protected function getAvailableItems($type = null) + { + return ArrayHelper::map( + $this->getAuthManager()->getItems($type), + 'name', + function ($item) { + return empty($item->description) + ? $item->name + : $item->name . ' (' . $item->description . ')'; + } + ); + } +} diff --git a/src/User/resources/i18n/ru/usuario.php b/src/User/resources/i18n/ru/usuario.php old mode 100644 new mode 100755 index 0d9850b..f8b486d --- a/src/User/resources/i18n/ru/usuario.php +++ b/src/User/resources/i18n/ru/usuario.php @@ -302,4 +302,12 @@ return [ 'Unable to disable two-factor authorization.' => '@@Не удалось отключить двухфакторную авторизацию.@@', 'We couldn\'t re-send the mail to confirm your address. ' => '@@Мы не можем повторно отправить письмо для подтверждения вашего адреса электронной почты.@@', 'We have sent confirmation links to both old and new email addresses. ' => '@@Мы отправили письма на ваш старый и новый почтовые ящики. Вы должны перейти по обеим, чтобы завершить процесс смены адреса.@@', + 'User agent' => '', + 'Status' => 'Статус', + 'Last activity' => 'Последняя активность', + 'Session history' => 'История сессий', + 'Active' => 'Активно', + 'Inactive' => 'Не автивно', + 'Current' => 'Текущий', + 'Terminate all sessions' => 'Прекратить другие сеансы', ]; diff --git a/src/User/resources/views/admin/_session-history.php b/src/User/resources/views/admin/_session-history.php new file mode 100755 index 0000000..8593e42 --- /dev/null +++ b/src/User/resources/views/admin/_session-history.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use Da\User\Widget\SessionStatusWidget; +use yii\helpers\Html; +use yii\grid\GridView; +use yii\widgets\Pjax; +use Da\User\Model\SessionHistory; +use Da\User\Search\SessionHistorySearch; +use yii\web\View; +use yii\data\ActiveDataProvider; + +/** + * @var $this View + * @var $searchModel SessionHistorySearch + * @var $dataProvider ActiveDataProvider + */ +?> + +beginContent('@Da/User/resources/views/admin/update.php', ['user' => $user]) ?> +
+
+ $user->id], + [ + 'class' => 'btn btn-danger btn-xs pull-right', + 'data-method' => 'post' + ] + ) ?> +
+
+
+ + + + $dataProvider, + 'filterModel' => $searchModel, + 'columns' => [ + 'user_agent', + 'ip', + [ + 'contentOptions' => [ + 'class' => 'text-nowrap', + ], + 'label' => Yii::t('usuario', 'Status'), + 'value' => function (SessionHistory $model) { + return SessionStatusWidget::widget(['model' => $model]); + }, + ], + [ + 'attribute' => 'updated_at', + 'format' => 'datetime' + ], + ], +]); ?> + + +endContent() ?> \ No newline at end of file diff --git a/src/User/resources/views/admin/update.php b/src/User/resources/views/admin/update.php old mode 100644 new mode 100755 index c598588..f884ce7 --- a/src/User/resources/views/admin/update.php +++ b/src/User/resources/views/admin/update.php @@ -67,6 +67,10 @@ $this->params['breadcrumbs'][] = $this->title; 'label' => Yii::t('usuario', 'Assignments'), 'url' => ['/user/admin/assignments', 'id' => $user->id], ], + [ + 'label' => Yii::t('usuario', 'Session history'), + 'url' => ['/user/admin/session-history', 'id' => $user->id], + ], '
', [ 'label' => Yii::t('usuario', 'Confirm'), diff --git a/src/User/resources/views/settings/_menu.php b/src/User/resources/views/settings/_menu.php old mode 100644 new mode 100755 index 9c58638..69c2270 --- a/src/User/resources/views/settings/_menu.php +++ b/src/User/resources/views/settings/_menu.php @@ -41,6 +41,10 @@ $networksVisible = count(Yii::$app->authClientCollection->clients) > 0; 'items' => [ ['label' => Yii::t('usuario', 'Profile'), 'url' => ['/user/settings/profile']], ['label' => Yii::t('usuario', 'Account'), 'url' => ['/user/settings/account']], + [ + 'label' => Yii::t('usuario', 'Session history'), + 'url' => ['/user/settings/session-history'] + ], ['label' => Yii::t('usuario', 'Privacy'), 'url' => ['/user/settings/privacy'], 'visible' => $module->enableGdprCompliance diff --git a/src/User/resources/views/settings/session-history.php b/src/User/resources/views/settings/session-history.php new file mode 100755 index 0000000..0e00a77 --- /dev/null +++ b/src/User/resources/views/settings/session-history.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use yii\helpers\Html; +use yii\grid\GridView; +use yii\widgets\Pjax; +use Da\User\Model\SessionHistory; +use Da\User\Search\SessionHistorySearch; +use yii\web\View; +use yii\data\ActiveDataProvider; +use Da\User\Widget\SessionStatusWidget; + +/** + * @var $this View + * @var $searchModel SessionHistorySearch + * @var $dataProvider ActiveDataProvider + */ + +$this->title = Yii::t('usuario', 'Session history'); +$this->params['breadcrumbs'][] = $this->title; +?> + +render('/shared/_alert', ['module' => Yii::$app->getModule('user')]) ?> + +
+
+ render('/settings/_menu') ?> +
+
+
+
+ title) ?> + 'btn btn-danger btn-xs pull-right', + 'data-method' => 'post' + ] + ) ?> +
+
+ + + + $dataProvider, + 'filterModel' => $searchModel, + 'columns' => [ + 'user_agent', + 'ip', + [ + 'contentOptions' => [ + 'class' => 'text-nowrap', + ], + 'label' => Yii::t('usuario', 'Status'), + 'value' => function (SessionHistory $model) { + return SessionStatusWidget::widget(['model' => $model]); + }, + ], + [ + 'attribute' => 'updated_at', + 'format' => 'datetime' + ], + ], + ]); ?> + +
+
+
+
\ No newline at end of file