mirror of
https://github.com/elyby/accounts.git
synced 2025-03-12 03:09:10 +05:30
Extract login logics into a separate component. Not quite clean result but enough for upcoming tasks
This commit is contained in:
parent
1c2969a4be
commit
be4697e6eb
@ -9,7 +9,6 @@ use Carbon\Carbon;
|
||||
use common\models\Account;
|
||||
use common\models\AccountSession;
|
||||
use DateTime;
|
||||
use Lcobucci\JWT\Token;
|
||||
use Lcobucci\JWT\UnencryptedToken;
|
||||
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
|
||||
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
||||
|
@ -1,18 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\controllers;
|
||||
|
||||
use api\models\authentication\ForgotPasswordForm;
|
||||
use api\models\authentication\LoginForm;
|
||||
use api\models\authentication\LogoutForm;
|
||||
use api\models\authentication\RecoverPasswordForm;
|
||||
use api\models\authentication\RefreshTokenForm;
|
||||
use common\components\Authentication\Entities\Credentials;
|
||||
use common\components\Authentication\Exceptions;
|
||||
use common\components\Authentication\Exceptions\AuthenticationException;
|
||||
use common\components\Authentication\LoginServiceInterface;
|
||||
use common\helpers\Error as E;
|
||||
use common\helpers\StringHelper;
|
||||
use DateTimeImmutable;
|
||||
use Yii;
|
||||
use yii\base\Module;
|
||||
use yii\filters\AccessControl;
|
||||
use yii\helpers\ArrayHelper;
|
||||
use yii\web\Request;
|
||||
|
||||
class AuthenticationController extends Controller {
|
||||
final class AuthenticationController extends Controller {
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
Module $module,
|
||||
private readonly LoginServiceInterface $loginService,
|
||||
array $config = [],
|
||||
) {
|
||||
parent::__construct($id, $module, $config);
|
||||
}
|
||||
|
||||
public function behaviors(): array {
|
||||
return ArrayHelper::merge(parent::behaviors(), [
|
||||
@ -38,7 +55,7 @@ class AuthenticationController extends Controller {
|
||||
]);
|
||||
}
|
||||
|
||||
public function verbs() {
|
||||
public function verbs(): array {
|
||||
return [
|
||||
'login' => ['POST'],
|
||||
'logout' => ['POST'],
|
||||
@ -48,30 +65,63 @@ class AuthenticationController extends Controller {
|
||||
];
|
||||
}
|
||||
|
||||
public function actionLogin(): array {
|
||||
$model = new LoginForm();
|
||||
$model->load(Yii::$app->request->post());
|
||||
if (($result = $model->login()) === null) {
|
||||
public function actionLogin(Request $request): array {
|
||||
$form = new LoginForm();
|
||||
$form->load($request->post());
|
||||
if (!$form->validate()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'errors' => $form->getFirstErrors(),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$loginResult = $this->loginService->loginByCredentials(new Credentials(
|
||||
login: (string)$form->login,
|
||||
password: (string)$form->password,
|
||||
totp: (string)$form->totp,
|
||||
rememberMe: (bool)$form->rememberMe,
|
||||
));
|
||||
} catch (AuthenticationException $e) {
|
||||
$data = [
|
||||
'success' => false,
|
||||
'errors' => $model->getFirstErrors(),
|
||||
'errors' => match ($e::class) {
|
||||
Exceptions\UnknownLoginException::class => ['login' => E::LOGIN_NOT_EXIST],
|
||||
Exceptions\InvalidPasswordException::class => ['password' => E::PASSWORD_INCORRECT],
|
||||
Exceptions\TotpRequiredException::class => ['totp' => E::TOTP_REQUIRED],
|
||||
Exceptions\InvalidTotpException::class => ['totp' => E::TOTP_INCORRECT],
|
||||
Exceptions\AccountBannedException::class => ['login' => E::ACCOUNT_BANNED],
|
||||
Exceptions\AccountNotActivatedException::class => ['login' => E::ACCOUNT_NOT_ACTIVATED],
|
||||
default => $e->getMessage(),
|
||||
},
|
||||
];
|
||||
|
||||
if (ArrayHelper::getValue($data['errors'], 'login') === E::ACCOUNT_NOT_ACTIVATED) {
|
||||
$data['data']['email'] = $model->getAccount()->email;
|
||||
if ($e instanceof Exceptions\AccountNotActivatedException) {
|
||||
$data['data']['email'] = $e->account->email;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
$token = Yii::$app->tokensFactory->createForWebAccount($loginResult->account, $loginResult->session);
|
||||
$data = [
|
||||
'success' => true,
|
||||
], $result->formatAsOAuth2Response());
|
||||
'access_token' => $token->toString(),
|
||||
'expires_in' => $token->claims()->get('exp')->getTimestamp() - (new DateTimeImmutable())->getTimestamp(),
|
||||
];
|
||||
|
||||
if ($loginResult->session) {
|
||||
$data['refresh_token'] = $loginResult->session->refresh_token;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function actionLogout(): array {
|
||||
$form = new LogoutForm();
|
||||
$form->logout();
|
||||
$session = Yii::$app->user->getActiveSession();
|
||||
if ($session) {
|
||||
$this->loginService->logout($session);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
|
@ -6,7 +6,6 @@ namespace api\eventListeners;
|
||||
use api\controllers\AuthenticationController;
|
||||
use api\controllers\SignupController;
|
||||
use api\modules\accounts\actions;
|
||||
use Closure;
|
||||
use Yii;
|
||||
use yii\base\ActionEvent;
|
||||
use yii\base\BootstrapInterface;
|
||||
@ -16,8 +15,8 @@ use yii\base\Event;
|
||||
final class LogMetricsToStatsd implements BootstrapInterface {
|
||||
|
||||
public function bootstrap($app): void {
|
||||
Event::on(Controller::class, Controller::EVENT_BEFORE_ACTION, Closure::fromCallable([$this, 'beforeAction']));
|
||||
Event::on(Controller::class, Controller::EVENT_AFTER_ACTION, Closure::fromCallable([$this, 'afterAction']));
|
||||
Event::on(Controller::class, Controller::EVENT_BEFORE_ACTION, $this->beforeAction(...));
|
||||
Event::on(Controller::class, Controller::EVENT_AFTER_ACTION, $this->afterAction(...));
|
||||
}
|
||||
|
||||
private function beforeAction(ActionEvent $event): void {
|
||||
|
@ -6,6 +6,7 @@ namespace api\models\authentication;
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\UnencryptedToken;
|
||||
|
||||
// TODO: remove this class
|
||||
final readonly class AuthenticationResult {
|
||||
|
||||
public function __construct(
|
||||
|
@ -4,14 +4,9 @@ declare(strict_types=1);
|
||||
namespace api\models\authentication;
|
||||
|
||||
use api\models\base\ApiForm;
|
||||
use api\validators\TotpValidator;
|
||||
use common\helpers\Error as E;
|
||||
use common\models\Account;
|
||||
use common\models\AccountSession;
|
||||
use Webmozart\Assert\Assert;
|
||||
use Yii;
|
||||
|
||||
class LoginForm extends ApiForm {
|
||||
final class LoginForm extends ApiForm {
|
||||
|
||||
public mixed $login = null;
|
||||
|
||||
@ -24,97 +19,10 @@ class LoginForm extends ApiForm {
|
||||
public function rules(): array {
|
||||
return [
|
||||
['login', 'required', 'message' => E::LOGIN_REQUIRED],
|
||||
['login', 'validateLogin'],
|
||||
|
||||
['password', 'required', 'when' => fn(self $model): bool => !$model->hasErrors(), 'message' => E::PASSWORD_REQUIRED],
|
||||
['password', 'validatePassword'],
|
||||
|
||||
['totp', 'required', 'when' => fn(self $model): bool => !$model->hasErrors() && $model->getAccount()->is_otp_enabled, 'message' => E::TOTP_REQUIRED],
|
||||
['totp', 'validateTotp'],
|
||||
|
||||
['login', 'validateActivity'],
|
||||
|
||||
['password', 'required', 'isEmpty' => fn($value) => $value === null, 'message' => E::PASSWORD_REQUIRED],
|
||||
['totp', 'string'],
|
||||
['rememberMe', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function validateLogin(string $attribute): void {
|
||||
if (!$this->hasErrors() && $this->getAccount() === null) {
|
||||
$this->addError($attribute, E::LOGIN_NOT_EXIST);
|
||||
}
|
||||
}
|
||||
|
||||
public function validatePassword(string $attribute): void {
|
||||
if (!$this->hasErrors()) {
|
||||
$account = $this->getAccount();
|
||||
if ($account === null || !$account->validatePassword($this->password)) {
|
||||
$this->addError($attribute, E::PASSWORD_INCORRECT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function validateTotp(string $attribute): void {
|
||||
if ($this->hasErrors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Account $account */
|
||||
$account = $this->getAccount();
|
||||
if (!$account->is_otp_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$validator = new TotpValidator(['account' => $account]);
|
||||
$validator->validateAttribute($this, $attribute);
|
||||
}
|
||||
|
||||
public function validateActivity(string $attribute): void {
|
||||
if (!$this->hasErrors()) {
|
||||
/** @var Account $account */
|
||||
$account = $this->getAccount();
|
||||
if ($account->status === Account::STATUS_BANNED) {
|
||||
$this->addError($attribute, E::ACCOUNT_BANNED);
|
||||
}
|
||||
|
||||
if ($account->status === Account::STATUS_REGISTERED) {
|
||||
$this->addError($attribute, E::ACCOUNT_NOT_ACTIVATED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @noinspection PhpIncompatibleReturnTypeInspection */
|
||||
public function getAccount(): ?Account {
|
||||
return Account::find()->andWhereLogin($this->login)->one();
|
||||
}
|
||||
|
||||
public function login(): ?AuthenticationResult {
|
||||
if (!$this->validate()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$transaction = Yii::$app->db->beginTransaction();
|
||||
|
||||
/** @var Account $account */
|
||||
$account = $this->getAccount();
|
||||
if ($account->password_hash_strategy !== Account::PASS_HASH_STRATEGY_YII2) {
|
||||
$account->setPassword($this->password);
|
||||
Assert::true($account->save(), 'Unable to upgrade user\'s password');
|
||||
}
|
||||
|
||||
$session = null;
|
||||
if ($this->rememberMe) {
|
||||
$session = new AccountSession();
|
||||
$session->account_id = $account->id;
|
||||
$session->setIp(Yii::$app->request->userIP);
|
||||
$session->generateRefreshToken();
|
||||
Assert::true($session->save(), 'Cannot save account session model');
|
||||
}
|
||||
|
||||
$token = Yii::$app->tokensFactory->createForWebAccount($account, $session);
|
||||
|
||||
$transaction->commit();
|
||||
|
||||
return new AuthenticationResult($token, $session?->refresh_token);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\models\authentication;
|
||||
|
||||
use api\models\base\ApiForm;
|
||||
use Yii;
|
||||
|
||||
class LogoutForm extends ApiForm {
|
||||
|
||||
public function logout(): bool {
|
||||
$component = Yii::$app->user;
|
||||
$session = $component->getActiveSession();
|
||||
if ($session === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$session->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
@ -5,7 +5,7 @@ namespace api\models\base;
|
||||
|
||||
use yii\base\Model;
|
||||
|
||||
class ApiForm extends Model {
|
||||
abstract class ApiForm extends Model {
|
||||
|
||||
public function formName(): string {
|
||||
return '';
|
||||
|
@ -1,4 +1,6 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\accounts\models;
|
||||
|
||||
use api\validators\PasswordRequiredValidator;
|
||||
@ -8,13 +10,13 @@ use Webmozart\Assert\Assert;
|
||||
|
||||
class DisableTwoFactorAuthForm extends AccountActionForm {
|
||||
|
||||
public $totp;
|
||||
public mixed $totp = null;
|
||||
|
||||
public $password;
|
||||
public mixed $password = null;
|
||||
|
||||
public function rules(): array {
|
||||
return [
|
||||
['account', 'validateOtpEnabled'],
|
||||
['account', $this->validateOtpEnabled(...)],
|
||||
['totp', 'required', 'message' => E::TOTP_REQUIRED],
|
||||
['totp', TotpValidator::class, 'account' => $this->getAccount()],
|
||||
['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()],
|
||||
@ -34,7 +36,7 @@ class DisableTwoFactorAuthForm extends AccountActionForm {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function validateOtpEnabled($attribute): void {
|
||||
private function validateOtpEnabled(string $attribute): void {
|
||||
if (!$this->getAccount()->is_otp_enabled) {
|
||||
$this->addError($attribute, E::OTP_NOT_ENABLED);
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\accounts\models;
|
||||
|
||||
use api\components\User\Component;
|
||||
@ -10,13 +12,13 @@ use Yii;
|
||||
|
||||
class EnableTwoFactorAuthForm extends AccountActionForm {
|
||||
|
||||
public $totp;
|
||||
public mixed $totp = null;
|
||||
|
||||
public $password;
|
||||
public mixed $password = null;
|
||||
|
||||
public function rules(): array {
|
||||
return [
|
||||
['account', 'validateOtpDisabled'],
|
||||
['account', $this->validateOtpDisabled(...)],
|
||||
['totp', 'required', 'message' => E::TOTP_REQUIRED],
|
||||
['totp', TotpValidator::class, 'account' => $this->getAccount()],
|
||||
['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()],
|
||||
@ -41,7 +43,7 @@ class EnableTwoFactorAuthForm extends AccountActionForm {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function validateOtpDisabled($attribute): void {
|
||||
private function validateOtpDisabled(string $attribute): void {
|
||||
if ($this->getAccount()->is_otp_enabled) {
|
||||
$this->addError($attribute, E::OTP_ALREADY_ENABLED);
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ use api\controllers\Controller;
|
||||
use api\modules\authserver\models;
|
||||
use Yii;
|
||||
|
||||
class AuthenticationController extends Controller {
|
||||
final class AuthenticationController extends Controller {
|
||||
|
||||
public function behaviors(): array {
|
||||
$behaviors = parent::behaviors();
|
||||
@ -27,12 +27,11 @@ class AuthenticationController extends Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
|
||||
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
|
||||
*/
|
||||
public function actionAuthenticate(): array {
|
||||
$model = new models\AuthenticationForm();
|
||||
/** @var \api\modules\authserver\models\AuthenticationForm $model */
|
||||
$model = Yii::createObject(models\AuthenticationForm::class);
|
||||
$model->load(Yii::$app->request->post());
|
||||
|
||||
return $model->authenticate()->getResponseData(true);
|
||||
@ -62,10 +61,6 @@ class AuthenticationController extends Controller {
|
||||
// In case of an error, an exception is thrown which will be processed by ErrorHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
|
||||
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
|
||||
*/
|
||||
public function actionSignout(): void {
|
||||
$model = new models\SignoutForm();
|
||||
$model->load(Yii::$app->request->post());
|
||||
|
@ -15,6 +15,28 @@ final readonly class AuthenticateData {
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* accessToken: string,
|
||||
* clientToken: string,
|
||||
* selectedProfile: array{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* },
|
||||
* availableProfiles?: array<array{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* }>,
|
||||
* user?: array{
|
||||
* id: string,
|
||||
* username: string,
|
||||
* properties: array<array{
|
||||
* name: string,
|
||||
* value: string,
|
||||
* }>,
|
||||
* },
|
||||
* }
|
||||
*/
|
||||
public function getResponseData(bool $includeAvailableProfiles = false): array {
|
||||
$uuid = str_replace('-', '', $this->account->uuid);
|
||||
$result = [
|
||||
|
@ -3,43 +3,40 @@ declare(strict_types=1);
|
||||
|
||||
namespace api\modules\authserver\models;
|
||||
|
||||
use api\models\authentication\LoginForm;
|
||||
use api\components\Tokens\TokensFactory;
|
||||
use api\models\base\ApiForm;
|
||||
use api\modules\authserver\exceptions\ForbiddenOperationException;
|
||||
use api\modules\authserver\Module as Authserver;
|
||||
use api\modules\authserver\validators\ClientTokenValidator;
|
||||
use api\modules\authserver\validators\RequiredValidator;
|
||||
use api\rbac\Permissions as P;
|
||||
use common\helpers\Error as E;
|
||||
use common\components\Authentication\Entities\Credentials;
|
||||
use common\components\Authentication\Exceptions;
|
||||
use common\components\Authentication\Exceptions\AuthenticationException;
|
||||
use common\components\Authentication\LoginServiceInterface;
|
||||
use common\models\Account;
|
||||
use common\models\OauthClient;
|
||||
use common\models\OauthSession;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Webmozart\Assert\Assert;
|
||||
use Yii;
|
||||
use yii\db\Exception;
|
||||
|
||||
class AuthenticationForm extends ApiForm {
|
||||
final class AuthenticationForm extends ApiForm {
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $username;
|
||||
public mixed $username = null;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $password;
|
||||
public mixed $password = null;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientToken;
|
||||
public mixed $clientToken = null;
|
||||
|
||||
/**
|
||||
* @var string|bool
|
||||
*/
|
||||
public $requestUser;
|
||||
public mixed $requestUser = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly LoginServiceInterface $loginService,
|
||||
private readonly TokensFactory $tokensFactory,
|
||||
array $config = [],
|
||||
) {
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
public function rules(): array {
|
||||
return [
|
||||
@ -50,9 +47,7 @@ class AuthenticationForm extends ApiForm {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AuthenticateData
|
||||
* @throws ForbiddenOperationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function authenticate(): AuthenticateData {
|
||||
// This validating method will throw an exception in case when validation will not pass successfully
|
||||
@ -60,11 +55,7 @@ class AuthenticationForm extends ApiForm {
|
||||
|
||||
Authserver::info("Trying to authenticate user by login = '{$this->username}'.");
|
||||
|
||||
// The previous authorization server implementation used the nickname field instead of username,
|
||||
// so we keep such behavior
|
||||
$attribute = !str_contains($this->username, '@') ? 'nickname' : 'email';
|
||||
|
||||
$password = $this->password;
|
||||
$password = (string)$this->password;
|
||||
$totp = null;
|
||||
if (preg_match('/.{8,}:(\d{6})$/', $password, $matches) === 1) {
|
||||
$totp = $matches[1];
|
||||
@ -73,47 +64,32 @@ class AuthenticationForm extends ApiForm {
|
||||
|
||||
login:
|
||||
|
||||
$loginForm = new LoginForm();
|
||||
$loginForm->login = $this->username;
|
||||
$loginForm->password = $password;
|
||||
$loginForm->totp = $totp;
|
||||
$credentials = new Credentials(
|
||||
login: (string)$this->username,
|
||||
password: $password,
|
||||
totp: $totp,
|
||||
);
|
||||
|
||||
$isValid = $loginForm->validate();
|
||||
// Handle case when user's password matches the template for totp via password
|
||||
if (!$isValid && $totp !== null && $loginForm->getFirstError('password') === E::PASSWORD_INCORRECT) {
|
||||
$password = "{$password}:{$totp}";
|
||||
$totp = null;
|
||||
|
||||
goto login;
|
||||
}
|
||||
|
||||
if (!$isValid || $loginForm->getAccount()->status === Account::STATUS_DELETED) {
|
||||
$errors = $loginForm->getFirstErrors();
|
||||
if (isset($errors['login'])) {
|
||||
if ($errors['login'] === E::ACCOUNT_BANNED) {
|
||||
Authserver::error("User with login = '{$this->username}' is banned");
|
||||
throw new ForbiddenOperationException('This account has been suspended.');
|
||||
}
|
||||
|
||||
Authserver::error("Cannot find user by login = '{$this->username}'");
|
||||
} elseif (isset($errors['password'])) {
|
||||
Authserver::error("User with login = '{$this->username}' passed wrong password.");
|
||||
} elseif (isset($errors['totp'])) {
|
||||
if ($errors['totp'] === E::TOTP_REQUIRED) {
|
||||
Authserver::error("User with login = '{$this->username}' protected by two factor auth.");
|
||||
throw new ForbiddenOperationException('Account protected with two factor auth.');
|
||||
}
|
||||
|
||||
Authserver::error("User with login = '{$this->username}' passed wrong totp token");
|
||||
try {
|
||||
$result = $this->loginService->loginByCredentials($credentials);
|
||||
} catch (Exceptions\InvalidPasswordException $e) {
|
||||
if ($totp !== null) {
|
||||
$password = $this->password;
|
||||
goto login;
|
||||
}
|
||||
|
||||
throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password.");
|
||||
$this->convertAuthenticationException($e);
|
||||
} catch (AuthenticationException $e) {
|
||||
$this->convertAuthenticationException($e);
|
||||
}
|
||||
|
||||
$account = $result->account;
|
||||
if ($account->status === Account::STATUS_DELETED) {
|
||||
throw new ForbiddenOperationException('Invalid credentials. Invalid username or password.');
|
||||
}
|
||||
|
||||
/** @var Account $account */
|
||||
$account = $loginForm->getAccount();
|
||||
$clientToken = $this->clientToken ?: Uuid::uuid4()->toString();
|
||||
$token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $clientToken);
|
||||
$token = $this->tokensFactory->createForMinecraftAccount($account, $clientToken);
|
||||
$dataModel = new AuthenticateData($account, $token->toString(), $clientToken, (bool)$this->requestUser);
|
||||
/** @var OauthSession|null $minecraftOauthSession */
|
||||
$minecraftOauthSession = $account->getOauthSessions()
|
||||
@ -134,4 +110,15 @@ class AuthenticationForm extends ApiForm {
|
||||
return $dataModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
|
||||
*/
|
||||
private function convertAuthenticationException(AuthenticationException $e): never {
|
||||
throw match ($e::class) {
|
||||
Exceptions\AccountBannedException::class => new ForbiddenOperationException('This account has been suspended.'),
|
||||
Exceptions\TotpRequiredException::class => new ForbiddenOperationException('Account protected with two factor auth.'),
|
||||
default => new ForbiddenOperationException('Invalid credentials. Invalid username or password.'),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,23 +3,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace api\modules\authserver\models;
|
||||
|
||||
use api\models\authentication\LoginForm;
|
||||
use api\models\base\ApiForm;
|
||||
use api\modules\authserver\exceptions\ForbiddenOperationException;
|
||||
use api\modules\authserver\validators\RequiredValidator;
|
||||
use common\helpers\Error as E;
|
||||
|
||||
class SignoutForm extends ApiForm {
|
||||
final class SignoutForm extends ApiForm {
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $username;
|
||||
public mixed $username = null;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $password;
|
||||
public mixed $password = null;
|
||||
|
||||
public function rules(): array {
|
||||
return [
|
||||
@ -27,32 +18,11 @@ class SignoutForm extends ApiForm {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
* @throws ForbiddenOperationException
|
||||
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
|
||||
*/
|
||||
public function signout(): bool {
|
||||
$this->validate();
|
||||
|
||||
$loginForm = new LoginForm();
|
||||
$loginForm->login = $this->username;
|
||||
$loginForm->password = $this->password;
|
||||
if (!$loginForm->validate()) {
|
||||
$errors = $loginForm->getFirstErrors();
|
||||
if (isset($errors['login']) && $errors['login'] === E::ACCOUNT_BANNED) {
|
||||
// We believe that a blocked one can get out painlessly
|
||||
return true;
|
||||
}
|
||||
|
||||
// The previous authorization server implementation used the nickname field instead of username,
|
||||
// so we keep such behavior
|
||||
$attribute = !str_contains($this->username, '@') ? 'nickname' : 'email';
|
||||
|
||||
throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password.");
|
||||
}
|
||||
|
||||
// We're unable to invalidate access tokens because they aren't stored in our database
|
||||
// We don't give an error about invalid credentials to eliminate a point through which attackers can brut force passwords.
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -1,14 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\_pages;
|
||||
|
||||
class AuthenticationRoute extends BasePage {
|
||||
final class AuthenticationRoute extends BasePage {
|
||||
|
||||
/**
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param bool|string|null $rememberMeOrToken
|
||||
* @param bool $rememberMe
|
||||
*/
|
||||
public function login(string $login = '', string $password = '', bool|string|null $rememberMeOrToken = null, bool $rememberMe = false): void {
|
||||
$params = [
|
||||
'login' => $login,
|
||||
|
@ -8,7 +8,7 @@ use api\tests\FunctionalTester;
|
||||
use OTPHP\TOTP;
|
||||
|
||||
// TODO: very outdated tests. Need to rewrite
|
||||
class LoginCest {
|
||||
final class LoginCest {
|
||||
|
||||
public function testLoginEmailOrUsername(FunctionalTester $I): void {
|
||||
$route = new AuthenticationRoute($I);
|
||||
|
@ -156,7 +156,7 @@ class AuthorizationCest {
|
||||
$I->canSeeResponseIsJson();
|
||||
$I->canSeeResponseContainsJson([
|
||||
'error' => 'ForbiddenOperationException',
|
||||
'errorMessage' => 'Invalid credentials. Invalid nickname or password.',
|
||||
'errorMessage' => 'Invalid credentials. Invalid username or password.',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -170,7 +170,7 @@ class AuthorizationCest {
|
||||
$I->canSeeResponseCodeIs(401);
|
||||
$I->canSeeResponseContainsJson([
|
||||
'error' => 'ForbiddenOperationException',
|
||||
'errorMessage' => 'Invalid credentials. Invalid nickname or password.',
|
||||
'errorMessage' => 'Invalid credentials. Invalid username or password.',
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -6,14 +6,15 @@ namespace api\tests\functional\authserver;
|
||||
use api\tests\functional\_steps\AuthserverSteps;
|
||||
use Codeception\Example;
|
||||
|
||||
class SignoutCest {
|
||||
final class SignoutCest {
|
||||
|
||||
/**
|
||||
* @example {"login": "admin", "password": "password_0"}
|
||||
* @example {"login": "admin@ely.by", "password": "password_0"}
|
||||
*
|
||||
* @param \Codeception\Example<array{login: string, password: string}> $example
|
||||
*/
|
||||
public function signout(AuthserverSteps $I, Example $example): void {
|
||||
$I->wantTo('signout by nickname and password');
|
||||
$I->sendPOST('/api/authserver/authentication/signout', [
|
||||
'username' => $example['login'],
|
||||
'password' => $example['password'],
|
||||
@ -23,7 +24,6 @@ class SignoutCest {
|
||||
}
|
||||
|
||||
public function wrongArguments(AuthserverSteps $I): void {
|
||||
$I->wantTo('get error on wrong amount of arguments');
|
||||
$I->sendPOST('/api/authserver/authentication/signout', [
|
||||
'key' => 'value',
|
||||
]);
|
||||
@ -36,21 +36,15 @@ class SignoutCest {
|
||||
}
|
||||
|
||||
public function wrongNicknameAndPassword(AuthserverSteps $I): void {
|
||||
$I->wantTo('signout by nickname and password with wrong data');
|
||||
$I->sendPOST('/api/authserver/authentication/signout', [
|
||||
'username' => 'nonexistent_user',
|
||||
'password' => 'nonexistent_password',
|
||||
]);
|
||||
$I->canSeeResponseCodeIs(401);
|
||||
$I->canSeeResponseIsJson();
|
||||
$I->canSeeResponseContainsJson([
|
||||
'error' => 'ForbiddenOperationException',
|
||||
'errorMessage' => 'Invalid credentials. Invalid nickname or password.',
|
||||
]);
|
||||
$I->canSeeResponseCodeIs(200);
|
||||
$I->canSeeResponseEquals('');
|
||||
}
|
||||
|
||||
public function bannedAccount(AuthserverSteps $I): void {
|
||||
$I->wantTo('signout from banned account');
|
||||
$I->sendPOST('/api/authserver/authentication/signout', [
|
||||
'username' => 'Banned',
|
||||
'password' => 'password_0',
|
||||
|
@ -1,122 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\models\authentication;
|
||||
|
||||
use api\models\authentication\LoginForm;
|
||||
use api\tests\unit\TestCase;
|
||||
use common\models\Account;
|
||||
use common\tests\fixtures\AccountFixture;
|
||||
use OTPHP\TOTP;
|
||||
|
||||
class LoginFormTest extends TestCase {
|
||||
|
||||
public function _fixtures(): array {
|
||||
return [
|
||||
'accounts' => AccountFixture::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function testValidateLogin(): void {
|
||||
$model = $this->createWithAccount(null);
|
||||
$model->login = 'mock-login';
|
||||
$model->validateLogin('login');
|
||||
$this->assertSame(['error.login_not_exist'], $model->getErrors('login'));
|
||||
|
||||
$model = $this->createWithAccount(new Account());
|
||||
$model->login = 'mock-login';
|
||||
$model->validateLogin('login');
|
||||
$this->assertEmpty($model->getErrors('login'));
|
||||
}
|
||||
|
||||
public function testValidatePassword(): void {
|
||||
$account = new Account();
|
||||
$account->password_hash = '$2y$04$N0q8DaHzlYILCnLYrpZfEeWKEqkPZzbawiS07GbSr/.xbRNweSLU6'; // 12345678
|
||||
$account->password_hash_strategy = Account::PASS_HASH_STRATEGY_YII2;
|
||||
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->password = '87654321';
|
||||
$model->validatePassword('password');
|
||||
$this->assertSame(['error.password_incorrect'], $model->getErrors('password'));
|
||||
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->password = '12345678';
|
||||
$model->validatePassword('password');
|
||||
$this->assertEmpty($model->getErrors('password'));
|
||||
}
|
||||
|
||||
public function testValidateTotp(): void {
|
||||
$account = new Account(['password' => '12345678']);
|
||||
$account->password = '12345678';
|
||||
$account->is_otp_enabled = true;
|
||||
$account->otp_secret = 'AAAA';
|
||||
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->password = '12345678';
|
||||
$model->totp = '321123';
|
||||
$model->validateTotp('totp');
|
||||
$this->assertSame(['error.totp_incorrect'], $model->getErrors('totp'));
|
||||
|
||||
$totp = TOTP::create($account->otp_secret);
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->password = '12345678';
|
||||
$model->totp = $totp->now();
|
||||
$model->validateTotp('totp');
|
||||
$this->assertEmpty($model->getErrors('totp'));
|
||||
}
|
||||
|
||||
public function testValidateActivity(): void {
|
||||
$account = new Account();
|
||||
$account->status = Account::STATUS_REGISTERED;
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->validateActivity('login');
|
||||
$this->assertSame(['error.account_not_activated'], $model->getErrors('login'));
|
||||
|
||||
$account = new Account();
|
||||
$account->status = Account::STATUS_BANNED;
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->validateActivity('login');
|
||||
$this->assertSame(['error.account_banned'], $model->getErrors('login'));
|
||||
|
||||
$account = new Account();
|
||||
$account->status = Account::STATUS_ACTIVE;
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->validateActivity('login');
|
||||
$this->assertEmpty($model->getErrors('login'));
|
||||
}
|
||||
|
||||
public function testLogin(): void {
|
||||
$account = new Account();
|
||||
$account->id = 1;
|
||||
$account->username = 'erickskrauch';
|
||||
$account->password_hash = '$2y$04$N0q8DaHzlYILCnLYrpZfEeWKEqkPZzbawiS07GbSr/.xbRNweSLU6'; // 12345678
|
||||
$account->password_hash_strategy = Account::PASS_HASH_STRATEGY_YII2;
|
||||
$account->status = Account::STATUS_ACTIVE;
|
||||
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->login = 'erickskrauch';
|
||||
$model->password = '12345678';
|
||||
|
||||
$this->assertNotNull($model->login(), 'model should login user');
|
||||
}
|
||||
|
||||
public function testLoginWithRehashing(): void {
|
||||
/** @var Account $account */
|
||||
$account = $this->tester->grabFixture('accounts', 'user-with-old-password-type');
|
||||
$model = $this->createWithAccount($account);
|
||||
$model->login = $account->username;
|
||||
$model->password = '12345678';
|
||||
|
||||
$this->assertNotNull($model->login());
|
||||
$this->assertSame(Account::PASS_HASH_STRATEGY_YII2, $account->password_hash_strategy);
|
||||
$this->assertNotSame('133c00c463cbd3e491c28cb653ce4718', $account->password_hash);
|
||||
}
|
||||
|
||||
private function createWithAccount(?Account $account): LoginForm {
|
||||
$model = $this->createPartialMock(LoginForm::class, ['getAccount']);
|
||||
$model->method('getAccount')->willReturn($account);
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\models\authentication;
|
||||
|
||||
use api\components\User\Component;
|
||||
use api\models\authentication\LogoutForm;
|
||||
use api\tests\unit\TestCase;
|
||||
use common\models\AccountSession;
|
||||
use Yii;
|
||||
|
||||
class LogoutFormTest extends TestCase {
|
||||
|
||||
public function testNoActionWhenThereIsNoActiveSession(): void {
|
||||
$userComp = $this->createPartialMock(Component::class, ['getActiveSession']);
|
||||
$userComp->method('getActiveSession')->willReturn(null);
|
||||
|
||||
Yii::$app->set('user', $userComp);
|
||||
|
||||
$model = new LogoutForm();
|
||||
$this->assertTrue($model->logout());
|
||||
}
|
||||
|
||||
public function testActiveSessionShouldBeDeleted(): void {
|
||||
$session = $this->createPartialMock(AccountSession::class, ['delete']);
|
||||
$session->expects($this->once())->method('delete')->willReturn(true);
|
||||
|
||||
$userComp = $this->createPartialMock(Component::class, ['getActiveSession']);
|
||||
$userComp->method('getActiveSession')->willReturn($session);
|
||||
|
||||
Yii::$app->set('user', $userComp);
|
||||
|
||||
$model = new LogoutForm();
|
||||
$model->logout();
|
||||
}
|
||||
|
||||
}
|
@ -5,10 +5,9 @@ namespace api\tests\unit\modules\accounts\models;
|
||||
|
||||
use api\modules\accounts\models\DisableTwoFactorAuthForm;
|
||||
use api\tests\unit\TestCase;
|
||||
use common\helpers\Error as E;
|
||||
use common\models\Account;
|
||||
|
||||
class DisableTwoFactorAuthFormTest extends TestCase {
|
||||
final class DisableTwoFactorAuthFormTest extends TestCase {
|
||||
|
||||
public function testPerformAction(): void {
|
||||
$account = $this->createPartialMock(Account::class, ['save']);
|
||||
@ -26,18 +25,4 @@ class DisableTwoFactorAuthFormTest extends TestCase {
|
||||
$this->assertFalse($account->is_otp_enabled);
|
||||
}
|
||||
|
||||
public function testValidateOtpEnabled(): void {
|
||||
$account = new Account();
|
||||
$account->is_otp_enabled = false;
|
||||
$model = new DisableTwoFactorAuthForm($account);
|
||||
$model->validateOtpEnabled('account');
|
||||
$this->assertSame([E::OTP_NOT_ENABLED], $model->getErrors('account'));
|
||||
|
||||
$account = new Account();
|
||||
$account->is_otp_enabled = true;
|
||||
$model = new DisableTwoFactorAuthForm($account);
|
||||
$model->validateOtpEnabled('account');
|
||||
$this->assertEmpty($model->getErrors('account'));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -6,11 +6,10 @@ namespace api\tests\unit\modules\accounts\models;
|
||||
use api\components\User\Component;
|
||||
use api\modules\accounts\models\EnableTwoFactorAuthForm;
|
||||
use api\tests\unit\TestCase;
|
||||
use common\helpers\Error as E;
|
||||
use common\models\Account;
|
||||
use Yii;
|
||||
|
||||
class EnableTwoFactorAuthFormTest extends TestCase {
|
||||
final class EnableTwoFactorAuthFormTest extends TestCase {
|
||||
|
||||
public function testPerformAction(): void {
|
||||
$account = $this->createPartialMock(Account::class, ['save']);
|
||||
@ -30,18 +29,4 @@ class EnableTwoFactorAuthFormTest extends TestCase {
|
||||
$this->assertTrue($account->is_otp_enabled);
|
||||
}
|
||||
|
||||
public function testValidateOtpDisabled(): void {
|
||||
$account = new Account();
|
||||
$account->is_otp_enabled = true;
|
||||
$model = new EnableTwoFactorAuthForm($account);
|
||||
$model->validateOtpDisabled('account');
|
||||
$this->assertSame([E::OTP_ALREADY_ENABLED], $model->getErrors('account'));
|
||||
|
||||
$account = new Account();
|
||||
$account->is_otp_enabled = false;
|
||||
$model = new EnableTwoFactorAuthForm($account);
|
||||
$model->validateOtpDisabled('account');
|
||||
$this->assertEmpty($model->getErrors('account'));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,116 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\modules\authserver\models;
|
||||
|
||||
use api\modules\authserver\exceptions\ForbiddenOperationException;
|
||||
use api\modules\authserver\models\AuthenticationForm;
|
||||
use api\tests\unit\TestCase;
|
||||
use common\models\Account;
|
||||
use common\models\OauthClient;
|
||||
use common\models\OauthSession;
|
||||
use common\tests\fixtures\AccountFixture;
|
||||
use common\tests\fixtures\OauthClientFixture;
|
||||
use OTPHP\TOTP;
|
||||
use function Ramsey\Uuid\v4 as uuid4;
|
||||
|
||||
class AuthenticationFormTest extends TestCase {
|
||||
|
||||
public function _fixtures(): array {
|
||||
return [
|
||||
'accounts' => AccountFixture::class,
|
||||
'oauthClients' => OauthClientFixture::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function testAuthenticateByValidCredentials(): void {
|
||||
$authForm = new AuthenticationForm();
|
||||
$authForm->username = 'admin';
|
||||
$authForm->password = 'password_0';
|
||||
$authForm->clientToken = uuid4();
|
||||
$result = $authForm->authenticate()->getResponseData();
|
||||
$this->assertMatchesRegularExpression('/^[\w=-]+\.[\w=-]+\.[\w=-]+$/', $result['accessToken']);
|
||||
$this->assertSame($authForm->clientToken, $result['clientToken']);
|
||||
$this->assertSame('df936908b2e1544d96f82977ec213022', $result['selectedProfile']['id']);
|
||||
$this->assertSame('Admin', $result['selectedProfile']['name']);
|
||||
$this->assertTrue(OauthSession::find()->andWhere([
|
||||
'account_id' => 1,
|
||||
'client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER,
|
||||
])->exists());
|
||||
$this->assertArrayNotHasKey('user', $result);
|
||||
|
||||
$authForm->requestUser = true;
|
||||
$result = $authForm->authenticate()->getResponseData();
|
||||
$this->assertSame([
|
||||
'id' => 'df936908b2e1544d96f82977ec213022',
|
||||
'username' => 'Admin',
|
||||
'properties' => [
|
||||
[
|
||||
'name' => 'preferredLanguage',
|
||||
'value' => 'en',
|
||||
],
|
||||
],
|
||||
], $result['user']);
|
||||
}
|
||||
|
||||
public function testAuthenticateByValidCredentialsWith2FA(): void {
|
||||
$authForm = new AuthenticationForm();
|
||||
$authForm->username = 'otp@gmail.com';
|
||||
$authForm->password = 'password_0:' . TOTP::create('BBBB')->now();
|
||||
$authForm->clientToken = uuid4();
|
||||
|
||||
// Just ensure that there is no exception
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
$authForm->authenticate();
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a special case which ensures that if the user has a password that looks like
|
||||
* a two-factor code passed in the password field, than he can still log in into his account
|
||||
*/
|
||||
public function testAuthenticateEdgyCaseFor2FA(): void {
|
||||
/** @var Account $account */
|
||||
$account = Account::findOne(['email' => 'admin@ely.by']);
|
||||
$account->setPassword('password_0:123456');
|
||||
$account->save();
|
||||
|
||||
$authForm = new AuthenticationForm();
|
||||
$authForm->username = 'admin@ely.by';
|
||||
$authForm->password = 'password_0:123456';
|
||||
$authForm->clientToken = uuid4();
|
||||
|
||||
// Just ensure that there is no exception
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
$authForm->authenticate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getInvalidCredentialsCases
|
||||
*/
|
||||
public function testAuthenticateByWrongCredentials(
|
||||
string $expectedExceptionMessage,
|
||||
string $login,
|
||||
string $password,
|
||||
string $totp = null,
|
||||
): void {
|
||||
$this->expectException(ForbiddenOperationException::class);
|
||||
$this->expectExceptionMessage($expectedExceptionMessage);
|
||||
|
||||
$authForm = new AuthenticationForm();
|
||||
$authForm->username = $login;
|
||||
$authForm->password = $password . ($totp ? ":{$totp}" : '');
|
||||
$authForm->clientToken = uuid4();
|
||||
$authForm->authenticate();
|
||||
}
|
||||
|
||||
public function getInvalidCredentialsCases(): iterable {
|
||||
yield ['Invalid credentials. Invalid nickname or password.', 'wrong-username', 'wrong-password'];
|
||||
yield ['Invalid credentials. Invalid email or password.', 'wrong-email@ely.by', 'wrong-password'];
|
||||
yield ['This account has been suspended.', 'Banned', 'password_0'];
|
||||
yield ['Account protected with two factor auth.', 'AccountWithEnabledOtp', 'password_0'];
|
||||
yield ['Invalid credentials. Invalid nickname or password.', 'AccountWithEnabledOtp', 'password_0', '123456'];
|
||||
}
|
||||
|
||||
}
|
@ -1,15 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\validators;
|
||||
|
||||
use api\tests\unit\TestCase;
|
||||
use api\validators\TotpValidator;
|
||||
use Carbon\CarbonImmutable;
|
||||
use common\helpers\Error as E;
|
||||
use common\models\Account;
|
||||
use common\tests\_support\ProtectedCaller;
|
||||
use Lcobucci\Clock\FrozenClock;
|
||||
use OTPHP\TOTP;
|
||||
|
||||
class TotpValidatorTest extends TestCase {
|
||||
use ProtectedCaller;
|
||||
final class TotpValidatorTest extends TestCase {
|
||||
|
||||
public function testValidateValue(): void {
|
||||
$account = new Account();
|
||||
@ -18,32 +20,25 @@ class TotpValidatorTest extends TestCase {
|
||||
|
||||
$validator = new TotpValidator(['account' => $account]);
|
||||
|
||||
$result = $this->callProtected($validator, 'validateValue', 123456);
|
||||
$this->assertSame([E::TOTP_INCORRECT, []], $result);
|
||||
$this->assertFalse($validator->validate(123456, $error));
|
||||
$this->assertSame(E::TOTP_INCORRECT, $error);
|
||||
|
||||
$result = $this->callProtected($validator, 'validateValue', $controlTotp->now());
|
||||
$this->assertNull($result);
|
||||
$error = null;
|
||||
|
||||
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31));
|
||||
$this->assertNull($result);
|
||||
$this->assertTrue($validator->validate($controlTotp->now(), $error));
|
||||
$this->assertNull($error);
|
||||
|
||||
$at = time() - 400;
|
||||
$validator->timestamp = $at;
|
||||
$result = $this->callProtected($validator, 'validateValue', $controlTotp->now());
|
||||
$this->assertSame([E::TOTP_INCORRECT, []], $result);
|
||||
$error = null;
|
||||
|
||||
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at($at));
|
||||
$this->assertNull($result);
|
||||
// @phpstan-ignore argument.type
|
||||
$this->assertTrue($validator->validate($controlTotp->at(time() - 31), $error));
|
||||
$this->assertNull($error);
|
||||
|
||||
$at = fn(): ?int => null;
|
||||
$validator->timestamp = $at;
|
||||
$result = $this->callProtected($validator, 'validateValue', $controlTotp->now());
|
||||
$this->assertNull($result);
|
||||
$error = null;
|
||||
|
||||
$at = fn(): int => time() - 700;
|
||||
$validator->timestamp = $at;
|
||||
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at($at()));
|
||||
$this->assertNull($result);
|
||||
$validator->setClock(new FrozenClock(CarbonImmutable::now()->subSeconds(400)));
|
||||
$this->assertFalse($validator->validate($controlTotp->now(), $error));
|
||||
$this->assertSame(E::TOTP_INCORRECT, $error);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,49 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\validators;
|
||||
|
||||
use Carbon\FactoryImmutable;
|
||||
use common\helpers\Error as E;
|
||||
use common\models\Account;
|
||||
use OTPHP\TOTP;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use RangeException;
|
||||
use Yii;
|
||||
use yii\base\InvalidConfigException;
|
||||
use yii\validators\Validator;
|
||||
|
||||
class TotpValidator extends Validator {
|
||||
final class TotpValidator extends Validator {
|
||||
|
||||
public ?Account $account = null;
|
||||
|
||||
/**
|
||||
* @var int|callable|null Allows you to set the exact time against which the validation will be performed.
|
||||
* It may be the unix time or a function returning a unix time.
|
||||
* If not specified, the current time will be used.
|
||||
*/
|
||||
public mixed $timestamp = null;
|
||||
|
||||
public $skipOnEmpty = false;
|
||||
|
||||
private ClockInterface $clock;
|
||||
|
||||
/**
|
||||
* @throws InvalidConfigException
|
||||
*/
|
||||
public function init(): void {
|
||||
parent::init();
|
||||
if ($this->account === null) {
|
||||
$this->account = Yii::$app->user->identity;
|
||||
}
|
||||
|
||||
if (!$this->account instanceof Account) {
|
||||
throw new InvalidConfigException('account should be instance of ' . Account::class);
|
||||
throw new InvalidConfigException('This validator must be instantiated with the account param');
|
||||
}
|
||||
|
||||
if (empty($this->account->otp_secret)) {
|
||||
throw new InvalidConfigException('account should have not empty otp_secret');
|
||||
}
|
||||
|
||||
$this->clock = FactoryImmutable::getDefaultInstance();
|
||||
}
|
||||
|
||||
public function setClock(ClockInterface $clock): void {
|
||||
$this->clock = $clock;
|
||||
}
|
||||
|
||||
protected function validateValue($value): ?array {
|
||||
try {
|
||||
// @phpstan-ignore argument.type (it is non empty, its checked in the init method)
|
||||
$totp = TOTP::create($this->account->otp_secret);
|
||||
if (!$totp->verify((string)$value, $this->getTimestamp(), $totp->getPeriod() - 1)) {
|
||||
// @phpstan-ignore argument.type,argument.type,argument.type (all types are fine, they're just not declared well)
|
||||
if (!$totp->verify((string)$value, $this->clock->now()->getTimestamp(), $totp->getPeriod() - 1)) {
|
||||
return [E::TOTP_INCORRECT, []];
|
||||
}
|
||||
} catch (RangeException) {
|
||||
@ -53,17 +56,4 @@ class TotpValidator extends Validator {
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getTimestamp(): ?int {
|
||||
$timestamp = $this->timestamp;
|
||||
if (is_callable($timestamp)) {
|
||||
$timestamp = call_user_func($this->timestamp);
|
||||
}
|
||||
|
||||
if ($timestamp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)$timestamp;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Entities;
|
||||
|
||||
use common\models\Account;
|
||||
use common\models\AccountSession;
|
||||
|
||||
final readonly class AuthenticationResult {
|
||||
|
||||
public function __construct(
|
||||
public Account $account,
|
||||
public ?AccountSession $session = null,
|
||||
) {
|
||||
}
|
||||
|
||||
}
|
16
common/components/Authentication/Entities/Credentials.php
Normal file
16
common/components/Authentication/Entities/Credentials.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Entities;
|
||||
|
||||
final readonly class Credentials {
|
||||
|
||||
public function __construct(
|
||||
public string $login,
|
||||
public string $password,
|
||||
public ?string $totp = null,
|
||||
public bool $rememberMe = false,
|
||||
) {
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Exceptions;
|
||||
|
||||
use common\models\Account;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
final class AccountBannedException extends Exception implements AuthenticationException {
|
||||
|
||||
public function __construct(
|
||||
public readonly Account $account,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct('The account has been banned', previous: $previous);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Exceptions;
|
||||
|
||||
use common\models\Account;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
final class AccountNotActivatedException extends Exception implements AuthenticationException {
|
||||
|
||||
public function __construct(
|
||||
public readonly Account $account,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct('The account has not been activated yet', previous: $previous);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
interface AuthenticationException extends Throwable {
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
final class InvalidPasswordException extends Exception implements AuthenticationException {
|
||||
|
||||
public function __construct(?Throwable $previous = null) {
|
||||
parent::__construct("The entered password doesn't match the account's password", previous: $previous);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
final class InvalidTotpException extends Exception implements AuthenticationException {
|
||||
|
||||
public function __construct(?Throwable $previous = null) {
|
||||
parent::__construct('Incorrect TOTP value', previous: $previous);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
final class TotpRequiredException extends Exception implements AuthenticationException {
|
||||
|
||||
public function __construct(?Throwable $previous = null) {
|
||||
parent::__construct('Two-factor authentication is enabled for the account and you need to pass the TOTP', previous: $previous);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
final class UnknownLoginException extends Exception implements AuthenticationException {
|
||||
|
||||
public function __construct(?Throwable $previous = null) {
|
||||
parent::__construct('The account with the specified login does not exist', previous: $previous);
|
||||
}
|
||||
|
||||
}
|
67
common/components/Authentication/LoginService.php
Normal file
67
common/components/Authentication/LoginService.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication;
|
||||
|
||||
use api\validators\TotpValidator;
|
||||
use common\components\Authentication\Entities\AuthenticationResult;
|
||||
use common\components\Authentication\Entities\Credentials;
|
||||
use common\models\Account;
|
||||
use common\models\AccountSession;
|
||||
use Webmozart\Assert\Assert;
|
||||
use Yii;
|
||||
|
||||
final class LoginService implements LoginServiceInterface {
|
||||
|
||||
public function loginByCredentials(Credentials $credentials): AuthenticationResult {
|
||||
/** @var Account|null $account */
|
||||
$account = Account::find()->andWhereLogin($credentials->login)->one();
|
||||
if ($account === null) {
|
||||
throw new Exceptions\UnknownLoginException();
|
||||
}
|
||||
|
||||
if (!$account->validatePassword($credentials->password)) {
|
||||
throw new Exceptions\InvalidPasswordException();
|
||||
}
|
||||
|
||||
if ($account->is_otp_enabled) {
|
||||
if (empty($credentials->totp)) {
|
||||
throw new Exceptions\TotpRequiredException();
|
||||
}
|
||||
|
||||
$validator = new TotpValidator(['account' => $account]);
|
||||
if (!$validator->validate($credentials->totp)) {
|
||||
throw new Exceptions\InvalidTotpException();
|
||||
}
|
||||
}
|
||||
|
||||
if ($account->status === Account::STATUS_BANNED) {
|
||||
throw new Exceptions\AccountBannedException($account);
|
||||
}
|
||||
|
||||
if ($account->status === Account::STATUS_REGISTERED) {
|
||||
throw new Exceptions\AccountNotActivatedException($account);
|
||||
}
|
||||
|
||||
if ($account->password_hash_strategy !== Account::PASS_HASH_STRATEGY_YII2) {
|
||||
$account->setPassword($credentials->password);
|
||||
Assert::true($account->save(), 'Unable to upgrade user\'s password');
|
||||
}
|
||||
|
||||
$session = null;
|
||||
if ($credentials->rememberMe) {
|
||||
$session = new AccountSession();
|
||||
$session->account_id = $account->id;
|
||||
$session->setIp(Yii::$app->request->userIP);
|
||||
$session->generateRefreshToken();
|
||||
Assert::true($session->save(), 'Cannot save account session model');
|
||||
}
|
||||
|
||||
return new AuthenticationResult($account, $session);
|
||||
}
|
||||
|
||||
public function logout(AccountSession $session): void {
|
||||
$session->delete();
|
||||
}
|
||||
|
||||
}
|
19
common/components/Authentication/LoginServiceInterface.php
Normal file
19
common/components/Authentication/LoginServiceInterface.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace common\components\Authentication;
|
||||
|
||||
use common\components\Authentication\Entities\AuthenticationResult;
|
||||
use common\components\Authentication\Entities\Credentials;
|
||||
use common\models\AccountSession;
|
||||
|
||||
interface LoginServiceInterface {
|
||||
|
||||
/**
|
||||
* @throws \common\components\Authentication\Exceptions\AuthenticationException
|
||||
*/
|
||||
public function loginByCredentials(Credentials $credentials): AuthenticationResult;
|
||||
|
||||
public function logout(AccountSession $session): void;
|
||||
|
||||
}
|
@ -27,6 +27,7 @@ return [
|
||||
],
|
||||
],
|
||||
League\OAuth2\Server\AuthorizationServer::class => common\components\OAuth2\AuthorizationServerFactory::build(...),
|
||||
common\components\Authentication\LoginServiceInterface::class => common\components\Authentication\LoginService::class,
|
||||
],
|
||||
],
|
||||
'components' => [
|
||||
|
@ -77,6 +77,10 @@ class Account extends ActiveRecord {
|
||||
}
|
||||
|
||||
public function validatePassword(string $password, int $passwordHashStrategy = null): bool {
|
||||
if (empty($password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($passwordHashStrategy === null) {
|
||||
$passwordHashStrategy = $this->password_hash_strategy;
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ namespace common\models;
|
||||
use yii\db\ActiveQuery;
|
||||
|
||||
/**
|
||||
* @see Account
|
||||
* @extends \yii\db\ActiveQuery<\common\models\Account>
|
||||
*/
|
||||
class AccountQuery extends ActiveQuery {
|
||||
|
||||
|
@ -330,36 +330,6 @@ parameters:
|
||||
count: 1
|
||||
path: api/modules/accounts/models/DeleteAccountForm.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\modules\\\\accounts\\\\models\\\\DisableTwoFactorAuthForm\\:\\:validateOtpEnabled\\(\\) has parameter \\$attribute with no type specified\\.$#"
|
||||
count: 1
|
||||
path: api/modules/accounts/models/DisableTwoFactorAuthForm.php
|
||||
|
||||
-
|
||||
message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\DisableTwoFactorAuthForm\\:\\:\\$password has no type specified\\.$#"
|
||||
count: 1
|
||||
path: api/modules/accounts/models/DisableTwoFactorAuthForm.php
|
||||
|
||||
-
|
||||
message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\DisableTwoFactorAuthForm\\:\\:\\$totp has no type specified\\.$#"
|
||||
count: 1
|
||||
path: api/modules/accounts/models/DisableTwoFactorAuthForm.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\modules\\\\accounts\\\\models\\\\EnableTwoFactorAuthForm\\:\\:validateOtpDisabled\\(\\) has parameter \\$attribute with no type specified\\.$#"
|
||||
count: 1
|
||||
path: api/modules/accounts/models/EnableTwoFactorAuthForm.php
|
||||
|
||||
-
|
||||
message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\EnableTwoFactorAuthForm\\:\\:\\$password has no type specified\\.$#"
|
||||
count: 1
|
||||
path: api/modules/accounts/models/EnableTwoFactorAuthForm.php
|
||||
|
||||
-
|
||||
message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\EnableTwoFactorAuthForm\\:\\:\\$totp has no type specified\\.$#"
|
||||
count: 1
|
||||
path: api/modules/accounts/models/EnableTwoFactorAuthForm.php
|
||||
|
||||
-
|
||||
message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\SendEmailVerificationForm\\:\\:\\$password has no type specified\\.$#"
|
||||
count: 1
|
||||
@ -415,11 +385,6 @@ parameters:
|
||||
count: 1
|
||||
path: api/modules/authserver/controllers/AuthenticationController.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\modules\\\\authserver\\\\models\\\\AuthenticateData\\:\\:getResponseData\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: api/modules/authserver/models/AuthenticateData.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\modules\\\\authserver\\\\validators\\\\AccessTokenValidator\\:\\:validateValue\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@ -860,11 +825,6 @@ parameters:
|
||||
count: 1
|
||||
path: api/tests/functional/authserver/RefreshCest.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\tests\\\\functional\\\\authserver\\\\SignoutCest\\:\\:signout\\(\\) has parameter \\$example with no value type specified in iterable type Codeception\\\\Example\\.$#"
|
||||
count: 1
|
||||
path: api/tests/functional/authserver/SignoutCest.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\tests\\\\functional\\\\authserver\\\\UsernamesToUuidsCest\\:\\:bulkProfilesEndpoints\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@ -1045,11 +1005,6 @@ parameters:
|
||||
count: 1
|
||||
path: api/tests/unit/modules/accounts/models/ChangeUsernameFormTest.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\tests\\\\unit\\\\modules\\\\authserver\\\\models\\\\AuthenticationFormTest\\:\\:getInvalidCredentialsCases\\(\\) return type has no value type specified in iterable type iterable\\.$#"
|
||||
count: 1
|
||||
path: api/tests/unit/modules/authserver/models/AuthenticationFormTest.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\tests\\\\unit\\\\modules\\\\authserver\\\\validators\\\\RequiredValidatorTest\\:\\:callProtected\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
@ -1100,31 +1055,6 @@ parameters:
|
||||
count: 1
|
||||
path: api/tests/unit/validators/PasswordRequiredValidatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\tests\\\\unit\\\\validators\\\\TotpValidatorTest\\:\\:callProtected\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: api/tests/unit/validators/TotpValidatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Method api\\\\tests\\\\unit\\\\validators\\\\TotpValidatorTest\\:\\:callProtected\\(\\) has parameter \\$args with no type specified\\.$#"
|
||||
count: 1
|
||||
path: api/tests/unit/validators/TotpValidatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$input of method OTPHP\\\\TOTP\\:\\:at\\(\\) expects int\\<0, max\\>, int\\<\\-30, max\\> given\\.$#"
|
||||
count: 1
|
||||
path: api/tests/unit/validators/TotpValidatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$input of method OTPHP\\\\TOTP\\:\\:at\\(\\) expects int\\<0, max\\>, int\\<\\-399, max\\> given\\.$#"
|
||||
count: 1
|
||||
path: api/tests/unit/validators/TotpValidatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$input of method OTPHP\\\\TOTP\\:\\:at\\(\\) expects int\\<0, max\\>, int\\<\\-699, max\\> given\\.$#"
|
||||
count: 1
|
||||
path: api/tests/unit/validators/TotpValidatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Property api\\\\validators\\\\EmailActivationKeyValidator\\:\\:\\$expired has no type specified\\.$#"
|
||||
count: 1
|
||||
@ -1150,31 +1080,6 @@ parameters:
|
||||
count: 1
|
||||
path: api/validators/PasswordRequiredValidator.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$callback of function call_user_func expects callable\\(\\)\\: mixed, \\(callable\\(\\)\\: mixed\\)\\|int\\|null given\\.$#"
|
||||
count: 1
|
||||
path: api/validators/TotpValidator.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$otp of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects non\\-empty\\-string, string given\\.$#"
|
||||
count: 1
|
||||
path: api/validators/TotpValidator.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$secret of static method OTPHP\\\\TOTP\\:\\:create\\(\\) expects non\\-empty\\-string\\|null, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: api/validators/TotpValidator.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$timestamp of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects int\\<0, max\\>\\|null, int\\|null given\\.$#"
|
||||
count: 1
|
||||
path: api/validators/TotpValidator.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$leeway of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects int\\<0, max\\>\\|null, int given\\.$#"
|
||||
count: 1
|
||||
path: api/validators/TotpValidator.php
|
||||
|
||||
-
|
||||
message: "#^Method common\\\\components\\\\EmailsRenderer\\\\Request\\\\TemplateRequest\\:\\:__construct\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@ -1285,11 +1190,6 @@ parameters:
|
||||
count: 1
|
||||
path: common/models/Account.php
|
||||
|
||||
-
|
||||
message: "#^Class common\\\\models\\\\AccountQuery extends generic class yii\\\\db\\\\ActiveQuery but does not specify its types\\: T$#"
|
||||
count: 1
|
||||
path: common/models/AccountQuery.php
|
||||
|
||||
-
|
||||
message: "#^Method common\\\\models\\\\AccountSession\\:\\:getAccount\\(\\) return type with generic class yii\\\\db\\\\ActiveQuery does not specify its types\\: T$#"
|
||||
count: 1
|
||||
|
Loading…
x
Reference in New Issue
Block a user