diff --git a/.gitignore b/.gitignore index 50f7380..4affda6 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ codeception.yml # composer composer.lock +# visual studio code +.vscode + diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d28b8..db9895b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ There's a change in flash messages handling, please see #391 - Enh #472: implement module viewPath in all views instead of static file reference (tonisormisson) - Fix: Clear 2FA auth key when feature is disabled by user - Fix: check user before accessing 2FA code +- Enh: added `AdminController` REST controller (MatteoF96) ## 1.5.1 April 5, 2020 diff --git a/docs/install/configuration-options.md b/docs/install/configuration-options.md index ad40051..743e3af 100755 --- a/docs/install/configuration-options.md +++ b/docs/install/configuration-options.md @@ -317,4 +317,37 @@ Possible array keys: - special: minimum number of special characters; - min: minimum number of characters (= minimum length). +#### authenticatorClass (type: `string`, default: `yii\filters\auth\QueryParamAuth`) + +Which class to use as authenticator for REST API. +Possible values ([official documentation](https://www.yiiframework.com/doc/guide/2.0/en/rest-authentication)): +- `HttpBasicAuth` +- `HttpBearerAuth` +- `QueryParamAuth`. + +Default value = `yii\filters\auth\QueryParamAuth` class, therefore access tokens are sent as query parameter; for instance: `https://example.com/users?access-token=xxxxxxxx`. + +#### adminRestPrefix (type: `string`, default: `user/api/v1`) + +Route prefix for REST admin controller. + +#### adminRestRoutes (type `array`) + +Routes for REST admin controller. + +Default value: +```php +[ + 'GET,HEAD users' => 'admin/index', + 'POST users' => 'admin/create', + 'PUT,PATCH users/' => 'admin/update', + 'GET,HEAD users/' => 'admin/view', + 'DELETE users/' => 'admin/delete', + 'users//' => 'admin/', + 'users/' => 'admin/options', + 'users' => 'admin/options', +]; +``` + + © [2amigos](http://www.2amigos.us/) 2013-2019 diff --git a/src/User/Bootstrap.php b/src/User/Bootstrap.php index baf4fa2..ddace61 100755 --- a/src/User/Bootstrap.php +++ b/src/User/Bootstrap.php @@ -53,6 +53,7 @@ class Bootstrap implements BootstrapInterface if ($app instanceof WebApplication) { $this->initControllerNamespace($app); $this->initUrlRoutes($app); + $this->initUrlRestRoutes($app); $this->initAuthCollection($app); $this->initAuthManager($app); } else { @@ -277,6 +278,25 @@ class Bootstrap implements BootstrapInterface $app->getUrlManager()->addRules([$rule], false); } + /** + * Initializes web url for rest routes. + * @param WebApplication $app + * @throws InvalidConfigException + */ + protected function initUrlRestRoutes(WebApplication $app) + { + /** @var Module $module */ + $module = $app->getModule('user'); + $rules = $module->adminRestRoutes; + $config = [ + 'class' => 'yii\web\GroupUrlRule', + 'prefix' => $module->adminRestPrefix, + 'rules' => $rules, + ]; + $rule = Yii::createObject($config); + $app->getUrlManager()->addRules([$rule], false); + } + /** * Ensures required mail parameters needed for the mail service. * diff --git a/src/User/Controller/api/v1/AdminController.php b/src/User/Controller/api/v1/AdminController.php new file mode 100644 index 0000000..4b1fc24 --- /dev/null +++ b/src/User/Controller/api/v1/AdminController.php @@ -0,0 +1,450 @@ +userQuery = $userQuery; + parent::__construct($id, $module, $config); + } + + /** + * {@inheritdoc} + */ + public function init() + { + parent::init(); + // Set user properties for REST APIs + \Yii::$app->user->enableSession = false; + \Yii::$app->user->loginUrl = null; + } + + /** + * {@inheritdoc} + */ + public function actions() + { + // Get and then remove some default actions + $actions = parent::actions(); + unset($actions['create']); + unset($actions['update']); + unset($actions['delete']); + return $actions; + } + + /** + * {@inheritdoc} + */ + protected function verbs() + { + // Get parent verbs + $verbs = parent::verbs(); + + // Add new verbs and return + $verbs['update-profile'] = ['PUT', 'PATCH']; + $verbs['assignments'] = ['GET']; + $verbs['confirm'] = ['PUT', 'PATCH']; + $verbs['block'] = ['PUT', 'PATCH']; + $verbs['password-reset'] = ['PUT', 'PATCH']; + $verbs['force-password-change'] = ['PUT', 'PATCH']; + return $verbs; + } + + /** + * {@inheritdoc} + */ + public function behaviors() + { + $behaviors = parent::behaviors(); + // Remove the (default) authentication filter + unset($behaviors['authenticator']); + + // Cors filter + $behaviors['corsFilter'] = [ + 'class' => Cors::class, + ]; + + // Re-add authentication filter + $behaviors['authenticator'] = [ + 'class' => $this->module->authenticatorClass, // Class depends on the module parameter + 'except' => ['options'] + ]; + // Return + return $behaviors; + } + + /** + * {@inheritdoc} + */ + public function checkAccess($action, $model = null, $params = []) + { + // Access for admins only + if (!Yii::$app->user->can('admin')) { + throw new ForbiddenHttpException(Yii::t('usuario', 'User does not have sufficient permissions.')); + } + } + + /** + * Create a user. + */ + public function actionCreate() + { + // Check access + $this->checkAccess($this->action); + + // Create new user model + /** @var User $user */ + $user = $this->make(User::class, [], ['scenario' => $this->createScenario]); + + // Create event object + /** @var UserEvent $event */ + $event = $this->make(UserEvent::class, [$user]); + + // Save user model + response + $user->load(Yii::$app->getRequest()->getBodyParams(), ''); + if ($user->validate()) { + $this->trigger(UserEvent::EVENT_BEFORE_CREATE, $event); + $mailService = MailFactory::makeWelcomeMailerService($user); // Welcome email + if ($this->make(UserCreateService::class, [$user, $mailService])->run()) { + $this->trigger(UserEvent::EVENT_AFTER_CREATE, $event); + Yii::$app->getResponse()->setStatusCode(201); // 201 = Created + return $user; + } + } + if (!$user->hasErrors()) { + $this->throwServerError(); + } + return $user; + } + + /** + * Update a user. + * @param int $id ID of the user. + */ + public function actionUpdate($id) + { + // Check access + $this->checkAccess($this->action); + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + $user->setScenario($this->updateScenario); + + // Create event object + /** @var UserEvent $event */ + $event = $this->make(UserEvent::class, [$user]); + + // Save user model + response + $user->load(Yii::$app->getRequest()->getBodyParams(), ''); + if ($user->validate()) { + $this->trigger(UserEvent::EVENT_BEFORE_ACCOUNT_UPDATE, $event); + if ($user->save()) { + $this->trigger(UserEvent::EVENT_AFTER_ACCOUNT_UPDATE, $event); + return $user; + } + } + if (!$user->hasErrors()) { + $this->throwServerError(); + } + return $user; + } + + /** + * Delete a user. + * @param int $id ID of the user. + */ + public function actionDelete($id) + { + // Check access + $this->checkAccess($this->action); + + // Check ID parameter (whether own account) + if ((int)$id === Yii::$app->user->getId()) { + throw new BadRequestHttpException(Yii::t('usuario', 'You cannot remove your own account.')); + } + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + + // Create event object + /** @var UserEvent $event */ + $event = $this->make(UserEvent::class, [$user]); + + // Detele user model + response + $this->trigger(ActiveRecord::EVENT_BEFORE_DELETE, $event); + if ($user->delete()) { + $this->trigger(ActiveRecord::EVENT_AFTER_DELETE, $event); + Yii::$app->getResponse()->setStatusCode(204); // 204 = No Content + } + else { + $this->throwServerError(); + } + } + + /** + * Update the user profile. + * @param int $id ID of the user. + */ + public function actionUpdateProfile($id) + { + // Check access + $this->checkAccess($this->action); + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + + // Get profile model + /** @var Profile $profile */ + $profile = $user->profile; + if ($profile === null) { + $profile = $this->make(Profile::class); + $profile->link('user', $user); + } + + // Create event object + /** @var UserEvent $event */ + $event = $this->make(UserEvent::class, [$user]); + + // Save profile model + response + $profile->load(Yii::$app->getRequest()->getBodyParams(), ''); + $this->trigger(UserEvent::EVENT_BEFORE_PROFILE_UPDATE, $event); + if ($profile->save() === false && !$profile->hasErrors()) { + $this->throwServerError(); + } + $this->trigger(UserEvent::EVENT_AFTER_PROFILE_UPDATE, $event); + return $profile; + } + + /** + * Get assignments of the specified user. + * @param int $id ID of the user. + */ + public function actionAssignments($id) + { + // Check access + $this->checkAccess($this->action); + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + + // Get assignments + response + $assignments = $this->make(Assignment::class, [], ['user_id' => $user->id]); + return $assignments; + } + + /** + * Confirm the user. + * @param int $id ID of the user. + */ + public function actionConfirm($id) + { + // Check access + $this->checkAccess($this->action); + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + + // Create event object + /** @var UserEvent $event */ + $event = $this->make(UserEvent::class, [$user]); + + // Confirm user + response + $this->trigger(UserEvent::EVENT_BEFORE_CONFIRMATION, $event); + if ($this->make(UserConfirmationService::class, [$user])->run() || $user->hasErrors()) { + $this->trigger(UserEvent::EVENT_AFTER_CONFIRMATION, $event); + return $user; + } + else { + $this->throwServerError(); + } + } + + /** + * Block and unblock the user. + * @param int $id ID of the user. + */ + public function actionBlock($id) + { + // Check access + $this->checkAccess($this->action); + + // Check ID parameter (whether own account) + if ((int)$id === Yii::$app->user->getId()) { + throw new BadRequestHttpException(Yii::t('usuario', 'You cannot block your own account.')); + } + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + + // Create event object + /** @var UserEvent $event */ + $event = $this->make(UserEvent::class, [$user]); + + // Block user + response + if ($this->make(UserBlockService::class, [$user, $event, $this])->run() || $user->hasErrors()) { + return $user; + } + else { + $this->throwServerError(); + } + } + + /** + * Reset password. + * @param int $id ID of the user. + */ + public function actionPasswordReset($id) + { + // Check access + $this->checkAccess($this->action); + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + + // Confirm user + response + $mailService = MailFactory::makeRecoveryMailerService($user->email); + if ($this->make(PasswordRecoveryService::class, [$user->email, $mailService])->run()) { + return $user; + } + else { + $this->throwServerError(); + } + } + + /** + * Forces the user to change password at next login. + * @param int $id ID of the user. + */ + public function actionForcePasswordChange($id) + { + // Check access + $this->checkAccess($this->action); + + // Get user model + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if (empty($user)) { // Check user, so `$id` parameter + $this->throwUser404(); + } + + // Confirm user + response + if ($this->make(PasswordExpireService::class, [$user])->run()) { + return $user; + } + else { + $this->throwServerError(); + } + } + + /** + * Handle server error (with default Yii2 response). + * @return void + * @throws ServerErrorHttpException + */ + protected function throwServerError() + { + throw new ServerErrorHttpException('Failed to create the object for unknown reason.'); // Yii2 standard response for server errors + } + + /** + * Handle 404 error for user (usually if the entered ID is not valid). + * @return void + * @throws NotFoundHttpException + */ + protected function throwUser404() + { + throw new NotFoundHttpException(Yii::t('usuario', 'User not found.')); + } + + + +} \ No newline at end of file diff --git a/src/User/Model/User.php b/src/User/Model/User.php index b4623c0..8bf0842 100644 --- a/src/User/Model/User.php +++ b/src/User/Model/User.php @@ -91,6 +91,16 @@ class User extends ActiveRecord implements IdentityInterface return '{{%user}}'; } + /** + * {@inheritdoc} + */ + public function fields() + { + $fields = parent::fields(); + unset($fields['auth_key'], $fields['password_hash']); + return $fields; + } + /** * {@inheritdoc} */ diff --git a/src/User/Module.php b/src/User/Module.php index 60e3398..b261bb1 100755 --- a/src/User/Module.php +++ b/src/User/Module.php @@ -233,7 +233,6 @@ class Module extends BaseModule * @var boolean whether to disable IP logging into user table */ public $disableIpLogging = false; - /** * @var array Minimum requirements when a new password is automatically generated. * Array structure: `requirement => minimum number characters`. @@ -250,6 +249,29 @@ class Module extends BaseModule 'digit' => 1, 'upper' => 1, ]; + /** + * @var string Which class to use as authenticator for REST API. + * Possible values: `HttpBasicAuth`, `HttpBearerAuth` or `QueryParamAuth`. + * Default value = `yii\filters\auth\QueryParamAuth` class, therefore access tokens are sent as query parameter; for instance: `https://example.com/users?access-token=xxxxxxxx`. + */ + public $authenticatorClass = 'yii\filters\auth\QueryParamAuth'; + /** + * @var string Route prefix for REST admin controller. + */ + public $adminRestPrefix = 'user/api/v1'; + /** + * @var array Routes for REST admin controller. + */ + public $adminRestRoutes = [ + 'GET,HEAD users' => 'admin/index', + 'POST users' => 'admin/create', + 'PUT,PATCH users/' => 'admin/update', + 'GET,HEAD users/' => 'admin/view', + 'DELETE users/' => 'admin/delete', + 'users//' => 'admin/', + 'users/' => 'admin/options', + 'users' => 'admin/options', + ]; /** * @return string with the hit to be used with the give consent checkbox diff --git a/src/User/Service/UserBlockService.php b/src/User/Service/UserBlockService.php index 658031b..468e84d 100644 --- a/src/User/Service/UserBlockService.php +++ b/src/User/Service/UserBlockService.php @@ -13,9 +13,11 @@ namespace Da\User\Service; use Da\User\Contracts\ServiceInterface; use Da\User\Controller\AdminController; +use Da\User\Controller\api\v1\AdminController as AdminControllerRest; use Da\User\Event\UserEvent; use Da\User\Helper\SecurityHelper; use Da\User\Model\User; +use TypeError; class UserBlockService implements ServiceInterface { @@ -27,9 +29,13 @@ class UserBlockService implements ServiceInterface public function __construct( User $model, UserEvent $event, - AdminController $controller, + $controller, SecurityHelper $securityHelper ) { + if (!in_array(get_class($controller), [AdminController::class, AdminControllerRest::class])) { + throw new TypeError('Argument controller must be either of type ' + . AdminController::class . ' or ' . AdminControllerRest::class . ', ' . get_class($controller) . ' given'); + } $this->model = $model; $this->event = $event; $this->controller = $controller;