Merge branch 'oauth_jwt_tokens' into 'master'

Make every auth token JWT

See merge request elyby/accounts!9
This commit is contained in:
ErickSkrauch
2019-12-11 12:00:50 +00:00
131 changed files with 2977 additions and 2866 deletions

View File

@@ -7,8 +7,10 @@ EMAILS_RENDERER_HOST=http://emails-renderer:3000
## Security params
JWT_USER_SECRET=
JWT_ENCRYPTION_KEY=
JWT_PUBLIC_KEY_PATH=
JWT_PRIVATE_KEY_PATH=
JWT_PRIVATE_KEY_PASS=
## External services
RECAPTCHA_PUBLIC=

View File

@@ -1,15 +1,13 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2;
use Carbon\CarbonInterval;
use DateInterval;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Storage\AccessTokenInterface;
use League\OAuth2\Server\Storage\RefreshTokenInterface;
use League\OAuth2\Server\Storage\SessionInterface;
use yii\base\Component as BaseComponent;
/**
* @property AuthorizationServer $authServer
*/
class Component extends BaseComponent {
/**
@@ -19,35 +17,45 @@ class Component extends BaseComponent {
public function getAuthServer(): AuthorizationServer {
if ($this->_authServer === null) {
$authServer = new AuthorizationServer();
$authServer->setAccessTokenStorage(new Storage\AccessTokenStorage());
$authServer->setClientStorage(new Storage\ClientStorage());
$authServer->setScopeStorage(new Storage\ScopeStorage());
$authServer->setSessionStorage(new Storage\SessionStorage());
$authServer->setAuthCodeStorage(new Storage\AuthCodeStorage());
$authServer->setRefreshTokenStorage(new Storage\RefreshTokenStorage());
$authServer->setAccessTokenTTL(86400); // 1d
$authServer->addGrantType(new Grants\AuthCodeGrant());
$authServer->addGrantType(new Grants\RefreshTokenGrant());
$authServer->addGrantType(new Grants\ClientCredentialsGrant());
$this->_authServer = $authServer;
$this->_authServer = $this->createAuthServer();
}
return $this->_authServer;
}
public function getAccessTokenStorage(): AccessTokenInterface {
return $this->getAuthServer()->getAccessTokenStorage();
}
private function createAuthServer(): AuthorizationServer {
$clientsRepo = new Repositories\ClientRepository();
$accessTokensRepo = new Repositories\AccessTokenRepository();
$publicScopesRepo = new Repositories\PublicScopeRepository();
$internalScopesRepo = new Repositories\InternalScopeRepository();
$authCodesRepo = new Repositories\AuthCodeRepository();
$refreshTokensRepo = new Repositories\RefreshTokenRepository();
public function getRefreshTokenStorage(): RefreshTokenInterface {
return $this->getAuthServer()->getRefreshTokenStorage();
}
$accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring
public function getSessionStorage(): SessionInterface {
return $this->getAuthServer()->getSessionStorage();
$authServer = new AuthorizationServer(
$clientsRepo,
$accessTokensRepo,
new Repositories\EmptyScopeRepository(),
new Keys\EmptyKey(),
'', // Omit the key because we use our own encryption mechanism
new ResponseTypes\BearerTokenResponse()
);
/** @noinspection PhpUnhandledExceptionInspection */
$authCodeGrant = new Grants\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant->disableRequireCodeChallengeForPublicClients();
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL);
$authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$refreshTokenGrant = new Grants\RefreshTokenGrant($refreshTokensRepo);
$authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL);
$refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$clientCredentialsGrant = new Grants\ClientCredentialsGrant();
$authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
return $authServer;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2;
use Yii;
/**
* This trait is intended to override the standard data encryption behavior
* with the help of \Defuse\Crypto\Crypto class, because the resultant string
* is much larger than the original one.
*
* The implementation under the hood relies on using libsodium library
* that provides more compact result values.
*/
trait CryptTrait {
protected function encrypt($unencryptedData): string {
return Yii::$app->tokens->encryptValue($unencryptedData);
}
protected function decrypt($encryptedData): string {
return Yii::$app->tokens->decryptValue($encryptedData);
}
}

View File

@@ -1,44 +1,24 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Entities;
use api\components\OAuth2\Storage\SessionStorage;
use ErrorException;
use League\OAuth2\Server\Entity\SessionEntity as OriginalSessionEntity;
use League\OAuth2\Server\CryptKeyInterface;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
use Yii;
class AccessTokenEntity extends \League\OAuth2\Server\Entity\AccessTokenEntity {
class AccessTokenEntity implements AccessTokenEntityInterface {
use EntityTrait;
use TokenEntityTrait;
protected $sessionId;
public function getSessionId() {
return $this->sessionId;
public function __toString(): string {
return (string)Yii::$app->tokensFactory->createForOAuthClient($this);
}
public function setSessionId($sessionId) {
$this->sessionId = $sessionId;
}
/**
* @inheritdoc
* @return static
*/
public function setSession(OriginalSessionEntity $session) {
parent::setSession($session);
$this->sessionId = $session->getId();
return $this;
}
public function getSession(): ?OriginalSessionEntity {
if ($this->session instanceof OriginalSessionEntity) {
return $this->session;
}
$sessionStorage = $this->server->getSessionStorage();
if (!$sessionStorage instanceof SessionStorage) {
throw new ErrorException('SessionStorage must be instance of ' . SessionStorage::class);
}
return $sessionStorage->getById($this->sessionId);
public function setPrivateKey(CryptKeyInterface $privateKey): void {
// We use a general-purpose component to build JWT tokens, so there is no need to keep the key
}
}

View File

@@ -1,29 +1,16 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Entities;
use League\OAuth2\Server\Entity\SessionEntity as OriginalSessionEntity;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
class AuthCodeEntity extends \League\OAuth2\Server\Entity\AuthCodeEntity {
protected $sessionId;
public function getSessionId() {
return $this->sessionId;
}
/**
* @inheritdoc
* @return static
*/
public function setSession(OriginalSessionEntity $session) {
parent::setSession($session);
$this->sessionId = $session->getId();
return $this;
}
public function setSessionId(string $sessionId) {
$this->sessionId = $sessionId;
}
class AuthCodeEntity implements AuthCodeEntityInterface {
use EntityTrait;
use AuthCodeTrait;
use TokenEntityTrait;
}

View File

@@ -1,28 +1,30 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Entities;
class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity {
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\Traits\ClientTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
class ClientEntity implements ClientEntityInterface {
use EntityTrait;
use ClientTrait;
/**
* @var bool
*/
private $isTrusted;
public function setId(string $id) {
$this->id = $id;
}
public function setName(string $name) {
public function __construct(string $id, string $name, $redirectUri, bool $isTrusted) {
$this->identifier = $id;
$this->name = $name;
}
public function setSecret(string $secret) {
$this->secret = $secret;
}
public function setRedirectUri($redirectUri) {
$this->redirectUri = $redirectUri;
$this->isTrusted = $isTrusted;
}
public function setIsTrusted(bool $isTrusted) {
$this->isTrusted = $isTrusted;
public function isConfidential(): bool {
return true;
}
public function isTrusted(): bool {

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Entities;
use api\components\OAuth2\Storage\SessionStorage;
use League\OAuth2\Server\Entity\SessionEntity as OriginalSessionEntity;
use Webmozart\Assert\Assert;
class RefreshTokenEntity extends \League\OAuth2\Server\Entity\RefreshTokenEntity {
private $sessionId;
public function isExpired(): bool {
return false;
}
public function getSession(): ?SessionEntity {
if ($this->session instanceof SessionEntity) {
return $this->session;
}
/** @var SessionStorage $sessionStorage */
$sessionStorage = $this->server->getSessionStorage();
Assert::isInstanceOf($sessionStorage, SessionStorage::class);
return $sessionStorage->getById($this->sessionId);
}
public function getSessionId(): int {
return $this->sessionId;
}
public function setSession(OriginalSessionEntity $session): self {
parent::setSession($session);
$this->setSessionId((int)$session->getId());
return $this;
}
public function setSessionId(int $sessionId): void {
$this->sessionId = $sessionId;
}
}

View File

@@ -1,10 +1,18 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Entities;
class ScopeEntity extends \League\OAuth2\Server\Entity\ScopeEntity {
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
public function setId(string $id) {
$this->id = $id;
class ScopeEntity implements ScopeEntityInterface {
use EntityTrait;
use ScopeTrait;
public function __construct(string $id) {
$this->identifier = $id;
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace api\components\OAuth2\Entities;
use League\OAuth2\Server\Entity\ClientEntity as OriginalClientEntity;
use League\OAuth2\Server\Entity\EntityTrait;
class SessionEntity extends \League\OAuth2\Server\Entity\SessionEntity {
use EntityTrait;
protected $clientId;
public function getClientId() {
return $this->clientId;
}
public function associateClient(OriginalClientEntity $client) {
parent::associateClient($client);
$this->clientId = $client->getId();
return $this;
}
public function setClientId(string $clientId) {
$this->clientId = $clientId;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Entities;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\UserEntityInterface;
class UserEntity implements UserEntityInterface {
use EntityTrait;
public function __construct(int $id) {
$this->identifier = $id;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Events;
use League\Event\AbstractEvent;
class RequestedRefreshToken extends AbstractEvent {
}

View File

@@ -1,22 +0,0 @@
<?php
namespace api\components\OAuth2\Exception;
use League\OAuth2\Server\Exception\OAuthException;
class AcceptRequiredException extends OAuthException {
public $httpStatusCode = 401;
/**
* {@inheritdoc}
*/
public $errorType = 'accept_required';
/**
* {@inheritdoc}
*/
public function __construct() {
parent::__construct('Client must accept authentication request.');
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace api\components\OAuth2\Exception;
class AccessDeniedException extends \League\OAuth2\Server\Exception\AccessDeniedException {
public function __construct($redirectUri = null) {
parent::__construct();
$this->redirectUri = $redirectUri;
}
}

View File

@@ -1,239 +1,43 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Grants;
use api\components\OAuth2\Entities\AccessTokenEntity;
use api\components\OAuth2\Entities\AuthCodeEntity;
use api\components\OAuth2\Entities\ClientEntity;
use api\components\OAuth2\Entities\RefreshTokenEntity;
use api\components\OAuth2\Entities\SessionEntity;
use api\components\OAuth2\Storage\ScopeStorage;
use api\components\OAuth2\Utils\Scopes;
use League\OAuth2\Server\Entity\AuthCodeEntity as BaseAuthCodeEntity;
use League\OAuth2\Server\Entity\ClientEntity as BaseClientEntity;
use League\OAuth2\Server\Event\ClientAuthenticationFailedEvent;
use League\OAuth2\Server\Exception;
use League\OAuth2\Server\Grant\AbstractGrant;
use League\OAuth2\Server\Util\SecureKey;
use api\components\OAuth2\CryptTrait;
use api\components\OAuth2\Events\RequestedRefreshToken;
use api\components\OAuth2\Repositories\PublicScopeRepository;
use DateInterval;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Grant\AuthCodeGrant as BaseAuthCodeGrant;
class AuthCodeGrant extends AbstractGrant {
protected $identifier = 'authorization_code';
protected $responseType = 'code';
protected $authTokenTTL = 600;
protected $requireClientSecret = true;
public function setAuthTokenTTL(int $authTokenTTL): void {
$this->authTokenTTL = $authTokenTTL;
}
public function setRequireClientSecret(bool $required): void {
$this->requireClientSecret = $required;
}
public function shouldRequireClientSecret(): bool {
return $this->requireClientSecret;
}
class AuthCodeGrant extends BaseAuthCodeGrant {
use CryptTrait;
/**
* Check authorize parameters
* @param DateInterval $accessTokenTTL
* @param ClientEntityInterface $client
* @param string|null $userIdentifier
* @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes
*
* @return AuthorizeParams Authorize request parameters
* @throws Exception\OAuthException
*
* @throws
* @return AccessTokenEntityInterface
* @throws \League\OAuth2\Server\Exception\OAuthServerException
* @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException
*/
public function checkAuthorizeParams(): AuthorizeParams {
// Get required params
$clientId = $this->server->getRequest()->query->get('client_id');
if ($clientId === null) {
throw new Exception\InvalidRequestException('client_id');
protected function issueAccessToken(
DateInterval $accessTokenTTL,
ClientEntityInterface $client,
$userIdentifier,
array $scopes = []
): AccessTokenEntityInterface {
foreach ($scopes as $i => $scope) {
if ($scope->getIdentifier() === PublicScopeRepository::OFFLINE_ACCESS) {
unset($scopes[$i]);
$this->getEmitter()->emit(new RequestedRefreshToken());
}
}
$redirectUri = $this->server->getRequest()->query->get('redirect_uri');
if ($redirectUri === null) {
throw new Exception\InvalidRequestException('redirect_uri');
}
// Validate client ID and redirect URI
$client = $this->server->getClientStorage()->get($clientId, null, $redirectUri, $this->getIdentifier());
if (!$client instanceof ClientEntity) {
$this->server->getEventEmitter()->emit(new ClientAuthenticationFailedEvent($this->server->getRequest()));
throw new Exception\InvalidClientException();
}
$state = $this->server->getRequest()->query->get('state');
if ($state === null && $this->server->stateParamRequired()) {
throw new Exception\InvalidRequestException('state', $redirectUri);
}
$responseType = $this->server->getRequest()->query->get('response_type');
if ($responseType === null) {
throw new Exception\InvalidRequestException('response_type', $redirectUri);
}
// Ensure response type is one that is recognised
if (!in_array($responseType, $this->server->getResponseTypes(), true)) {
throw new Exception\UnsupportedResponseTypeException($responseType, $redirectUri);
}
// Validate any scopes that are in the request
$scopeParam = $this->server->getRequest()->query->get('scope', '');
$scopes = $this->validateScopes($scopeParam, $client, $redirectUri);
return new AuthorizeParams($client, $redirectUri, $state, $responseType, $scopes);
}
/**
* Parse a new authorize request
*
* @param string $type The session owner's type
* @param string $typeId The session owner's ID
* @param AuthorizeParams $authParams The authorize request $_GET parameters
*
* @return string An authorisation code
*/
public function newAuthorizeRequest(string $type, string $typeId, AuthorizeParams $authParams): string {
// Create a new session
$session = new SessionEntity($this->server);
$session->setOwner($type, $typeId);
$session->associateClient($authParams->getClient());
// Create a new auth code
$authCode = new AuthCodeEntity($this->server);
$authCode->setId(SecureKey::generate());
$authCode->setRedirectUri($authParams->getRedirectUri());
$authCode->setExpireTime(time() + $this->authTokenTTL);
foreach ($authParams->getScopes() as $scope) {
$authCode->associateScope($scope);
$session->associateScope($scope);
}
$session->save();
$authCode->setSession($session);
$authCode->save();
return $authCode->generateRedirectUri($authParams->getState());
}
/**
* Complete the auth code grant
*
* @return array
*
* @throws Exception\OAuthException
*/
public function completeFlow(): array {
// Get the required params
$clientId = $this->server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser());
if ($clientId === null) {
throw new Exception\InvalidRequestException('client_id');
}
$clientSecret = $this->server->getRequest()->request->get(
'client_secret',
$this->server->getRequest()->getPassword()
);
if ($clientSecret === null && $this->shouldRequireClientSecret()) {
throw new Exception\InvalidRequestException('client_secret');
}
$redirectUri = $this->server->getRequest()->request->get('redirect_uri');
if ($redirectUri === null) {
throw new Exception\InvalidRequestException('redirect_uri');
}
// Validate client ID and client secret
$client = $this->server->getClientStorage()->get($clientId, $clientSecret, $redirectUri, $this->getIdentifier());
if (!$client instanceof BaseClientEntity) {
$this->server->getEventEmitter()->emit(new ClientAuthenticationFailedEvent($this->server->getRequest()));
throw new Exception\InvalidClientException();
}
// Validate the auth code
$authCode = $this->server->getRequest()->request->get('code');
if ($authCode === null) {
throw new Exception\InvalidRequestException('code');
}
$code = $this->server->getAuthCodeStorage()->get($authCode);
if (($code instanceof BaseAuthCodeEntity) === false) {
throw new Exception\InvalidRequestException('code');
}
// Ensure the auth code hasn't expired
if ($code->isExpired()) {
throw new Exception\InvalidRequestException('code');
}
// Check redirect URI presented matches redirect URI originally used in authorize request
if ($code->getRedirectUri() !== $redirectUri) {
throw new Exception\InvalidRequestException('redirect_uri');
}
$session = $code->getSession();
$session->associateClient($client);
$authCodeScopes = $code->getScopes();
// Generate the access token
$accessToken = new AccessTokenEntity($this->server);
$accessToken->setId(SecureKey::generate());
$accessToken->setExpireTime($this->getAccessTokenTTL() + time());
foreach ($authCodeScopes as $authCodeScope) {
$session->associateScope($authCodeScope);
}
foreach ($session->getScopes() as $scope) {
$accessToken->associateScope($scope);
}
$this->server->getTokenType()->setSession($session);
$this->server->getTokenType()->setParam('access_token', $accessToken->getId());
$this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL());
// Set refresh_token param only in case when offline_access requested
if (isset($accessToken->getScopes()[ScopeStorage::OFFLINE_ACCESS])) {
/** @var RefreshTokenGrant $refreshTokenGrant */
$refreshTokenGrant = $this->server->getGrantType('refresh_token');
$refreshToken = new RefreshTokenEntity($this->server);
$refreshToken->setId(SecureKey::generate());
$refreshToken->setExpireTime($refreshTokenGrant->getRefreshTokenTTL() + time());
$this->server->getTokenType()->setParam('refresh_token', $refreshToken->getId());
}
// Expire the auth code
$code->expire();
// Save all the things
$accessToken->setSession($session);
$accessToken->save();
if (isset($refreshToken)) {
$refreshToken->setAccessToken($accessToken);
$refreshToken->save();
}
return $this->server->getTokenType()->generateResponse();
}
/**
* In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes
* list, while by OAuth2 standard it they should be separated by a space. Shit happens :)
* So override scopes validation function to reformat passed value.
*
* @param string $scopeParam
* @param BaseClientEntity $client
* @param string $redirectUri
*
* @return \League\OAuth2\Server\Entity\ScopeEntity[]
*/
public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) {
return parent::validateScopes(Scopes::format($scopeParam), $client, $redirectUri);
return parent::issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes);
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace api\components\OAuth2\Grants;
use api\components\OAuth2\Entities\ClientEntity;
class AuthorizeParams {
private $client;
private $redirectUri;
private $state;
private $responseType;
/**
* @var \api\components\OAuth2\Entities\ScopeEntity[]
*/
private $scopes;
public function __construct(
ClientEntity $client,
string $redirectUri,
?string $state,
string $responseType,
array $scopes
) {
$this->client = $client;
$this->redirectUri = $redirectUri;
$this->state = $state;
$this->responseType = $responseType;
$this->scopes = $scopes;
}
public function getClient(): ClientEntity {
return $this->client;
}
public function getRedirectUri(): string {
return $this->redirectUri;
}
public function getState(): ?string {
return $this->state;
}
public function getResponseType(): string {
return $this->responseType;
}
/**
* @return \api\components\OAuth2\Entities\ScopeEntity[]
*/
public function getScopes(): array {
return $this->scopes ?? [];
}
}

View File

@@ -1,86 +1,12 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Grants;
use api\components\OAuth2\Entities\AccessTokenEntity;
use api\components\OAuth2\Entities\SessionEntity;
use api\components\OAuth2\Utils\Scopes;
use League\OAuth2\Server\Entity\ClientEntity as BaseClientEntity;
use League\OAuth2\Server\Event;
use League\OAuth2\Server\Exception;
use League\OAuth2\Server\Grant\AbstractGrant;
use League\OAuth2\Server\Util\SecureKey;
use api\components\OAuth2\CryptTrait;
use League\OAuth2\Server\Grant\ClientCredentialsGrant as BaseClientCredentialsGrant;
class ClientCredentialsGrant extends AbstractGrant {
protected $identifier = 'client_credentials';
/**
* @return array
* @throws \League\OAuth2\Server\Exception\OAuthException
*/
public function completeFlow(): array {
$clientId = $this->server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser());
if ($clientId === null) {
throw new Exception\InvalidRequestException('client_id');
}
$clientSecret = $this->server->getRequest()->request->get('client_secret');
if ($clientSecret === null) {
throw new Exception\InvalidRequestException('client_secret');
}
// Validate client ID and client secret
$client = $this->server->getClientStorage()->get($clientId, $clientSecret, null, $this->getIdentifier());
if (!$client instanceof BaseClientEntity) {
$this->server->getEventEmitter()->emit(new Event\ClientAuthenticationFailedEvent($this->server->getRequest()));
throw new Exception\InvalidClientException();
}
// Validate any scopes that are in the request
$scopeParam = $this->server->getRequest()->request->get('scope', '');
$scopes = $this->validateScopes($scopeParam, $client);
// Create a new session
$session = new SessionEntity($this->server);
$session->setOwner('client', $client->getId());
$session->associateClient($client);
// Generate an access token
$accessToken = new AccessTokenEntity($this->server);
$accessToken->setId(SecureKey::generate());
$accessToken->setExpireTime($this->getAccessTokenTTL() + time());
// Associate scopes with the session and access token
foreach ($scopes as $scope) {
$session->associateScope($scope);
$accessToken->associateScope($scope);
}
// Save everything
$session->save();
$accessToken->setSession($session);
$accessToken->save();
$this->server->getTokenType()->setSession($session);
$this->server->getTokenType()->setParam('access_token', $accessToken->getId());
$this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL());
return $this->server->getTokenType()->generateResponse();
}
/**
* In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes
* list, while by OAuth2 standard it they should be separated by a space. Shit happens :)
* So override scopes validation function to reformat passed value.
*
* @param string $scopeParam
* @param BaseClientEntity $client
* @param string $redirectUri
*
* @return \League\OAuth2\Server\Entity\ScopeEntity[]
*/
public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) {
return parent::validateScopes(Scopes::format($scopeParam), $client, $redirectUri);
}
class ClientCredentialsGrant extends BaseClientCredentialsGrant {
use CryptTrait;
}

View File

@@ -1,187 +1,123 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Grants;
use api\components\OAuth2\Entities\AccessTokenEntity;
use api\components\OAuth2\Entities\RefreshTokenEntity;
use api\components\OAuth2\Utils\Scopes;
use ErrorException;
use League\OAuth2\Server\Entity\AccessTokenEntity as BaseAccessTokenEntity;
use League\OAuth2\Server\Entity\ClientEntity as BaseClientEntity;
use League\OAuth2\Server\Entity\RefreshTokenEntity as BaseRefreshTokenEntity;
use League\OAuth2\Server\Event\ClientAuthenticationFailedEvent;
use League\OAuth2\Server\Exception;
use League\OAuth2\Server\Grant\AbstractGrant;
use League\OAuth2\Server\Util\SecureKey;
use api\components\OAuth2\CryptTrait;
use api\components\Tokens\TokenReader;
use Carbon\Carbon;
use common\models\OauthSession;
use InvalidArgumentException;
use Lcobucci\JWT\ValidationData;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant;
use Psr\Http\Message\ServerRequestInterface;
use Yii;
class RefreshTokenGrant extends AbstractGrant {
class RefreshTokenGrant extends BaseRefreshTokenGrant {
use CryptTrait;
protected $identifier = 'refresh_token';
/**
* Previously, refresh tokens were stored in Redis.
* If received refresh token is matches the legacy token template,
* restore the information from the legacy storage.
*
* @param ServerRequestInterface $request
* @param string $clientId
*
* @return array
* @throws OAuthServerException
*/
protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId): array {
$refreshToken = $this->getRequestParameter('refresh_token', $request);
if ($refreshToken !== null && mb_strlen($refreshToken) === 40) {
return $this->validateLegacyRefreshToken($refreshToken);
}
protected $refreshTokenTTL = 604800;
protected $refreshTokenRotate = false;
protected $requireClientSecret = true;
public function setRefreshTokenTTL($refreshTokenTTL): void {
$this->refreshTokenTTL = $refreshTokenTTL;
}
public function getRefreshTokenTTL(): int {
return $this->refreshTokenTTL;
}
public function setRefreshTokenRotation(bool $refreshTokenRotate = true): void {
$this->refreshTokenRotate = $refreshTokenRotate;
}
public function shouldRotateRefreshTokens(): bool {
return $this->refreshTokenRotate;
}
public function setRequireClientSecret(string $required): void {
$this->requireClientSecret = $required;
}
public function shouldRequireClientSecret(): bool {
return $this->requireClientSecret;
return $this->validateAccessToken($refreshToken);
}
/**
* In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes
* list, while by OAuth2 standard it they should be separated by a space. Shit happens :)
* So override scopes validation function to reformat passed value.
* Currently we're not rotating refresh tokens.
* So we overriding this method to always return null, which means,
* that refresh_token will not be issued.
*
* @param string $scopeParam
* @param BaseClientEntity $client
* @param string $redirectUri
* @param AccessTokenEntityInterface $accessToken
*
* @return \League\OAuth2\Server\Entity\ScopeEntity[]
* @return RefreshTokenEntityInterface|null
*/
public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) {
return parent::validateScopes(Scopes::format($scopeParam), $client, $redirectUri);
protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface {
return null;
}
/**
* The method has been overridden because we stores access_tokens in Redis with expire value,
* so they might not exists at the moment, when it will be requested via refresh_token.
* That's why we extends RefreshTokenEntity to give it knowledge about related session.
*
* @inheritdoc
* @throws \League\OAuth2\Server\Exception\OAuthException
* @param string $refreshToken
* @return array
* @throws OAuthServerException
*/
public function completeFlow(): array {
$clientId = $this->server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser());
if ($clientId === null) {
throw new Exception\InvalidRequestException('client_id');
private function validateLegacyRefreshToken(string $refreshToken): array {
$result = Yii::$app->redis->get("oauth:refresh:tokens:{$refreshToken}");
if ($result === null) {
throw OAuthServerException::invalidRefreshToken('Token has been revoked');
}
$clientSecret = $this->server->getRequest()->request->get(
'client_secret',
$this->server->getRequest()->getPassword()
);
if ($clientSecret === null && $this->shouldRequireClientSecret()) {
throw new Exception\InvalidRequestException('client_secret');
try {
[
'access_token_id' => $accessTokenId,
'session_id' => $sessionId,
] = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
} catch (\Exception $e) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e);
}
// Validate client ID and client secret
$client = $this->server->getClientStorage()->get($clientId, $clientSecret, null, $this->getIdentifier());
if (($client instanceof BaseClientEntity) === false) {
$this->server->getEventEmitter()->emit(new ClientAuthenticationFailedEvent($this->server->getRequest()));
throw new Exception\InvalidClientException();
/** @var OauthSession|null $relatedSession */
$relatedSession = OauthSession::findOne(['legacy_id' => $sessionId]);
if ($relatedSession === null) {
throw OAuthServerException::invalidRefreshToken('Token has been revoked');
}
$oldRefreshTokenParam = $this->server->getRequest()->request->get('refresh_token');
if ($oldRefreshTokenParam === null) {
throw new Exception\InvalidRequestException('refresh_token');
return [
'client_id' => $relatedSession->client_id,
'refresh_token_id' => $refreshToken,
'access_token_id' => $accessTokenId,
'scopes' => $relatedSession->getScopes(),
'user_id' => $relatedSession->account_id,
'expire_time' => null,
];
}
/**
* @param string $jwt
* @return array
* @throws OAuthServerException
*/
private function validateAccessToken(string $jwt): array {
try {
$token = Yii::$app->tokens->parse($jwt);
} catch (InvalidArgumentException $e) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e);
}
// Validate refresh token
$oldRefreshToken = $this->server->getRefreshTokenStorage()->get($oldRefreshTokenParam);
if (($oldRefreshToken instanceof BaseRefreshTokenEntity) === false) {
throw new Exception\InvalidRefreshException();
if (!Yii::$app->tokens->verify($token)) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token');
}
// Ensure the old refresh token hasn't expired
if ($oldRefreshToken->isExpired()) {
throw new Exception\InvalidRefreshException();
if (!$token->validate(new ValidationData(Carbon::now()->getTimestamp()))) {
throw OAuthServerException::invalidRefreshToken('Token has expired');
}
/** @var AccessTokenEntity|null $oldAccessToken */
$oldAccessToken = $oldRefreshToken->getAccessToken();
if ($oldAccessToken instanceof AccessTokenEntity) {
// Get the scopes for the original session
$session = $oldAccessToken->getSession();
} else {
if (!$oldRefreshToken instanceof RefreshTokenEntity) {
/** @noinspection ExceptionsAnnotatingAndHandlingInspection */
throw new ErrorException('oldRefreshToken must be instance of ' . RefreshTokenEntity::class);
}
$reader = new TokenReader($token);
$session = $oldRefreshToken->getSession();
}
if ($session === null) {
throw new Exception\InvalidRefreshException();
}
$scopes = $this->formatScopes($session->getScopes());
// Get and validate any requested scopes
$requestedScopesString = $this->server->getRequest()->request->get('scope', '');
$requestedScopes = $this->validateScopes($requestedScopesString, $client);
// If no new scopes are requested then give the access token the original session scopes
if (count($requestedScopes) === 0) {
$newScopes = $scopes;
} else {
// The OAuth spec says that a refreshed access token can have the original scopes or fewer so ensure
// the request doesn't include any new scopes
foreach ($requestedScopes as $requestedScope) {
if (!isset($scopes[$requestedScope->getId()])) {
throw new Exception\InvalidScopeException($requestedScope->getId());
}
}
$newScopes = $requestedScopes;
}
// Generate a new access token and assign it the correct sessions
$newAccessToken = new AccessTokenEntity($this->server);
$newAccessToken->setId(SecureKey::generate());
$newAccessToken->setExpireTime($this->getAccessTokenTTL() + time());
$newAccessToken->setSession($session);
foreach ($newScopes as $newScope) {
$newAccessToken->associateScope($newScope);
}
// Expire the old token and save the new one
$oldAccessToken instanceof BaseAccessTokenEntity && $oldAccessToken->expire();
$newAccessToken->save();
$this->server->getTokenType()->setSession($session);
$this->server->getTokenType()->setParam('access_token', $newAccessToken->getId());
$this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL());
if ($this->shouldRotateRefreshTokens()) {
// Expire the old refresh token
$oldRefreshToken->expire();
// Generate a new refresh token
$newRefreshToken = new RefreshTokenEntity($this->server);
$newRefreshToken->setId(SecureKey::generate());
$newRefreshToken->setExpireTime($this->getRefreshTokenTTL() + time());
$newRefreshToken->setAccessToken($newAccessToken);
$newRefreshToken->save();
$this->server->getTokenType()->setParam('refresh_token', $newRefreshToken->getId());
} else {
$oldRefreshToken->setAccessToken($newAccessToken);
$oldRefreshToken->save();
}
return $this->server->getTokenType()->generateResponse();
return [
'client_id' => $reader->getClientId(),
'refresh_token_id' => '', // This value used only to invalidate old token
'access_token_id' => '', // This value used only to invalidate old token
'scopes' => $reader->getScopes(),
'user_id' => $reader->getAccountId(),
'expire_time' => null,
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Keys;
use League\OAuth2\Server\CryptKeyInterface;
class EmptyKey implements CryptKeyInterface {
public function getKeyPath(): string {
return '';
}
public function getPassPhrase(): ?string {
return null;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\AccessTokenEntity;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
class AccessTokenRepository implements AccessTokenRepositoryInterface {
/**
* Create a new access token
*
* @param ClientEntityInterface $clientEntity
* @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes
* @param mixed $userIdentifier
*
* @return AccessTokenEntityInterface
*/
public function getNewToken(
ClientEntityInterface $clientEntity,
array $scopes,
$userIdentifier = null
): AccessTokenEntityInterface {
$accessToken = new AccessTokenEntity();
$accessToken->setClient($clientEntity);
array_map([$accessToken, 'addScope'], $scopes);
if ($userIdentifier !== null) {
$accessToken->setUserIdentifier($userIdentifier);
}
return $accessToken;
}
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void {
// We don't store access tokens, so there's no need to do anything here
}
public function revokeAccessToken($tokenId): void {
// We don't store access tokens, so there's no need to do anything here
}
public function isAccessTokenRevoked($tokenId): bool {
return false;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\AuthCodeEntity;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
class AuthCodeRepository implements AuthCodeRepositoryInterface {
public function getNewAuthCode(): AuthCodeEntityInterface {
return new AuthCodeEntity();
}
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void {
}
public function revokeAuthCode($codeId): void {
}
public function isAuthCodeRevoked($codeId): bool {
return false;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\ClientEntity;
use common\models\OauthClient;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
class ClientRepository implements ClientRepositoryInterface {
public function getClientEntity($clientId): ?ClientEntityInterface {
$client = $this->findModel($clientId);
if ($client === null) {
return null;
}
return new ClientEntity($client->id, $client->name, $client->redirect_uri, (bool)$client->is_trusted);
}
public function validateClient($clientId, $clientSecret, $grantType): bool {
$client = $this->findModel($clientId);
if ($client === null) {
return false;
}
if ($client->type !== OauthClient::TYPE_APPLICATION) {
return false;
}
if ($clientSecret !== null && $clientSecret !== $client->secret) {
return false;
}
return true;
}
private function findModel(string $id): ?OauthClient {
$client = OauthClient::findOne(['id' => $id]);
if ($client === null || $client->type !== OauthClient::TYPE_APPLICATION) {
return null;
}
return $client;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
/**
* In our application we use separate scopes repositories for different grants.
* To create an instance of the authorization server, you need to pass the scopes
* repository. This class acts as a dummy to meet this requirement.
*/
class EmptyScopeRepository implements ScopeRepositoryInterface {
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
return null;
}
public function finalizeScopes(
array $scopes,
$grantType,
ClientEntityInterface $client,
$userIdentifier = null
): array {
return $scopes;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\ClientEntity;
use api\components\OAuth2\Entities\ScopeEntity;
use api\rbac\Permissions as P;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use Webmozart\Assert\Assert;
class InternalScopeRepository implements ScopeRepositoryInterface {
private const ALLOWED_SCOPES = [
P::CHANGE_ACCOUNT_USERNAME,
P::CHANGE_ACCOUNT_PASSWORD,
P::BLOCK_ACCOUNT,
P::OBTAIN_EXTENDED_ACCOUNT_INFO,
P::ESCAPE_IDENTITY_VERIFICATION,
];
private const PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS = [
'internal_account_info' => P::OBTAIN_EXTENDED_ACCOUNT_INFO,
];
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
$identifier = $this->convertToInternalPermission($identifier);
if (!in_array($identifier, self::ALLOWED_SCOPES, true)) {
return null;
}
return new ScopeEntity($identifier);
}
public function finalizeScopes(
array $scopes,
$grantType,
ClientEntityInterface $client,
$userIdentifier = null
): array {
/** @var ClientEntity $client */
Assert::isInstanceOf($client, ClientEntity::class);
if (empty($scopes)) {
return $scopes;
}
// Right now we have no available scopes for the client_credentials grant
if (!$client->isTrusted()) {
throw OAuthServerException::invalidScope($scopes[0]->getIdentifier());
}
return $scopes;
}
private function convertToInternalPermission(string $publicScope): string {
return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\ScopeEntity;
use api\rbac\Permissions as P;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
class PublicScopeRepository implements ScopeRepositoryInterface {
public const OFFLINE_ACCESS = 'offline_access';
public const CHANGE_SKIN = 'change_skin';
private const ACCOUNT_INFO = 'account_info';
private const ACCOUNT_EMAIL = 'account_email';
private const PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS = [
self::ACCOUNT_INFO => P::OBTAIN_OWN_ACCOUNT_INFO,
self::ACCOUNT_EMAIL => P::OBTAIN_ACCOUNT_EMAIL,
];
private const ALLOWED_SCOPES = [
P::OBTAIN_OWN_ACCOUNT_INFO,
P::OBTAIN_ACCOUNT_EMAIL,
P::MINECRAFT_SERVER_SESSION,
self::OFFLINE_ACCESS,
self::CHANGE_SKIN,
];
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
$identifier = $this->convertToInternalPermission($identifier);
if (!in_array($identifier, self::ALLOWED_SCOPES, true)) {
return null;
}
return new ScopeEntity($identifier);
}
public function finalizeScopes(
array $scopes,
$grantType,
ClientEntityInterface $clientEntity,
$userIdentifier = null
): array {
return $scopes;
}
private function convertToInternalPermission(string $publicScope): string {
return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface {
public function getNewRefreshToken(): ?RefreshTokenEntityInterface {
return null;
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void {
// Do nothing
}
public function revokeRefreshToken($tokenId): void {
// Do nothing
}
public function isRefreshTokenRevoked($tokenId): bool {
return false;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\ResponseTypes;
use api\components\OAuth2\CryptTrait;
use League\OAuth2\Server\ResponseTypes\BearerTokenResponse as BaseBearerTokenResponse;
class BearerTokenResponse extends BaseBearerTokenResponse {
use CryptTrait;
}

View File

@@ -1,70 +0,0 @@
<?php
namespace api\components\OAuth2\Storage;
use api\components\OAuth2\Entities\AccessTokenEntity;
use common\components\Redis\Key;
use common\components\Redis\Set;
use League\OAuth2\Server\Entity\AccessTokenEntity as OriginalAccessTokenEntity;
use League\OAuth2\Server\Entity\ScopeEntity;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\AccessTokenInterface;
use yii\helpers\Json;
class AccessTokenStorage extends AbstractStorage implements AccessTokenInterface {
public $dataTable = 'oauth_access_tokens';
public function get($token) {
$result = Json::decode((new Key($this->dataTable, $token))->getValue());
if ($result === null) {
return null;
}
$token = new AccessTokenEntity($this->server);
$token->setId($result['id']);
$token->setExpireTime($result['expire_time']);
$token->setSessionId($result['session_id']);
return $token;
}
public function getScopes(OriginalAccessTokenEntity $token) {
$scopes = $this->scopes($token->getId());
$entities = [];
foreach ($scopes as $scope) {
if ($this->server->getScopeStorage()->get($scope) !== null) {
$entities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]);
}
}
return $entities;
}
public function create($token, $expireTime, $sessionId) {
$payload = Json::encode([
'id' => $token,
'expire_time' => $expireTime,
'session_id' => $sessionId,
]);
$this->key($token)->setValue($payload)->expireAt($expireTime);
}
public function associateScope(OriginalAccessTokenEntity $token, ScopeEntity $scope) {
$this->scopes($token->getId())->add($scope->getId())->expireAt($token->getExpireTime());
}
public function delete(OriginalAccessTokenEntity $token) {
$this->key($token->getId())->delete();
$this->scopes($token->getId())->delete();
}
private function key(string $token): Key {
return new Key($this->dataTable, $token);
}
private function scopes(string $token): Set {
return new Set($this->dataTable, $token, 'scopes');
}
}

View File

@@ -1,72 +0,0 @@
<?php
namespace api\components\OAuth2\Storage;
use api\components\OAuth2\Entities\AuthCodeEntity;
use common\components\Redis\Key;
use common\components\Redis\Set;
use League\OAuth2\Server\Entity\AuthCodeEntity as OriginalAuthCodeEntity;
use League\OAuth2\Server\Entity\ScopeEntity;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\AuthCodeInterface;
use yii\helpers\Json;
class AuthCodeStorage extends AbstractStorage implements AuthCodeInterface {
public $dataTable = 'oauth_auth_codes';
public function get($code) {
$result = Json::decode((new Key($this->dataTable, $code))->getValue());
if ($result === null) {
return null;
}
$entity = new AuthCodeEntity($this->server);
$entity->setId($result['id']);
$entity->setExpireTime($result['expire_time']);
$entity->setSessionId($result['session_id']);
$entity->setRedirectUri($result['client_redirect_uri']);
return $entity;
}
public function create($token, $expireTime, $sessionId, $redirectUri) {
$payload = Json::encode([
'id' => $token,
'expire_time' => $expireTime,
'session_id' => $sessionId,
'client_redirect_uri' => $redirectUri,
]);
$this->key($token)->setValue($payload)->expireAt($expireTime);
}
public function getScopes(OriginalAuthCodeEntity $token) {
$scopes = $this->scopes($token->getId());
$scopesEntities = [];
foreach ($scopes as $scope) {
if ($this->server->getScopeStorage()->get($scope) !== null) {
$scopesEntities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]);
}
}
return $scopesEntities;
}
public function associateScope(OriginalAuthCodeEntity $token, ScopeEntity $scope) {
$this->scopes($token->getId())->add($scope->getId())->expireAt($token->getExpireTime());
}
public function delete(OriginalAuthCodeEntity $token) {
$this->key($token->getId())->delete();
$this->scopes($token->getId())->delete();
}
private function key(string $token): Key {
return new Key($this->dataTable, $token);
}
private function scopes(string $token): Set {
return new Set($this->dataTable, $token, 'scopes');
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace api\components\OAuth2\Storage;
use api\components\OAuth2\Entities\ClientEntity;
use api\components\OAuth2\Entities\SessionEntity;
use common\models\OauthClient;
use League\OAuth2\Server\Entity\SessionEntity as OriginalSessionEntity;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\ClientInterface;
use yii\helpers\StringHelper;
class ClientStorage extends AbstractStorage implements ClientInterface {
private const REDIRECT_STATIC_PAGE = 'static_page';
private const REDIRECT_STATIC_PAGE_WITH_CODE = 'static_page_with_code';
/**
* @inheritdoc
*/
public function get($clientId, $clientSecret = null, $redirectUri = null, $grantType = null) {
$model = $this->findClient($clientId);
if ($model === null) {
return null;
}
if ($clientSecret !== null && $clientSecret !== $model->secret) {
return null;
}
// TODO: should check application type
// For "desktop" app type redirect_uri is not required and should be by default set
// to the static redirect, but for "site" it's required always.
if ($redirectUri !== null) {
if (in_array($redirectUri, [self::REDIRECT_STATIC_PAGE, self::REDIRECT_STATIC_PAGE_WITH_CODE], true)) {
// I think we should check the type of application here
} else {
if (!StringHelper::startsWith($redirectUri, $model->redirect_uri, false)) {
return null;
}
}
}
$entity = $this->hydrate($model);
$entity->setRedirectUri($redirectUri);
return $entity;
}
/**
* @inheritdoc
*/
public function getBySession(OriginalSessionEntity $session) {
if (!$session instanceof SessionEntity) {
throw new \ErrorException('This module assumes that $session typeof ' . SessionEntity::class);
}
$model = $this->findClient($session->getClientId());
if ($model === null) {
return null;
}
return $this->hydrate($model);
}
private function hydrate(OauthClient $model): ClientEntity {
$entity = new ClientEntity($this->server);
$entity->setId($model->id);
$entity->setName($model->name);
$entity->setSecret($model->secret);
$entity->setIsTrusted($model->is_trusted);
$entity->setRedirectUri($model->redirect_uri);
return $entity;
}
private function findClient(string $clientId): ?OauthClient {
return OauthClient::findOne($clientId);
}
}

View File

@@ -1,63 +0,0 @@
<?php
namespace api\components\OAuth2\Storage;
use api\components\OAuth2\Entities\RefreshTokenEntity;
use common\components\Redis\Key;
use common\components\Redis\Set;
use common\models\OauthSession;
use ErrorException;
use League\OAuth2\Server\Entity\RefreshTokenEntity as OriginalRefreshTokenEntity;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\RefreshTokenInterface;
use Yii;
use yii\helpers\Json;
class RefreshTokenStorage extends AbstractStorage implements RefreshTokenInterface {
public $dataTable = 'oauth_refresh_tokens';
public function get($token) {
$result = Json::decode((new Key($this->dataTable, $token))->getValue());
if ($result === null) {
return null;
}
$entity = new RefreshTokenEntity($this->server);
$entity->setId($result['id']);
$entity->setAccessTokenId($result['access_token_id']);
$entity->setSessionId($result['session_id']);
return $entity;
}
public function create($token, $expireTime, $accessToken) {
$sessionId = $this->server->getAccessTokenStorage()->get($accessToken)->getSession()->getId();
$payload = Json::encode([
'id' => $token,
'access_token_id' => $accessToken,
'session_id' => $sessionId,
]);
$this->key($token)->setValue($payload);
$this->sessionHash($sessionId)->add($token);
}
public function delete(OriginalRefreshTokenEntity $token) {
if (!$token instanceof RefreshTokenEntity) {
throw new ErrorException('Token must be instance of ' . RefreshTokenEntity::class);
}
$this->key($token->getId())->delete();
$this->sessionHash($token->getSessionId())->remove($token->getId());
}
public function sessionHash(string $sessionId): Set {
$tableName = Yii::$app->db->getSchema()->getRawTableName(OauthSession::tableName());
return new Set($tableName, $sessionId, 'refresh_tokens');
}
private function key(string $token): Key {
return new Key($this->dataTable, $token);
}
}

View File

@@ -1,93 +0,0 @@
<?php
namespace api\components\OAuth2\Storage;
use api\components\OAuth2\Entities\ClientEntity;
use api\components\OAuth2\Entities\ScopeEntity;
use api\rbac\Permissions as P;
use Assert\Assert;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\ScopeInterface;
class ScopeStorage extends AbstractStorage implements ScopeInterface {
public const OFFLINE_ACCESS = 'offline_access';
public const CHANGE_SKIN = 'change_skin';
private const PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS = [
'account_info' => P::OBTAIN_OWN_ACCOUNT_INFO,
'account_email' => P::OBTAIN_ACCOUNT_EMAIL,
'account_block' => P::BLOCK_ACCOUNT,
'internal_account_info' => P::OBTAIN_EXTENDED_ACCOUNT_INFO,
];
private const AUTHORIZATION_CODE_PERMISSIONS = [
P::OBTAIN_OWN_ACCOUNT_INFO,
P::OBTAIN_ACCOUNT_EMAIL,
P::MINECRAFT_SERVER_SESSION,
self::OFFLINE_ACCESS,
self::CHANGE_SKIN,
];
private const CLIENT_CREDENTIALS_PERMISSIONS = [
];
private const CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL = [
P::CHANGE_ACCOUNT_USERNAME,
P::CHANGE_ACCOUNT_PASSWORD,
P::BLOCK_ACCOUNT,
P::OBTAIN_EXTENDED_ACCOUNT_INFO,
P::ESCAPE_IDENTITY_VERIFICATION,
];
/**
* @param string $scope
* @param string $grantType is passed on if called from the grant.
* In this case, you only need to filter out the rights that you can get on this grant.
* @param string $clientId
*
* @return ScopeEntity|null
*/
public function get($scope, $grantType = null, $clientId = null): ?ScopeEntity {
$permission = $this->convertToInternalPermission($scope);
if ($grantType === 'authorization_code') {
$permissions = self::AUTHORIZATION_CODE_PERMISSIONS;
} elseif ($grantType === 'client_credentials') {
$permissions = self::CLIENT_CREDENTIALS_PERMISSIONS;
$isTrusted = false;
if ($clientId !== null) {
/** @var ClientEntity $client */
$client = $this->server->getClientStorage()->get($clientId);
Assert::that($client)->isInstanceOf(ClientEntity::class);
/** @noinspection NullPointerExceptionInspection */
$isTrusted = $client->isTrusted();
}
if ($isTrusted) {
$permissions = array_merge($permissions, self::CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL);
}
} else {
$permissions = array_merge(
self::AUTHORIZATION_CODE_PERMISSIONS,
self::CLIENT_CREDENTIALS_PERMISSIONS,
self::CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL
);
}
if (!in_array($permission, $permissions, true)) {
return null;
}
$entity = new ScopeEntity($this->server);
$entity->setId($permission);
return $entity;
}
private function convertToInternalPermission(string $publicScope): string {
return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope;
}
}

View File

@@ -1,108 +0,0 @@
<?php
namespace api\components\OAuth2\Storage;
use api\components\OAuth2\Entities\AuthCodeEntity;
use api\components\OAuth2\Entities\SessionEntity;
use api\exceptions\ThisShouldNotHappenException;
use common\models\OauthSession;
use ErrorException;
use League\OAuth2\Server\Entity\AccessTokenEntity as OriginalAccessTokenEntity;
use League\OAuth2\Server\Entity\AuthCodeEntity as OriginalAuthCodeEntity;
use League\OAuth2\Server\Entity\ScopeEntity;
use League\OAuth2\Server\Entity\SessionEntity as OriginalSessionEntity;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\SessionInterface;
use yii\db\Exception;
class SessionStorage extends AbstractStorage implements SessionInterface {
/**
* @param string $sessionId
* @return SessionEntity|null
*/
public function getById($sessionId): ?SessionEntity {
$session = $this->getSessionModel($sessionId);
if ($session === null) {
return null;
}
return $this->hydrate($session);
}
public function getByAccessToken(OriginalAccessTokenEntity $accessToken) {
throw new ErrorException('This method is not implemented and should not be used');
}
public function getByAuthCode(OriginalAuthCodeEntity $authCode) {
if (!$authCode instanceof AuthCodeEntity) {
throw new ErrorException('This module assumes that $authCode typeof ' . AuthCodeEntity::class);
}
return $this->getById($authCode->getSessionId());
}
public function getScopes(OriginalSessionEntity $entity) {
$session = $this->getSessionModel($entity->getId());
if ($session === null) {
return [];
}
$result = [];
foreach ($session->getScopes() as $scope) {
if ($this->server->getScopeStorage()->get($scope) !== null) {
$result[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]);
}
}
return $result;
}
public function create($ownerType, $ownerId, $clientId, $clientRedirectUri = null) {
$sessionId = OauthSession::find()
->select('id')
->andWhere([
'client_id' => $clientId,
'owner_type' => $ownerType,
'owner_id' => (string)$ownerId, // Casts as a string to make the indexes work, because the varchar field
])->scalar();
if ($sessionId === false) {
$model = new OauthSession();
$model->client_id = $clientId;
$model->owner_type = $ownerType;
$model->owner_id = $ownerId;
$model->client_redirect_uri = $clientRedirectUri;
if (!$model->save()) {
throw new Exception('Cannot save ' . OauthSession::class . ' model.');
}
$sessionId = $model->id;
}
return $sessionId;
}
public function associateScope(OriginalSessionEntity $sessionEntity, ScopeEntity $scopeEntity): void {
$session = $this->getSessionModel($sessionEntity->getId());
if ($session === null) {
throw new ThisShouldNotHappenException('Cannot find oauth session');
}
$session->getScopes()->add($scopeEntity->getId());
}
private function getSessionModel(string $sessionId): ?OauthSession {
return OauthSession::findOne(['id' => $sessionId]);
}
private function hydrate(OauthSession $sessionModel): SessionEntity {
$entity = new SessionEntity($this->server);
$entity->setId($sessionModel->id);
$entity->setClientId($sessionModel->client_id);
$entity->setOwner($sessionModel->owner_type, $sessionModel->owner_id);
return $entity;
}
}

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Utils;
class Scopes {
/**
* In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes
* list, while by OAuth2 standard it they should be separated by a space. Shit happens :)
* So override scopes validation function to reformat passed value.
*
* @param string|array $scopes
* @return string
*/
public static function format($scopes): string {
if ($scopes === null) {
return '';
}
if (is_array($scopes)) {
return implode(' ', $scopes);
}
return str_replace(',', ' ', $scopes);
}
}

View File

@@ -8,6 +8,7 @@ use Exception;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Token;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webmozart\Assert\Assert;
use yii\base\Component as BaseComponent;
@@ -17,6 +18,10 @@ class Component extends BaseComponent {
/**
* @var string
* @deprecated In earlier versions of the application, JWT were signed by a synchronous encryption algorithm.
* Now asynchronous encryption is used instead, and this logic is saved for a transitional period.
* I think it can be safely removed, but I'll not do it yet, because at the time of writing the comment
* there were enough changes in the code already.
*/
public $hmacKey;
@@ -35,6 +40,11 @@ class Component extends BaseComponent {
*/
public $privateKeyPass;
/**
* @var string
*/
public $encryptionKey;
/**
* @var AlgorithmsManager|null
*/
@@ -45,19 +55,22 @@ class Component extends BaseComponent {
Assert::notEmpty($this->hmacKey, 'hmacKey must be set');
Assert::notEmpty($this->privateKeyPath, 'privateKeyPath must be set');
Assert::notEmpty($this->publicKeyPath, 'publicKeyPath must be set');
Assert::notEmpty($this->encryptionKey, 'encryptionKey must be set');
}
public function create(array $payloads = [], array $headers = []): Token {
$now = Carbon::now();
$builder = (new Builder())
->issuedAt($now->getTimestamp())
->expiresAt($now->addHour()->getTimestamp());
$builder = (new Builder())->issuedAt($now->getTimestamp());
if (isset($payloads['exp'])) {
$builder->expiresAt($payloads['exp']);
}
foreach ($payloads as $claim => $value) {
$builder->withClaim($claim, $value);
$builder->withClaim($claim, $this->prepareValue($value));
}
foreach ($headers as $claim => $value) {
$builder->withHeader($claim, $value);
$builder->withHeader($claim, $this->prepareValue($value));
}
/** @noinspection PhpUnhandledExceptionInspection */
@@ -85,6 +98,28 @@ class Component extends BaseComponent {
}
}
public function encryptValue(string $rawValue): string {
/** @noinspection PhpUnhandledExceptionInspection */
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = Base64UrlSafe::encodeUnpadded($nonce . sodium_crypto_secretbox($rawValue, $nonce, $this->encryptionKey));
sodium_memzero($rawValue);
return $cipher;
}
public function decryptValue(string $encryptedValue): string {
$decoded = Base64UrlSafe::decode($encryptedValue);
Assert::true(mb_strlen($decoded, '8bit') >= (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES));
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
$cipherText = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
$rawValue = sodium_crypto_secretbox_open($cipherText, $nonce, $this->encryptionKey);
Assert::true($rawValue !== false);
sodium_memzero($cipherText);
return $rawValue;
}
private function getAlgorithmManager(): AlgorithmsManager {
if ($this->algorithmManager === null) {
$this->algorithmManager = new AlgorithmsManager([
@@ -100,4 +135,12 @@ class Component extends BaseComponent {
return $this->algorithmManager;
}
private function prepareValue($value) {
if ($value instanceof EncryptedValue) {
return $this->encryptValue($value->getValue());
}
return $value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace api\components\Tokens;
class EncryptedValue {
/**
* @var string
*/
private $value;
public function __construct(string $value) {
$this->value = $value;
}
public function getValue(): string {
return $this->value;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace api\components\Tokens;
use Lcobucci\JWT\Token;
use Yii;
class TokenReader {
/**
* @var Token
*/
private $token;
public function __construct(Token $token) {
$this->token = $token;
}
public function getAccountId(): ?int {
$sub = $this->token->getClaim('sub', false);
if ($sub === false) {
return null;
}
if (mb_strpos((string)$sub, TokensFactory::SUB_ACCOUNT_PREFIX) !== 0) {
return null;
}
return (int)mb_substr($sub, mb_strlen(TokensFactory::SUB_ACCOUNT_PREFIX));
}
public function getClientId(): ?string {
$aud = $this->token->getClaim('aud', false);
if ($aud === false) {
return null;
}
if (mb_strpos((string)$aud, TokensFactory::AUD_CLIENT_PREFIX) !== 0) {
return null;
}
return mb_substr($aud, mb_strlen(TokensFactory::AUD_CLIENT_PREFIX));
}
public function getScopes(): ?array {
$scopes = $this->token->getClaim('ely-scopes', false);
if ($scopes === false) {
return null;
}
return explode(',', $scopes);
}
public function getMinecraftClientToken(): ?string {
$encodedClientToken = $this->token->getClaim('ely-client-token', false);
if ($encodedClientToken === false) {
return null;
}
return Yii::$app->tokens->decryptValue($encodedClientToken);
}
}

View File

@@ -3,20 +3,28 @@ declare(strict_types=1);
namespace api\components\Tokens;
use api\rbac\Permissions as P;
use api\rbac\Roles as R;
use Carbon\Carbon;
use common\models\Account;
use common\models\AccountSession;
use DateTime;
use Lcobucci\JWT\Token;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use Yii;
use yii\base\Component;
class TokensFactory {
class TokensFactory extends Component {
public const SUB_ACCOUNT_PREFIX = 'ely|';
public const AUD_CLIENT_PREFIX = 'client|';
public static function createForAccount(Account $account, AccountSession $session = null): Token {
public function createForWebAccount(Account $account, AccountSession $session = null): Token {
$payloads = [
'ely-scopes' => 'accounts_web_user',
'sub' => self::SUB_ACCOUNT_PREFIX . $account->id,
'ely-scopes' => $this->prepareScopes([R::ACCOUNTS_WEB_USER]),
'sub' => $this->buildSub($account->id),
'exp' => Carbon::now()->addHour()->getTimestamp(),
];
if ($session === null) {
// If we don't remember a session, the token should live longer
@@ -29,4 +37,52 @@ class TokensFactory {
return Yii::$app->tokens->create($payloads);
}
public function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token {
$payloads = [
'aud' => $this->buildAud($accessToken->getClient()->getIdentifier()),
'ely-scopes' => $this->prepareScopes($accessToken->getScopes()),
];
if ($accessToken->getExpiryDateTime() > new DateTime()) {
$payloads['exp'] = $accessToken->getExpiryDateTime()->getTimestamp();
}
if ($accessToken->getUserIdentifier() !== null) {
$payloads['sub'] = $this->buildSub($accessToken->getUserIdentifier());
}
return Yii::$app->tokens->create($payloads);
}
public function createForMinecraftAccount(Account $account, string $clientToken): Token {
return Yii::$app->tokens->create([
'ely-scopes' => $this->prepareScopes([P::MINECRAFT_SERVER_SESSION]),
'ely-client-token' => new EncryptedValue($clientToken),
'sub' => $this->buildSub($account->id),
'exp' => Carbon::now()->addDays(2)->getTimestamp(),
]);
}
/**
* @param ScopeEntityInterface[]|string[] $scopes
*
* @return string
*/
private function prepareScopes(array $scopes): string {
return implode(',', array_map(function($scope): string { // TODO: replace to the space if it's possible
if ($scope instanceof ScopeEntityInterface) {
return $scope->getIdentifier();
}
return $scope;
}, $scopes));
}
private function buildSub(int $accountId): string {
return self::SUB_ACCOUNT_PREFIX . $accountId;
}
private function buildAud(string $clientId): string {
return self::AUD_CLIENT_PREFIX . $clientId;
}
}

View File

@@ -5,6 +5,8 @@ namespace api\components\User;
use common\models\Account;
use common\models\AccountSession;
use common\models\OauthClient;
use Webmozart\Assert\Assert;
use yii\web\User as YiiUserComponent;
/**
@@ -78,6 +80,15 @@ class Component extends YiiUserComponent {
}
if (!($mode & self::KEEP_MINECRAFT_SESSIONS)) {
/** @var \common\models\OauthSession|null $minecraftSession */
$minecraftSession = $account->getOauthSessions()
->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER])
->one();
if ($minecraftSession !== null) {
$minecraftSession->revoked_at = time();
Assert::true($minecraftSession->save());
}
foreach ($account->minecraftAccessKeys as $minecraftAccessKey) {
$minecraftAccessKey->delete();
}

View File

@@ -8,19 +8,24 @@ use yii\web\UnauthorizedHttpException;
class IdentityFactory {
/**
* @throws UnauthorizedHttpException
* @param string $token
* @param string $type
*
* @return IdentityInterface
* @throws UnauthorizedHttpException
*/
public static function findIdentityByAccessToken($token, $type = null): IdentityInterface {
if (empty($token)) {
throw new UnauthorizedHttpException('Incorrect token');
if (!empty($token)) {
if (mb_strlen($token) === 40) {
return LegacyOAuth2Identity::findIdentityByAccessToken($token, $type);
}
if (substr_count($token, '.') === 2) {
return JwtIdentity::findIdentityByAccessToken($token, $type);
}
}
if (substr_count($token, '.') === 2) {
return JwtIdentity::findIdentityByAccessToken($token, $type);
}
return OAuth2Identity::findIdentityByAccessToken($token, $type);
throw new UnauthorizedHttpException('Incorrect token');
}
}

View File

@@ -3,13 +3,14 @@ declare(strict_types=1);
namespace api\components\User;
use api\components\Tokens\TokensFactory;
use api\components\Tokens\TokenReader;
use Carbon\Carbon;
use common\models\Account;
use common\models\OauthClient;
use common\models\OauthSession;
use Exception;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\ValidationData;
use Webmozart\Assert\Assert;
use Yii;
use yii\base\NotSupportedException;
use yii\web\UnauthorizedHttpException;
@@ -21,6 +22,11 @@ class JwtIdentity implements IdentityInterface {
*/
private $token;
/**
* @var TokenReader|null
*/
private $reader;
private function __construct(Token $token) {
$this->token = $token;
}
@@ -46,9 +52,21 @@ class JwtIdentity implements IdentityInterface {
throw new UnauthorizedHttpException('Incorrect token');
}
$sub = $token->getClaim('sub', false);
if ($sub !== false && strpos((string)$sub, TokensFactory::SUB_ACCOUNT_PREFIX) !== 0) {
throw new UnauthorizedHttpException('Incorrect token');
$tokenReader = new TokenReader($token);
$accountId = $tokenReader->getAccountId();
if ($accountId !== null) {
$iat = $token->getClaim('iat');
if ($tokenReader->getMinecraftClientToken() !== null
&& self::isRevoked($accountId, OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, $iat)
) {
throw new UnauthorizedHttpException('Token has been revoked');
}
if ($tokenReader->getClientId() !== null
&& self::isRevoked($accountId, $tokenReader->getClientId(), $iat)
) {
throw new UnauthorizedHttpException('Token has been revoked');
}
}
return new self($token);
@@ -59,24 +77,11 @@ class JwtIdentity implements IdentityInterface {
}
public function getAccount(): ?Account {
$subject = $this->token->getClaim('sub', false);
if ($subject === false) {
return null;
}
Assert::startsWith($subject, TokensFactory::SUB_ACCOUNT_PREFIX);
$accountId = (int)mb_substr($subject, mb_strlen(TokensFactory::SUB_ACCOUNT_PREFIX));
return Account::findOne(['id' => $accountId]);
return Account::findOne(['id' => $this->getReader()->getAccountId()]);
}
public function getAssignedPermissions(): array {
$scopesClaim = $this->token->getClaim('ely-scopes', false);
if ($scopesClaim === false) {
return [];
}
return explode(',', $scopesClaim);
return $this->getReader()->getScopes() ?? [];
}
public function getId(): string {
@@ -96,6 +101,19 @@ class JwtIdentity implements IdentityInterface {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
private static function isRevoked(int $accountId, string $clientId, int $iat): bool {
$session = OauthSession::findOne(['account_id' => $accountId, 'client_id' => $clientId]);
return $session !== null && $session->revoked_at !== null && $session->revoked_at > $iat;
}
// @codeCoverageIgnoreEnd
private function getReader(): TokenReader {
if ($this->reader === null) {
$this->reader = new TokenReader($this->token);
}
return $this->reader;
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace api\components\User;
use common\models\Account;
use common\models\OauthSession;
use Exception;
use Yii;
use yii\base\NotSupportedException;
use yii\web\UnauthorizedHttpException;
class LegacyOAuth2Identity implements IdentityInterface {
/**
* @var string
*/
private $accessToken;
/**
* @var string
*/
private $sessionId;
/**
* @var string[]
*/
private $scopes;
/**
* @var OauthSession|null
*/
private $session = false;
private function __construct(string $accessToken, int $sessionId, array $scopes) {
$this->accessToken = $accessToken;
$this->sessionId = $sessionId;
$this->scopes = $scopes;
}
/**
* @inheritdoc
* @throws UnauthorizedHttpException
* @return IdentityInterface
*/
public static function findIdentityByAccessToken($token, $type = null): IdentityInterface {
$tokenParams = self::findRecordOnLegacyStorage($token);
if ($tokenParams === null) {
throw new UnauthorizedHttpException('Incorrect token');
}
if ($tokenParams['expire_time'] < time()) {
throw new UnauthorizedHttpException('Token expired');
}
return new static($token, $tokenParams['session_id'], $tokenParams['scopes']);
}
public function getAccount(): ?Account {
$session = $this->getSession();
if ($session === null) {
return null;
}
return $session->account;
}
/**
* @return string[]
*/
public function getAssignedPermissions(): array {
return $this->scopes;
}
public function getId(): string {
return $this->accessToken;
}
// @codeCoverageIgnoreStart
public function getAuthKey() {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public function validateAuthKey($authKey) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public static function findIdentity($id) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
// @codeCoverageIgnoreEnd
private static function findRecordOnLegacyStorage(string $accessToken): ?array {
$record = Yii::$app->redis->get("oauth:access:tokens:{$accessToken}");
if ($record === null) {
return null;
}
try {
$data = json_decode($record, true, 512, JSON_THROW_ON_ERROR);
} catch (Exception $e) {
return null;
}
$data['scopes'] = (array)Yii::$app->redis->smembers("oauth:access:tokens:{$accessToken}:scopes");
return $data;
}
private function getSession(): ?OauthSession {
if ($this->session === false) {
$this->session = OauthSession::findOne(['id' => $this->sessionId]);
}
return $this->session;
}
}

View File

@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace api\components\User;
use api\components\OAuth2\Entities\AccessTokenEntity;
use common\models\Account;
use common\models\OauthSession;
use Yii;
use yii\base\NotSupportedException;
use yii\web\UnauthorizedHttpException;
class OAuth2Identity implements IdentityInterface {
/**
* @var AccessTokenEntity
*/
private $_accessToken;
private function __construct(AccessTokenEntity $accessToken) {
$this->_accessToken = $accessToken;
}
/**
* @inheritdoc
* @throws UnauthorizedHttpException
* @return IdentityInterface
*/
public static function findIdentityByAccessToken($token, $type = null): IdentityInterface {
/** @var AccessTokenEntity|null $model */
$model = Yii::$app->oauth->getAccessTokenStorage()->get($token);
if ($model === null) {
throw new UnauthorizedHttpException('Incorrect token');
}
if ($model->isExpired()) {
throw new UnauthorizedHttpException('Token expired');
}
return new static($model);
}
public function getAccount(): ?Account {
$session = $this->getSession();
if ($session === null) {
return null;
}
return $this->getSession()->account;
}
/**
* @return string[]
*/
public function getAssignedPermissions(): array {
return array_keys($this->_accessToken->getScopes());
}
public function getId(): string {
return $this->_accessToken->getId();
}
// @codeCoverageIgnoreStart
public function getAuthKey() {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public function validateAuthKey($authKey) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public static function findIdentity($id) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
// @codeCoverageIgnoreEnd
private function getSession(): ?OauthSession {
return OauthSession::findOne(['id' => $this->_accessToken->getSessionId()]);
}
}

View File

@@ -6,6 +6,7 @@ return [
'privateKeyPath' => codecept_data_dir('certs/private.pem'),
'privateKeyPass' => null,
'publicKeyPath' => codecept_data_dir('certs/public.pem'),
'encryptionKey' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
],
'reCaptcha' => [
'public' => 'public-key',

View File

@@ -7,16 +7,31 @@ return [
'params' => [
'authserverHost' => getenv('AUTHSERVER_HOST') ?: 'authserver.ely.by',
],
'modules' => [
'authserver' => api\modules\authserver\Module::class,
'session' => api\modules\session\Module::class,
'mojang' => api\modules\mojang\Module::class,
'internal' => api\modules\internal\Module::class,
'accounts' => api\modules\accounts\Module::class,
'oauth' => api\modules\oauth\Module::class,
],
'components' => [
'user' => [
'class' => api\components\User\Component::class,
],
'oauth' => [
'class' => api\components\OAuth2\Component::class,
],
'tokens' => [
'class' => api\components\Tokens\Component::class,
'hmacKey' => getenv('JWT_USER_SECRET'),
'privateKeyPath' => getenv('JWT_PRIVATE_KEY_PATH') ?: __DIR__ . '/../../data/certs/private.pem',
'privateKeyPass' => getenv('JWT_PRIVATE_KEY_PASS') ?: null,
'publicKeyPath' => getenv('JWT_PUBLIC_KEY_PATH') ?: __DIR__ . '/../../data/certs/public.pem',
'encryptionKey' => getenv('JWT_ENCRYPTION_KEY'),
],
'tokensFactory' => [
'class' => api\components\Tokens\TokensFactory::class,
],
'log' => [
'traceLevel' => YII_DEBUG ? 3 : 0,
@@ -83,12 +98,4 @@ return [
'class' => api\components\ErrorHandler::class,
],
],
'modules' => [
'authserver' => api\modules\authserver\Module::class,
'session' => api\modules\session\Module::class,
'mojang' => api\modules\mojang\Module::class,
'internal' => api\modules\internal\Module::class,
'accounts' => api\modules\accounts\Module::class,
'oauth' => api\modules\oauth\Module::class,
],
];

View File

@@ -7,6 +7,8 @@ namespace api\exceptions;
* The exception can be used for cases where the outcome doesn't seem to be expected,
* but can theoretically happen. The goal is to capture these areas and refine the logic
* if such situations do occur.
*
* @deprecated use \Webmozart\Assert\Assert to ensure, that action has been successfully performed
*/
class ThisShouldNotHappenException extends Exception {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace api\models\authentication;
use api\aop\annotations\CollectModelMetrics;
use api\components\Tokens\TokensFactory;
use api\models\base\ApiForm;
use api\validators\EmailActivationKeyValidator;
use common\models\Account;
@@ -48,7 +47,7 @@ class ConfirmEmailForm extends ApiForm {
$session->generateRefreshToken();
Assert::true($session->save(), 'Cannot save account session model');
$token = TokensFactory::createForAccount($account, $session);
$token = Yii::$app->tokensFactory->createForWebAccount($account, $session);
$transaction->commit();

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace api\models\authentication;
use api\aop\annotations\CollectModelMetrics;
use api\components\Tokens\TokensFactory;
use api\models\base\ApiForm;
use api\traits\AccountFinder;
use api\validators\TotpValidator;
@@ -121,7 +120,7 @@ class LoginForm extends ApiForm {
Assert::true($session->save(), 'Cannot save account session model');
}
$token = TokensFactory::createForAccount($account, $session);
$token = Yii::$app->tokensFactory->createForWebAccount($account, $session);
$transaction->commit();

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace api\models\authentication;
use api\aop\annotations\CollectModelMetrics;
use api\components\Tokens\TokensFactory;
use api\models\base\ApiForm;
use api\validators\EmailActivationKeyValidator;
use common\helpers\Error as E;
@@ -56,7 +55,7 @@ class RecoverPasswordForm extends ApiForm {
Assert::true($account->save(), 'Unable activate user account.');
$token = TokensFactory::createForAccount($account);
$token = Yii::$app->tokensFactory->createForWebAccount($account);
$transaction->commit();

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace api\models\authentication;
use api\aop\annotations\CollectModelMetrics;
use api\components\Tokens\TokensFactory;
use api\models\base\ApiForm;
use common\helpers\Error as E;
use common\models\AccountSession;
@@ -47,7 +46,7 @@ class RefreshTokenForm extends ApiForm {
$transaction = Yii::$app->db->beginTransaction();
$token = TokensFactory::createForAccount($account, $session);
$token = Yii::$app->tokensFactory->createForWebAccount($account, $session);
$session->setIp(Yii::$app->request->userIP);
$session->touch('last_refreshed_at');

View File

@@ -1,11 +1,13 @@
<?php
declare(strict_types=1);
namespace api\models\base;
use yii\base\Model;
class ApiForm extends Model {
public function formName() {
public function formName(): string {
return '';
}

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\controllers;
use api\controllers\Controller;
@@ -14,7 +16,7 @@ class AuthenticationController extends Controller {
return $behaviors;
}
public function verbs() {
public function verbs(): array {
return [
'authenticate' => ['POST'],
'refresh' => ['POST'],
@@ -24,21 +26,35 @@ class AuthenticationController extends Controller {
];
}
public function actionAuthenticate() {
/**
* @return array
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function actionAuthenticate(): array {
$model = new models\AuthenticationForm();
$model->load(Yii::$app->request->post());
return $model->authenticate()->getResponseData(true);
}
public function actionRefresh() {
/**
* @return array
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function actionRefresh(): array {
$model = new models\RefreshTokenForm();
$model->load(Yii::$app->request->post());
return $model->refresh()->getResponseData(false);
}
public function actionValidate() {
/**
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function actionValidate(): void {
$model = new models\ValidateForm();
$model->load(Yii::$app->request->post());
$model->validateToken();
@@ -46,7 +62,11 @@ class AuthenticationController extends Controller {
// In case of an error, an exception is thrown which will be processed by ErrorHandler
}
public function actionSignout() {
/**
* @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());
$model->signout();
@@ -54,7 +74,10 @@ class AuthenticationController extends Controller {
// In case of an error, an exception is thrown which will be processed by ErrorHandler
}
public function actionInvalidate() {
/**
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function actionInvalidate(): void {
$model = new models\InvalidateForm();
$model->load(Yii::$app->request->post());
$model->invalidateToken();

View File

@@ -1,39 +1,47 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\models;
use common\models\MinecraftAccessKey;
use common\models\Account;
use Lcobucci\JWT\Token;
class AuthenticateData {
/**
* @var MinecraftAccessKey
* @var Account
*/
private $minecraftAccessKey;
private $account;
public function __construct(MinecraftAccessKey $minecraftAccessKey) {
$this->minecraftAccessKey = $minecraftAccessKey;
}
/**
* @var Token
*/
private $accessToken;
public function getMinecraftAccessKey(): MinecraftAccessKey {
return $this->minecraftAccessKey;
/**
* @var string
*/
private $clientToken;
public function __construct(Account $account, string $accessToken, string $clientToken) {
$this->account = $account;
$this->accessToken = $accessToken;
$this->clientToken = $clientToken;
}
public function getResponseData(bool $includeAvailableProfiles = false): array {
$accessKey = $this->minecraftAccessKey;
$account = $accessKey->account;
$result = [
'accessToken' => $accessKey->access_token,
'clientToken' => $accessKey->client_token,
'accessToken' => $this->accessToken,
'clientToken' => $this->clientToken,
'selectedProfile' => [
'id' => $account->uuid,
'name' => $account->username,
'id' => $this->account->uuid,
'name' => $this->account->username,
'legacy' => false,
],
];
if ($includeAvailableProfiles) {
// The Moiangs themselves haven't come up with anything yet with these availableProfiles
// The Mojang themselves haven't come up with anything yet with these availableProfiles
$availableProfiles[0] = $result['selectedProfile'];
$result['availableProfiles'] = $availableProfiles;
}

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\models;
use api\models\authentication\LoginForm;
@@ -7,19 +9,32 @@ 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\models\Account;
use common\models\MinecraftAccessKey;
use common\models\OauthClient;
use common\models\OauthSession;
use Webmozart\Assert\Assert;
use Yii;
class AuthenticationForm extends ApiForm {
/**
* @var string
*/
public $username;
/**
* @var string
*/
public $password;
/**
* @var string
*/
public $clientToken;
public function rules() {
public function rules(): array {
return [
[['username', 'password', 'clientToken'], RequiredValidator::class],
[['clientToken'], ClientTokenValidator::class],
@@ -28,14 +43,16 @@ class AuthenticationForm extends ApiForm {
/**
* @return AuthenticateData
* @throws \api\modules\authserver\exceptions\AuthserverException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
*/
public function authenticate() {
public function authenticate(): AuthenticateData {
// This validating method will throw an exception in case when validation will not pass successfully
$this->validate();
Authserver::info("Trying to authenticate user by login = '{$this->username}'.");
$loginForm = $this->createLoginForm();
$loginForm = new LoginForm();
$loginForm->login = $this->username;
$loginForm->password = $this->password;
if (!$loginForm->validate()) {
@@ -68,37 +85,25 @@ class AuthenticationForm extends ApiForm {
throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password.");
}
/** @var Account $account */
$account = $loginForm->getAccount();
$accessTokenModel = $this->createMinecraftAccessToken($account);
$dataModel = new AuthenticateData($accessTokenModel);
$token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken);
$dataModel = new AuthenticateData($account, (string)$token, $this->clientToken);
/** @var OauthSession|null $minecraftOauthSession */
$hasMinecraftOauthSession = $account->getOauthSessions()
->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER])
->exists();
if ($hasMinecraftOauthSession === false) {
$minecraftOauthSession = new OauthSession();
$minecraftOauthSession->account_id = $account->id;
$minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER;
$minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION];
Assert::true($minecraftOauthSession->save());
}
Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in.");
return $dataModel;
}
protected function createMinecraftAccessToken(Account $account): MinecraftAccessKey {
/** @var MinecraftAccessKey|null $accessTokenModel */
$accessTokenModel = MinecraftAccessKey::findOne([
'account_id' => $account->id,
'client_token' => $this->clientToken,
]);
if ($accessTokenModel === null) {
$accessTokenModel = new MinecraftAccessKey();
$accessTokenModel->client_token = $this->clientToken;
$accessTokenModel->account_id = $account->id;
$accessTokenModel->insert();
} else {
$accessTokenModel->refreshPrimaryKeyValue();
$accessTokenModel->update();
}
return $accessTokenModel;
}
protected function createLoginForm(): LoginForm {
return new LoginForm();
}
}

View File

@@ -1,17 +1,24 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\models;
use api\models\base\ApiForm;
use api\modules\authserver\validators\RequiredValidator;
use common\models\MinecraftAccessKey;
class InvalidateForm extends ApiForm {
/**
* @var string
*/
public $accessToken;
/**
* @var string
*/
public $clientToken;
public function rules() {
public function rules(): array {
return [
[['accessToken', 'clientToken'], RequiredValidator::class],
];
@@ -19,19 +26,12 @@ class InvalidateForm extends ApiForm {
/**
* @return bool
* @throws \api\modules\authserver\exceptions\AuthserverException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function invalidateToken(): bool {
$this->validate();
$token = MinecraftAccessKey::findOne([
'access_token' => $this->accessToken,
'client_token' => $this->clientToken,
]);
if ($token !== null) {
$token->delete();
}
// We're can't invalidate access token because it's not stored in our database
return true;
}

View File

@@ -1,48 +1,90 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\models;
use api\components\Tokens\TokenReader;
use api\models\base\ApiForm;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\validators\AccessTokenValidator;
use api\modules\authserver\validators\RequiredValidator;
use common\models\Account;
use common\models\MinecraftAccessKey;
use common\models\OauthClient;
use common\models\OauthSession;
use Webmozart\Assert\Assert;
use Yii;
class RefreshTokenForm extends ApiForm {
/**
* @var string
*/
public $accessToken;
/**
* @var string
*/
public $clientToken;
public function rules() {
public function rules(): array {
return [
[['accessToken', 'clientToken'], RequiredValidator::class],
[['accessToken'], AccessTokenValidator::class, 'verifyExpiration' => false],
];
}
/**
* @return AuthenticateData
* @throws \api\modules\authserver\exceptions\AuthserverException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
*/
public function refresh() {
public function refresh(): AuthenticateData {
$this->validate();
$account = null;
if (mb_strlen($this->accessToken) === 36) {
/** @var MinecraftAccessKey $token */
$token = MinecraftAccessKey::findOne([
'access_token' => $this->accessToken,
'client_token' => $this->clientToken,
]);
if ($token !== null) {
$account = $token->account;
}
} else {
$token = Yii::$app->tokens->parse($this->accessToken);
$tokenReader = new TokenReader($token);
if ($tokenReader->getMinecraftClientToken() !== $this->clientToken) {
throw new ForbiddenOperationException('Invalid token.');
}
/** @var MinecraftAccessKey|null $accessToken */
$accessToken = MinecraftAccessKey::findOne([
'access_token' => $this->accessToken,
'client_token' => $this->clientToken,
]);
if ($accessToken === null) {
$account = Account::findOne(['id' => $tokenReader->getAccountId()]);
}
if ($account === null) {
throw new ForbiddenOperationException('Invalid token.');
}
if ($accessToken->account->status === Account::STATUS_BANNED) {
if ($account->status === Account::STATUS_BANNED) {
throw new ForbiddenOperationException('This account has been suspended.');
}
$accessToken->refreshPrimaryKeyValue();
$accessToken->update();
$token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken);
return new AuthenticateData($accessToken);
// TODO: This behavior duplicates with the AuthenticationForm. Need to find a way to avoid duplication.
/** @var OauthSession|null $minecraftOauthSession */
$hasMinecraftOauthSession = $account->getOauthSessions()
->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER])
->exists();
if ($hasMinecraftOauthSession === false) {
$minecraftOauthSession = new OauthSession();
$minecraftOauthSession->account_id = $account->id;
$minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER;
$minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION];
Assert::true($minecraftOauthSession->save());
}
return new AuthenticateData($account, (string)$token, $this->clientToken);
}
}

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\models;
use api\models\authentication\LoginForm;
@@ -6,21 +8,30 @@ use api\models\base\ApiForm;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\validators\RequiredValidator;
use common\helpers\Error as E;
use common\models\MinecraftAccessKey;
use Yii;
class SignoutForm extends ApiForm {
/**
* @var string
*/
public $username;
/**
* @var string
*/
public $password;
public function rules() {
public function rules(): array {
return [
[['username', 'password'], RequiredValidator::class],
];
}
/**
* @return bool
* @throws ForbiddenOperationException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function signout(): bool {
$this->validate();
@@ -44,16 +55,7 @@ class SignoutForm extends ApiForm {
throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password.");
}
$account = $loginForm->getAccount();
/** @noinspection SqlResolve */
Yii::$app->db->createCommand('
DELETE
FROM ' . MinecraftAccessKey::tableName() . '
WHERE account_id = :userId
', [
'userId' => $account->id,
])->execute();
// We're unable to invalidate access tokens because they aren't stored in our database
return true;
}

View File

@@ -1,35 +1,33 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\models;
use api\models\base\ApiForm;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\validators\AccessTokenValidator;
use api\modules\authserver\validators\RequiredValidator;
use common\models\MinecraftAccessKey;
class ValidateForm extends ApiForm {
/**
* @var string
*/
public $accessToken;
public function rules() {
public function rules(): array {
return [
[['accessToken'], RequiredValidator::class],
[['accessToken'], AccessTokenValidator::class],
];
}
/**
* @return bool
* @throws \api\modules\authserver\exceptions\ForbiddenOperationException
* @throws \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function validateToken(): bool {
$this->validate();
/** @var MinecraftAccessKey|null $result */
$result = MinecraftAccessKey::findOne($this->accessToken);
if ($result === null) {
throw new ForbiddenOperationException('Invalid token.');
}
if ($result->isExpired()) {
throw new ForbiddenOperationException('Token expired.');
}
return true;
return $this->validate();
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\validators;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use Carbon\Carbon;
use common\models\MinecraftAccessKey;
use Exception;
use Lcobucci\JWT\ValidationData;
use Yii;
use yii\validators\Validator;
class AccessTokenValidator extends Validator {
/**
* @var bool
*/
public $verifyExpiration = true;
/**
* @param string $value
*
* @return array|null
* @throws ForbiddenOperationException
*/
protected function validateValue($value): ?array {
if (mb_strlen($value) === 36) {
return $this->validateLegacyToken($value);
}
try {
$token = Yii::$app->tokens->parse($value);
} catch (Exception $e) {
throw new ForbiddenOperationException('Invalid token.');
}
if (!Yii::$app->tokens->verify($token)) {
throw new ForbiddenOperationException('Invalid token.');
}
if ($this->verifyExpiration && !$token->validate(new ValidationData(Carbon::now()->getTimestamp()))) {
throw new ForbiddenOperationException('Token expired.');
}
return null;
}
/**
* @param string $value
*
* @return array|null
* @throws ForbiddenOperationException
*/
private function validateLegacyToken(string $value): ?array {
/** @var MinecraftAccessKey|null $result */
$result = MinecraftAccessKey::findOne(['access_token' => $value]);
if ($result === null) {
throw new ForbiddenOperationException('Invalid token.');
}
if ($this->verifyExpiration && $result->isExpired()) {
throw new ForbiddenOperationException('Token expired.');
}
return null;
}
}

View File

@@ -1,20 +1,23 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\validators;
use api\modules\authserver\exceptions\IllegalArgumentException;
use yii\validators\Validator;
/**
* The maximum length of clientToken for our database is 255.
* If the token is longer, we do not accept the passed token at all.
*/
class ClientTokenValidator extends \yii\validators\RequiredValidator {
class ClientTokenValidator extends Validator {
/**
* @param string $value
* @return null
* @throws \api\modules\authserver\exceptions\AuthserverException
*/
protected function validateValue($value) {
protected function validateValue($value): ?array {
if (mb_strlen($value) > 255) {
throw new IllegalArgumentException('clientToken is too long.');
}

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\modules\authserver\validators;
use api\modules\authserver\exceptions\IllegalArgumentException;
@@ -14,7 +16,7 @@ class RequiredValidator extends \yii\validators\RequiredValidator {
* @return null
* @throws \api\modules\authserver\exceptions\AuthserverException
*/
protected function validateValue($value) {
protected function validateValue($value): ?array {
if (parent::validateValue($value) !== null) {
throw new IllegalArgumentException();
}

View File

@@ -1,9 +1,13 @@
<?php
declare(strict_types=1);
namespace api\modules\oauth\controllers;
use api\controllers\Controller;
use api\modules\oauth\models\OauthProcess;
use api\rbac\Permissions as P;
use GuzzleHttp\Psr7\ServerRequest;
use Psr\Http\Message\ServerRequestInterface;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
@@ -43,22 +47,23 @@ class AuthorizationController extends Controller {
}
public function actionValidate(): array {
return $this->createOauthProcess()->validate();
return $this->createOauthProcess()->validate($this->getServerRequest());
}
public function actionComplete(): array {
return $this->createOauthProcess()->complete();
return $this->createOauthProcess()->complete($this->getServerRequest());
}
public function actionToken(): array {
return $this->createOauthProcess()->getToken();
return $this->createOauthProcess()->getToken($this->getServerRequest());
}
private function createOauthProcess(): OauthProcess {
$server = Yii::$app->oauth->authServer;
$server->setRequest(null); // Enforce request recreation (test environment bug)
return new OauthProcess(Yii::$app->oauth->getAuthServer());
}
return new OauthProcess($server);
private function getServerRequest(): ServerRequestInterface {
return ServerRequest::fromGlobals();
}
}

View File

@@ -1,19 +1,22 @@
<?php
declare(strict_types=1);
namespace api\modules\oauth\models;
use api\components\OAuth2\Exception\AcceptRequiredException;
use api\components\OAuth2\Exception\AccessDeniedException;
use api\components\OAuth2\Grants\AuthCodeGrant;
use api\components\OAuth2\Grants\AuthorizeParams;
use api\components\OAuth2\Entities\UserEntity;
use api\components\OAuth2\Events\RequestedRefreshToken;
use api\rbac\Permissions as P;
use common\models\Account;
use common\models\OauthClient;
use common\models\OauthSession;
use GuzzleHttp\Psr7\Response;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\InvalidGrantException;
use League\OAuth2\Server\Exception\OAuthException;
use League\OAuth2\Server\Grant\GrantTypeInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Psr\Http\Message\ServerRequestInterface;
use Webmozart\Assert\Assert;
use Yii;
use yii\helpers\ArrayHelper;
class OauthProcess {
@@ -46,21 +49,18 @@ class OauthProcess {
*
* In addition, you can pass the description value to override the application's description.
*
* @param ServerRequestInterface $request
* @return array
*/
public function validate(): array {
public function validate(ServerRequestInterface $request): array {
try {
$authParams = $this->getAuthorizationCodeGrant()->checkAuthorizeParams();
$client = $authParams->getClient();
$authRequest = $this->server->validateAuthorizationRequest($request);
$client = $authRequest->getClient();
/** @var OauthClient $clientModel */
$clientModel = $this->findClient($client->getId());
$response = $this->buildSuccessResponse(
Yii::$app->request->getQueryParams(),
$clientModel,
$authParams->getScopes()
);
} catch (OAuthException $e) {
$response = $this->buildErrorResponse($e);
$clientModel = $this->findClient($client->getIdentifier());
$response = $this->buildSuccessResponse($request, $clientModel, $authRequest->getScopes());
} catch (OAuthServerException $e) {
$response = $this->buildCompleteErrorResponse($e);
}
return $response;
@@ -83,45 +83,53 @@ class OauthProcess {
* If the field is present, it will be interpreted as any value resulting in false positives.
* Otherwise, the value will be interpreted as "true".
*
* @param ServerRequestInterface $request
* @return array
*/
public function complete(): array {
public function complete(ServerRequestInterface $request): array {
try {
Yii::$app->statsd->inc('oauth.complete.attempt');
$grant = $this->getAuthorizationCodeGrant();
$authParams = $grant->checkAuthorizeParams();
$authRequest = $this->server->validateAuthorizationRequest($request);
/** @var Account $account */
$account = Yii::$app->user->identity->getAccount();
/** @var \common\models\OauthClient $clientModel */
$clientModel = $this->findClient($authParams->getClient()->getId());
/** @var OauthClient $client */
$client = $this->findClient($authRequest->getClient()->getIdentifier());
if (!$this->canAutoApprove($account, $clientModel, $authParams)) {
$approved = $this->canAutoApprove($account, $client, $authRequest);
if (!$approved) {
Yii::$app->statsd->inc('oauth.complete.approve_required');
$isAccept = Yii::$app->request->post('accept');
if ($isAccept === null) {
throw new AcceptRequiredException();
$acceptParam = ((array)$request->getParsedBody())['accept'] ?? null;
if ($acceptParam === null) {
throw $this->createAcceptRequiredException();
}
if (!$isAccept) {
throw new AccessDeniedException($authParams->getRedirectUri());
$approved = in_array($acceptParam, [1, '1', true, 'true'], true);
if ($approved) {
$this->storeOauthSession($account, $client, $authRequest);
}
}
$redirectUri = $grant->newAuthorizeRequest('user', $account->id, $authParams);
$response = [
$authRequest->setUser(new UserEntity($account->id));
$authRequest->setAuthorizationApproved($approved);
$response = $this->server->completeAuthorizationRequest($authRequest, new Response(200));
$result = [
'success' => true,
'redirectUri' => $redirectUri,
'redirectUri' => $response->getHeaderLine('Location'),
];
Yii::$app->statsd->inc('oauth.complete.success');
} catch (OAuthException $e) {
if (!$e instanceof AcceptRequiredException) {
} catch (OAuthServerException $e) {
if ($e->getErrorType() === 'accept_required') {
Yii::$app->statsd->inc('oauth.complete.fail');
}
$response = $this->buildErrorResponse($e);
$result = $this->buildCompleteErrorResponse($e);
}
return $response;
return $result;
}
/**
@@ -143,30 +151,44 @@ class OauthProcess {
* grant_type,
* ]
*
* @param ServerRequestInterface $request
* @return array
*/
public function getToken(): array {
$grantType = Yii::$app->request->post('grant_type', 'null');
public function getToken(ServerRequestInterface $request): array {
$params = (array)$request->getParsedBody();
$clientId = $params['client_id'] ?? '';
$grantType = $params['grant_type'] ?? 'null';
try {
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt");
$response = $this->server->issueAccessToken();
$clientId = Yii::$app->request->post('client_id');
$shouldIssueRefreshToken = false;
$this->server->getEmitter()->addOneTimeListener(RequestedRefreshToken::class, function() use (&$shouldIssueRefreshToken) {
$shouldIssueRefreshToken = true;
});
$response = $this->server->respondToAccessTokenRequest($request, new Response(200));
/** @noinspection JsonEncodingApiUsageInspection at this point json error is not possible */
$result = json_decode((string)$response->getBody(), true);
if ($shouldIssueRefreshToken) {
// Set the refresh_token field to keep compatibility with the old clients,
// which will be broken in case when refresh_token field will be missing
$result['refresh_token'] = $result['access_token'];
}
Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}");
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success");
} catch (OAuthException $e) {
} catch (OAuthServerException $e) {
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail");
Yii::$app->response->statusCode = $e->httpStatusCode;
$response = [
'error' => $e->errorType,
'message' => $e->getMessage(),
];
Yii::$app->response->statusCode = $e->getHttpStatusCode();
$result = $this->buildIssueErrorResponse($e);
}
return $response;
return $result;
}
private function findClient(string $clientId): ?OauthClient {
return OauthClient::findOne($clientId);
return OauthClient::findOne(['id' => $clientId]);
}
/**
@@ -175,39 +197,48 @@ class OauthProcess {
*
* @param Account $account
* @param OauthClient $client
* @param AuthorizeParams $oauthParams
* @param AuthorizationRequest $request
*
* @return bool
*/
private function canAutoApprove(Account $account, OauthClient $client, AuthorizeParams $oauthParams): bool {
private function canAutoApprove(Account $account, OauthClient $client, AuthorizationRequest $request): bool {
if ($client->is_trusted) {
return true;
}
/** @var \common\models\OauthSession|null $session */
$session = $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one();
if ($session !== null) {
$existScopes = $session->getScopes()->members();
if (empty(array_diff(array_keys($oauthParams->getScopes()), $existScopes))) {
return true;
}
$session = $this->findOauthSession($account, $client);
if ($session === null) {
return false;
}
return false;
return empty(array_diff($this->getScopesList($request), $session->getScopes()));
}
private function storeOauthSession(Account $account, OauthClient $client, AuthorizationRequest $request): void {
$session = $this->findOauthSession($account, $client);
if ($session === null) {
$session = new OauthSession();
$session->account_id = $account->id;
$session->client_id = $client->id;
}
$session->scopes = array_unique(array_merge($session->getScopes(), $this->getScopesList($request)));
Assert::true($session->save());
}
/**
* @param array $queryParams
* @param ServerRequestInterface $request
* @param OauthClient $client
* @param \api\components\OAuth2\Entities\ScopeEntity[] $scopes
* @param ScopeEntityInterface[] $scopes
*
* @return array
*/
private function buildSuccessResponse(array $queryParams, OauthClient $client, array $scopes): array {
private function buildSuccessResponse(ServerRequestInterface $request, OauthClient $client, array $scopes): array {
return [
'success' => true,
// We return only those keys which are related to the OAuth2 standard parameters
'oAuth' => array_intersect_key($queryParams, array_flip([
'oAuth' => array_intersect_key($request->getQueryParams(), array_flip([
'client_id',
'redirect_uri',
'response_type',
@@ -217,55 +248,94 @@ class OauthProcess {
'client' => [
'id' => $client->id,
'name' => $client->name,
'description' => ArrayHelper::getValue($queryParams, 'description', $client->description),
'description' => $request->getQueryParams()['description'] ?? $client->description,
],
'session' => [
'scopes' => $this->fixScopesNames(array_keys($scopes)),
'scopes' => $this->buildScopesArray($scopes),
],
];
}
private function fixScopesNames(array $scopes): array {
foreach ($scopes as &$scope) {
if (isset(self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope])) {
$scope = self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope];
}
/**
* @param ScopeEntityInterface[] $scopes
* @return array
*/
private function buildScopesArray(array $scopes): array {
$result = [];
foreach ($scopes as $scope) {
$result[] = self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope->getIdentifier()] ?? $scope->getIdentifier();
}
return $scopes;
return $result;
}
private function buildErrorResponse(OAuthException $e): array {
private function buildCompleteErrorResponse(OAuthServerException $e): array {
$hint = $e->getPayload()['hint'] ?? '';
if (preg_match('/the `(\w+)` scope/', $hint, $matches)) {
$parameter = $matches[1];
}
$response = [
'success' => false,
'error' => $e->errorType,
'parameter' => $e->parameter,
'statusCode' => $e->httpStatusCode,
'error' => $e->getErrorType(),
'parameter' => $parameter ?? null,
'statusCode' => $e->getHttpStatusCode(),
];
if ($e->shouldRedirect()) {
if ($e->hasRedirect()) {
$response['redirectUri'] = $e->getRedirectUri();
}
if ($e->httpStatusCode !== 200) {
Yii::$app->response->setStatusCode($e->httpStatusCode);
if ($e->getHttpStatusCode() !== 200) {
Yii::$app->response->setStatusCode($e->getHttpStatusCode());
}
return $response;
}
private function getGrant(string $grantType = null): GrantTypeInterface {
return $this->server->getGrantType($grantType ?? Yii::$app->request->get('grant_type'));
}
private function getAuthorizationCodeGrant(): AuthCodeGrant {
/** @var GrantTypeInterface $grantType */
$grantType = $this->getGrant('authorization_code');
if (!$grantType instanceof AuthCodeGrant) {
throw new InvalidGrantException('authorization_code grant have invalid realisation');
/**
* Raw error messages aren't very informative for the end user, as they don't contain
* information about the parameter that caused the error.
* This method is intended to build a more understandable description.
*
* Part of the existing texts are the legacy from the previous implementation.
*
* @param OAuthServerException $e
* @return array
*/
private function buildIssueErrorResponse(OAuthServerException $e): array {
$errorType = $e->getErrorType();
$message = $e->getMessage();
$hint = $e->getHint();
switch ($hint) {
case 'Invalid redirect URI':
$errorType = 'invalid_client';
$message = 'Client authentication failed.';
break;
case 'Cannot decrypt the authorization code':
$message .= ' Check the "code" parameter.';
break;
}
return $grantType;
return [
'error' => $errorType,
'message' => $message,
];
}
private function createAcceptRequiredException(): OAuthServerException {
return new OAuthServerException('Client must accept authentication request.', 0, 'accept_required', 401);
}
private function getScopesList(AuthorizationRequest $request): array {
return array_map(function(ScopeEntityInterface $scope): string {
return $scope->getIdentifier();
}, $request->getScopes());
}
private function findOauthSession(Account $account, OauthClient $client): ?OauthSession {
/** @noinspection PhpIncompatibleReturnTypeInspection */
return $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one();
}
}

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\modules\session\controllers;
use api\controllers\Controller;
@@ -31,18 +33,23 @@ class SessionController extends Controller {
return $behaviors;
}
public function actionJoin() {
/**
* @return array
* @throws ForbiddenOperationException
* @throws IllegalArgumentException
*/
public function actionJoin(): array {
Yii::$app->response->format = Response::FORMAT_JSON;
$data = Yii::$app->request->post();
$protocol = new ModernJoin($data['accessToken'] ?? '', $data['selectedProfile'] ?? '', $data['serverId'] ?? '');
$joinForm = new JoinForm($protocol);
$joinForm->join();
$joinForm->join(); // will throw an exception in case of any error
return ['id' => 'OK'];
}
public function actionJoinLegacy() {
public function actionJoinLegacy(): string {
Yii::$app->response->format = Response::FORMAT_RAW;
$data = Yii::$app->request->get();
@@ -64,7 +71,12 @@ class SessionController extends Controller {
return 'OK';
}
public function actionHasJoined() {
/**
* @return array
* @throws ForbiddenOperationException
* @throws IllegalArgumentException
*/
public function actionHasJoined(): array {
Yii::$app->response->format = Response::FORMAT_JSON;
$data = Yii::$app->request->get();
@@ -76,7 +88,7 @@ class SessionController extends Controller {
return $textures->getMinecraftResponse();
}
public function actionHasJoinedLegacy() {
public function actionHasJoinedLegacy(): string {
Yii::$app->response->format = Response::FORMAT_RAW;
$data = Yii::$app->request->get();
@@ -95,7 +107,14 @@ class SessionController extends Controller {
return 'YES';
}
public function actionProfile($uuid) {
/**
* @param string $uuid
*
* @return array
* @throws ForbiddenOperationException
* @throws IllegalArgumentException
*/
public function actionProfile(string $uuid): array {
try {
$uuid = Uuid::fromString($uuid)->toString();
} catch (\InvalidArgumentException $e) {

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\modules\session\models;
use api\modules\session\exceptions\ForbiddenOperationException;
@@ -6,19 +8,27 @@ use api\modules\session\exceptions\IllegalArgumentException;
use api\modules\session\models\protocols\HasJoinedInterface;
use api\modules\session\Module as Session;
use common\models\Account;
use Webmozart\Assert\Assert;
use Yii;
use yii\base\ErrorException;
use yii\base\Model;
class HasJoinedForm extends Model {
/**
* @var HasJoinedInterface
*/
private $protocol;
public function __construct(HasJoinedInterface $protocol, array $config = []) {
$this->protocol = $protocol;
parent::__construct($config);
$this->protocol = $protocol;
}
/**
* @return Account
* @throws ForbiddenOperationException
* @throws IllegalArgumentException
*/
public function hasJoined(): Account {
Yii::$app->statsd->inc('sessionserver.hasJoined.attempt');
if (!$this->protocol->validate()) {
@@ -38,10 +48,9 @@ class HasJoinedForm extends Model {
}
$joinModel->delete();
/** @var Account $account */
$account = $joinModel->getAccount();
if ($account === null) {
throw new ErrorException('Account must exists');
}
Assert::notNull($account);
Session::info("User with username = '{$username}' successfully verified by server with server_id = '{$serverId}'.");
Yii::$app->statsd->inc('sessionserver.hasJoined.success');

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\modules\session\models;
use api\modules\session\exceptions\ForbiddenOperationException;
@@ -11,8 +13,8 @@ use common\helpers\StringHelper;
use common\models\Account;
use common\models\MinecraftAccessKey;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
use Yii;
use yii\base\ErrorException;
use yii\base\Model;
use yii\web\UnauthorizedHttpException;
@@ -35,15 +37,14 @@ class JoinForm extends Model {
private $protocol;
public function __construct(JoinInterface $protocol, array $config = []) {
parent::__construct($config);
$this->protocol = $protocol;
$this->accessToken = $protocol->getAccessToken();
$this->selectedProfile = $protocol->getSelectedProfile();
$this->serverId = $protocol->getServerId();
parent::__construct($config);
}
public function rules() {
public function rules(): array {
return [
[['accessToken', 'serverId'], RequiredValidator::class],
[['accessToken', 'selectedProfile'], 'validateUuid'],
@@ -51,7 +52,12 @@ class JoinForm extends Model {
];
}
public function join() {
/**
* @return bool
* @throws IllegalArgumentException
* @throws ForbiddenOperationException
*/
public function join(): bool {
$serverId = $this->serverId;
$accessToken = $this->accessToken;
Session::info("User with access_token = '{$accessToken}' trying join to server with server_id = '{$serverId}'.");
@@ -62,9 +68,7 @@ class JoinForm extends Model {
$account = $this->getAccount();
$sessionModel = new SessionModel($account->username, $serverId);
if (!$sessionModel->save()) {
throw new ErrorException('Cannot save join session model');
}
Assert::true($sessionModel->save());
Session::info("User with access_token = '{$accessToken}' and nickname = '{$account->username}' successfully joined to server_id = '{$serverId}'.");
Yii::$app->statsd->inc('sessionserver.join.success');
@@ -72,7 +76,14 @@ class JoinForm extends Model {
return true;
}
public function validate($attributeNames = null, $clearErrors = true) {
/**
* @param string $attributeNames
* @param bool $clearErrors
*
* @return bool
* @throws IllegalArgumentException
*/
public function validate($attributeNames = null, $clearErrors = true): bool {
if (!$this->protocol->validate()) {
throw new IllegalArgumentException();
}
@@ -80,7 +91,12 @@ class JoinForm extends Model {
return parent::validate($attributeNames, $clearErrors);
}
public function validateUuid($attribute) {
/**
* @param string $attribute
*
* @throws IllegalArgumentException
*/
public function validateUuid(string $attribute): void {
if ($this->hasErrors($attribute)) {
return;
}
@@ -91,18 +107,19 @@ class JoinForm extends Model {
}
/**
* @throws \api\modules\session\exceptions\SessionServerException
* @throws \api\modules\session\exceptions\ForbiddenOperationException
*/
public function validateAccessToken() {
public function validateAccessToken(): void {
$accessToken = $this->accessToken;
/** @var MinecraftAccessKey|null $accessModel */
$accessModel = MinecraftAccessKey::findOne($accessToken);
$accessModel = MinecraftAccessKey::findOne(['access_token' => $accessToken]);
if ($accessModel !== null) {
Yii::$app->statsd->inc('sessionserver.authentication.legacy_minecraft_protocol');
/** @var MinecraftAccessKey|\api\components\OAuth2\Entities\AccessTokenEntity $accessModel */
if ($accessModel->isExpired()) {
Session::error("User with access_token = '{$accessToken}' failed join by expired access_token.");
Yii::$app->statsd->inc('sessionserver.authentication.legacy_minecraft_protocol_token_expired');
throw new ForbiddenOperationException('Expired access_token.');
}
@@ -117,6 +134,7 @@ class JoinForm extends Model {
if ($identity === null) {
Session::error("User with access_token = '{$accessToken}' failed join by wrong access_token.");
Yii::$app->statsd->inc('sessionserver.join.fail_wrong_token');
throw new ForbiddenOperationException('Invalid access_token.');
}
@@ -124,6 +142,7 @@ class JoinForm extends Model {
if (!Yii::$app->user->can(P::MINECRAFT_SERVER_SESSION)) {
Session::error("User with access_token = '{$accessToken}' doesn't have enough scopes to make join.");
Yii::$app->statsd->inc('sessionserver.authentication.oauth2_not_enough_scopes');
throw new ForbiddenOperationException('The token does not have required scope.');
}
@@ -135,12 +154,14 @@ class JoinForm extends Model {
if ($isUuid && $account->uuid !== $this->normalizeUUID($selectedProfile)) {
Session::error("User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}', but access_token issued to account with id = '{$account->uuid}'.");
Yii::$app->statsd->inc('sessionserver.join.fail_uuid_mismatch');
throw new ForbiddenOperationException('Wrong selected_profile.');
}
if (!$isUuid && mb_strtolower($account->username) !== mb_strtolower($selectedProfile)) {
Session::error("User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}', but access_token issued to account with username = '{$account->username}'.");
Yii::$app->statsd->inc('sessionserver.join.fail_username_mismatch');
throw new ForbiddenOperationException('Invalid credentials');
}

View File

@@ -1,26 +0,0 @@
<?php
namespace api\tests\_pages;
class AuthserverRoute extends BasePage {
public function authenticate($params) {
$this->getActor()->sendPOST('/api/authserver/authentication/authenticate', $params);
}
public function refresh($params) {
$this->getActor()->sendPOST('/api/authserver/authentication/refresh', $params);
}
public function validate($params) {
$this->getActor()->sendPOST('/api/authserver/authentication/validate', $params);
}
public function invalidate($params) {
$this->getActor()->sendPOST('/api/authserver/authentication/invalidate', $params);
}
public function signout($params) {
$this->getActor()->sendPOST('/api/authserver/authentication/signout', $params);
}
}

View File

@@ -1,40 +1,52 @@
<?php
declare(strict_types=1);
namespace api\tests\_pages;
/**
* @deprecated
* TODO: remove
*/
class OauthRoute extends BasePage {
public function validate(array $queryParams): void {
$this->getActor()->sendGET('/api/oauth2/v1/validate', $queryParams);
}
public function complete(array $queryParams = [], array $postParams = []): void {
$this->getActor()->sendPOST('/api/oauth2/v1/complete?' . http_build_query($queryParams), $postParams);
}
public function issueToken(array $postParams = []): void {
$this->getActor()->sendPOST('/api/oauth2/v1/token', $postParams);
}
/**
* @deprecated
*/
public function createClient(string $type, array $postParams): void {
$this->getActor()->sendPOST('/api/v1/oauth2/' . $type, $postParams);
}
/**
* @deprecated
*/
public function updateClient(string $clientId, array $params): void {
$this->getActor()->sendPUT('/api/v1/oauth2/' . $clientId, $params);
}
/**
* @deprecated
*/
public function deleteClient(string $clientId): void {
$this->getActor()->sendDELETE('/api/v1/oauth2/' . $clientId);
}
/**
* @deprecated
*/
public function resetClient(string $clientId, bool $regenerateSecret = false): void {
$this->getActor()->sendPOST("/api/v1/oauth2/{$clientId}/reset" . ($regenerateSecret ? '?regenerateSecret' : ''));
}
/**
* @deprecated
*/
public function getClient(string $clientId): void {
$this->getActor()->sendGET("/api/v1/oauth2/{$clientId}");
}
/**
* @deprecated
*/
public function getPerAccount(int $accountId): void {
$this->getActor()->sendGET("/api/v1/accounts/{$accountId}/oauth2/clients");
}

View File

@@ -3,7 +3,6 @@ declare(strict_types=1);
namespace api\tests;
use api\components\Tokens\TokensFactory;
use api\tests\_generated\FunctionalTesterActions;
use Codeception\Actor;
use common\models\Account;
@@ -20,7 +19,7 @@ class FunctionalTester extends Actor {
throw new InvalidArgumentException("Cannot find account with username \"{$asUsername}\"");
}
$token = TokensFactory::createForAccount($account);
$token = Yii::$app->tokensFactory->createForWebAccount($account);
$this->amBearerAuthenticated((string)$token);
return $account->id;

View File

@@ -17,4 +17,4 @@ modules:
host: redis
port: 6379
database: 0
cleanupBefore: 'test'
cleanupBefore: 'suite'

View File

@@ -3,16 +3,14 @@ declare(strict_types=1);
namespace api\tests\functional\_steps;
use api\tests\_pages\AuthserverRoute;
use api\tests\FunctionalTester;
use Ramsey\Uuid\Uuid;
class AuthserverSteps extends FunctionalTester {
public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0') {
$route = new AuthserverRoute($this);
public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0'): array {
$clientToken = Uuid::uuid4()->toString();
$route->authenticate([
$this->sendPOST('/api/authserver/authentication/authenticate', [
'username' => $asUsername,
'password' => $password,
'clientToken' => $clientToken,

View File

@@ -1,62 +1,60 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\_steps;
use api\components\OAuth2\Storage\ScopeStorage as S;
use api\tests\_pages\OauthRoute;
use api\components\OAuth2\Repositories\PublicScopeRepository;
use api\tests\FunctionalTester;
class OauthSteps extends FunctionalTester {
public function getAuthCode(array $permissions = []) {
public function obtainAuthCode(array $permissions = []): string {
$this->amAuthenticated();
$route = new OauthRoute($this);
$route->complete([
$this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => implode(',', $permissions),
], ['accept' => true]);
'scope' => implode(' ', $permissions),
]), ['accept' => true]);
$this->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
$response = json_decode($this->grabResponse(), true);
preg_match('/code=([\w-]+)/', $response['redirectUri'], $matches);
[$redirectUri] = $this->grabDataFromResponseByJsonPath('$.redirectUri');
preg_match('/code=([^&$]+)/', $redirectUri, $matches);
return $matches[1];
}
public function getAccessToken(array $permissions = []) {
$authCode = $this->getAuthCode($permissions);
public function getAccessToken(array $permissions = []): string {
$authCode = $this->obtainAuthCode($permissions);
$response = $this->issueToken($authCode);
return $response['access_token'];
}
public function getRefreshToken(array $permissions = []) {
$authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions));
public function getRefreshToken(array $permissions = []): string {
$authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions));
$response = $this->issueToken($authCode);
return $response['refresh_token'];
}
public function issueToken($authCode) {
$route = new OauthRoute($this);
$route->issueToken([
public function issueToken(string $authCode): array {
$this->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'authorization_code',
'code' => $authCode,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'redirect_uri' => 'http://ely.by',
'grant_type' => 'authorization_code',
]);
return json_decode($this->grabResponse(), true);
}
public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true) {
$route = new OauthRoute($this);
$route->issueToken([
public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string {
$this->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials',
'client_id' => $useTrusted ? 'trusted-client' : 'default-client',
'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W',
'grant_type' => 'client_credentials',
'scope' => implode(',', $permissions),
'scope' => implode(' ', $permissions),
]);
$response = json_decode($this->grabResponse(), true);

View File

@@ -1,48 +1,38 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\authserver;
use api\tests\_pages\AuthserverRoute;
use api\tests\FunctionalTester;
use Codeception\Example;
use Ramsey\Uuid\Uuid;
class AuthorizationCest {
/**
* @var AuthserverRoute
* @example {"login": "admin", "password": "password_0"}
* @example {"login": "admin@ely.by", "password": "password_0"}
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new AuthserverRoute($I);
}
public function byName(FunctionalTester $I) {
public function byFormParamsPostRequest(FunctionalTester $I, Example $example) {
$I->wantTo('authenticate by username and password');
$this->route->authenticate([
'username' => 'admin',
'password' => 'password_0',
$I->sendPOST('/api/authserver/authentication/authenticate', [
'username' => $example['login'],
'password' => $example['password'],
'clientToken' => Uuid::uuid4()->toString(),
]);
$this->testSuccessResponse($I);
}
public function byEmail(FunctionalTester $I) {
$I->wantTo('authenticate by email and password');
$this->route->authenticate([
'username' => 'admin@ely.by',
'password' => 'password_0',
'clientToken' => Uuid::uuid4()->toString(),
]);
$this->testSuccessResponse($I);
}
public function byNamePassedViaPOSTBody(FunctionalTester $I) {
/**
* @example {"login": "admin", "password": "password_0"}
* @example {"login": "admin@ely.by", "password": "password_0"}
*/
public function byJsonPostRequest(FunctionalTester $I, Example $example) {
$I->wantTo('authenticate by username and password sent via post body');
$this->route->authenticate(json_encode([
'username' => 'admin',
'password' => 'password_0',
$I->sendPOST('/api/authserver/authentication/authenticate', json_encode([
'username' => $example['login'],
'password' => $example['password'],
'clientToken' => Uuid::uuid4()->toString(),
]));
@@ -51,7 +41,7 @@ class AuthorizationCest {
public function byEmailWithEnabledTwoFactorAuth(FunctionalTester $I) {
$I->wantTo('get valid error by authenticate account with enabled two factor auth');
$this->route->authenticate([
$I->sendPOST('/api/authserver/authentication/authenticate', [
'username' => 'otp@gmail.com',
'password' => 'password_0',
'clientToken' => Uuid::uuid4()->toString(),
@@ -64,30 +54,9 @@ class AuthorizationCest {
]);
}
public function byEmailWithParamsAsJsonInPostBody(FunctionalTester $I) {
$I->wantTo('authenticate by email and password, passing values as serialized string in post body');
$this->route->authenticate(json_encode([
'username' => 'admin@ely.by',
'password' => 'password_0',
'clientToken' => Uuid::uuid4()->toString(),
]));
$this->testSuccessResponse($I);
}
public function longClientToken(FunctionalTester $I) {
$I->wantTo('send non uuid clientToken, but less then 255 characters');
$this->route->authenticate([
'username' => 'admin@ely.by',
'password' => 'password_0',
'clientToken' => str_pad('', 255, 'x'),
]);
$this->testSuccessResponse($I);
}
public function tooLongClientToken(FunctionalTester $I) {
$I->wantTo('send non uuid clientToken with more then 255 characters length');
$this->route->authenticate([
$I->sendPOST('/api/authserver/authentication/authenticate', [
'username' => 'admin@ely.by',
'password' => 'password_0',
'clientToken' => str_pad('', 256, 'x'),
@@ -102,7 +71,7 @@ class AuthorizationCest {
public function wrongArguments(FunctionalTester $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->authenticate([
$I->sendPOST('/api/authserver/authentication/authenticate', [
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
@@ -115,7 +84,7 @@ class AuthorizationCest {
public function wrongNicknameAndPassword(FunctionalTester $I) {
$I->wantTo('authenticate by username and password with wrong data');
$this->route->authenticate([
$I->sendPOST('/api/authserver/authentication/authenticate', [
'username' => 'nonexistent_user',
'password' => 'nonexistent_password',
'clientToken' => Uuid::uuid4()->toString(),
@@ -130,7 +99,7 @@ class AuthorizationCest {
public function bannedAccount(FunctionalTester $I) {
$I->wantTo('authenticate in suspended account');
$this->route->authenticate([
$I->sendPOST('/api/authserver/authentication/authenticate', [
'username' => 'Banned',
'password' => 'password_0',
'clientToken' => Uuid::uuid4()->toString(),

View File

@@ -3,25 +3,15 @@ declare(strict_types=1);
namespace api\tests\functional\authserver;
use api\tests\_pages\AuthserverRoute;
use api\tests\functional\_steps\AuthserverSteps;
use Ramsey\Uuid\Uuid;
class InvalidateCest {
/**
* @var AuthserverRoute
*/
private $route;
public function _before(AuthserverSteps $I) {
$this->route = new AuthserverRoute($I);
}
public function invalidate(AuthserverSteps $I) {
$I->wantTo('invalidate my token');
[$accessToken, $clientToken] = $I->amAuthenticated();
$this->route->invalidate([
$I->sendPOST('/api/authserver/authentication/invalidate', [
'accessToken' => $accessToken,
'clientToken' => $clientToken,
]);
@@ -31,7 +21,7 @@ class InvalidateCest {
public function wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->invalidate([
$I->sendPOST('/api/authserver/authentication/invalidate', [
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
@@ -44,7 +34,7 @@ class InvalidateCest {
public function wrongAccessTokenOrClientToken(AuthserverSteps $I) {
$I->wantTo('invalidate by wrong client and access token');
$this->route->invalidate([
$I->sendPOST('/api/authserver/authentication/invalidate', [
'accessToken' => Uuid::uuid4()->toString(),
'clientToken' => Uuid::uuid4()->toString(),
]);

View File

@@ -1,42 +1,74 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\authserver;
use api\tests\_pages\AuthserverRoute;
use api\tests\functional\_steps\AuthserverSteps;
use Codeception\Example;
use Ramsey\Uuid\Uuid;
class RefreshCest {
/**
* @var AuthserverRoute
*/
private $route;
public function _before(AuthserverSteps $I) {
$this->route = new AuthserverRoute($I);
}
public function refresh(AuthserverSteps $I) {
$I->wantTo('refresh my accessToken');
$I->wantTo('refresh accessToken');
[$accessToken, $clientToken] = $I->amAuthenticated();
$this->route->refresh([
$I->sendPOST('/api/authserver/authentication/refresh', [
'accessToken' => $accessToken,
'clientToken' => $clientToken,
]);
$this->assertSuccessResponse($I);
}
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->canSeeResponseJsonMatchesJsonPath('$.accessToken');
$I->canSeeResponseJsonMatchesJsonPath('$.clientToken');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.id');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.name');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.legacy');
$I->cantSeeResponseJsonMatchesJsonPath('$.availableProfiles');
public function refreshLegacyAccessToken(AuthserverSteps $I) {
$I->wantTo('refresh legacy accessToken');
$I->sendPOST('/api/authserver/authentication/refresh', [
'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42',
'clientToken' => '6f380440-0c05-47bd-b7c6-d011f1b5308f',
]);
$this->assertSuccessResponse($I);
}
public function refreshWithInvalidClientToken(AuthserverSteps $I) {
$I->wantTo('refresh accessToken with not matched client token');
[$accessToken] = $I->amAuthenticated();
$I->sendPOST('/api/authserver/authentication/refresh', [
'accessToken' => $accessToken,
'clientToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Invalid token.',
]);
}
public function refreshLegacyAccessTokenWithInvalidClientToken(AuthserverSteps $I) {
$I->wantTo('refresh legacy accessToken with not matched client token');
$I->sendPOST('/api/authserver/authentication/refresh', [
'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42',
'clientToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Invalid token.',
]);
}
/**
* @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU1NjE1MjgsImV4cCI6MTU3NTU2MTUyOCwiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiIydnByWnRVdk40VTVtSnZzc0ozaXNpekdVWFhQYnFsV1FsQjVVRWVfUV81bkxKYzlsbUJ3VU1hQWJ1MjBtZC1FNzNtengxNWFsZmRJSU1OMTV5YUpBalZOM29vQW9IRDctOWdOcmciLCJzdWIiOiJlbHl8MSJ9.vwjXzy0VtjJlP6B4RxqoE69yRSBsluZ29VELe4vDi8GCy487eC5cIf9hz9oxp5YcdE7uEJZeqX2yi3nk_0nCaA", "clientToken": "4f368b58-9097-4e56-80b1-f421ae4b53cf"}
* @example {"accessToken": "6042634a-a1e2-4aed-866c-c661fe4e63e2", "clientToken": "47fb164a-2332-42c1-8bad-549e67bb210c"}
*/
public function refreshExpiredToken(AuthserverSteps $I, Example $example) {
$I->wantTo('refresh legacy accessToken');
$I->sendPOST('/api/authserver/authentication/refresh', [
'accessToken' => $example['accessToken'],
'clientToken' => $example['clientToken'],
]);
$this->assertSuccessResponse($I);
}
public function wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->refresh([
$I->sendPOST('/api/authserver/authentication/refresh', [
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
@@ -49,7 +81,7 @@ class RefreshCest {
public function wrongAccessToken(AuthserverSteps $I) {
$I->wantTo('get error on wrong access or client tokens');
$this->route->refresh([
$I->sendPOST('/api/authserver/authentication/refresh', [
'accessToken' => Uuid::uuid4()->toString(),
'clientToken' => Uuid::uuid4()->toString(),
]);
@@ -63,7 +95,7 @@ class RefreshCest {
public function refreshTokenFromBannedUser(AuthserverSteps $I) {
$I->wantTo('refresh token from suspended account');
$this->route->refresh([
$I->sendPOST('/api/authserver/authentication/refresh', [
'accessToken' => '918ecb41-616c-40ee-a7d2-0b0ef0d0d732',
'clientToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2',
]);
@@ -74,4 +106,15 @@ class RefreshCest {
]);
}
private function assertSuccessResponse(AuthserverSteps $I) {
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->canSeeResponseJsonMatchesJsonPath('$.accessToken');
$I->canSeeResponseJsonMatchesJsonPath('$.clientToken');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.id');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.name');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.legacy');
$I->cantSeeResponseJsonMatchesJsonPath('$.availableProfiles');
}
}

View File

@@ -1,35 +1,22 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\authserver;
use api\tests\_pages\AuthserverRoute;
use api\tests\functional\_steps\AuthserverSteps;
use Codeception\Example;
class SignoutCest {
/**
* @var AuthserverRoute
* @example {"login": "admin", "password": "password_0"}
* @example {"login": "admin@ely.by", "password": "password_0"}
*/
private $route;
public function _before(AuthserverSteps $I) {
$this->route = new AuthserverRoute($I);
}
public function byName(AuthserverSteps $I) {
public function signout(AuthserverSteps $I, Example $example) {
$I->wantTo('signout by nickname and password');
$this->route->signout([
'username' => 'admin',
'password' => 'password_0',
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
public function byEmail(AuthserverSteps $I) {
$I->wantTo('signout by email and password');
$this->route->signout([
'username' => 'admin@ely.by',
'password' => 'password_0',
$I->sendPOST('/api/authserver/authentication/signout', [
'username' => $example['login'],
'password' => $example['password'],
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseEquals('');
@@ -37,7 +24,7 @@ class SignoutCest {
public function wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->signout([
$I->sendPOST('/api/authserver/authentication/signout', [
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
@@ -50,7 +37,7 @@ class SignoutCest {
public function wrongNicknameAndPassword(AuthserverSteps $I) {
$I->wantTo('signout by nickname and password with wrong data');
$this->route->signout([
$I->sendPOST('/api/authserver/authentication/signout', [
'username' => 'nonexistent_user',
'password' => 'nonexistent_password',
]);
@@ -64,7 +51,7 @@ class SignoutCest {
public function bannedAccount(AuthserverSteps $I) {
$I->wantTo('signout from banned account');
$this->route->signout([
$I->sendPOST('/api/authserver/authentication/signout', [
'username' => 'Banned',
'password' => 'password_0',
]);

View File

@@ -1,34 +1,35 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\authserver;
use api\tests\_pages\AuthserverRoute;
use api\tests\functional\_steps\AuthserverSteps;
use Ramsey\Uuid\Uuid;
class ValidateCest {
/**
* @var AuthserverRoute
*/
private $route;
public function _before(AuthserverSteps $I) {
$this->route = new AuthserverRoute($I);
}
public function validate(AuthserverSteps $I) {
$I->wantTo('validate my accessToken');
[$accessToken] = $I->amAuthenticated();
$this->route->validate([
$I->sendPOST('/api/authserver/authentication/validate', [
'accessToken' => $accessToken,
]);
$I->seeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
public function validateLegacyToken(AuthserverSteps $I) {
$I->wantTo('validate my legacy accessToken');
$I->sendPOST('/api/authserver/authentication/validate', [
'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42',
]);
$I->seeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
public function wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->validate([
$I->sendPOST('/api/authserver/authentication/validate', [
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
@@ -41,7 +42,7 @@ class ValidateCest {
public function wrongAccessToken(AuthserverSteps $I) {
$I->wantTo('get error on wrong accessToken');
$this->route->validate([
$I->sendPOST('/api/authserver/authentication/validate', [
'accessToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseCodeIs(401);
@@ -54,9 +55,21 @@ class ValidateCest {
public function expiredAccessToken(AuthserverSteps $I) {
$I->wantTo('get error on expired accessToken');
$this->route->validate([
// Knowingly expired token from the dump
'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2',
$I->sendPOST('/api/authserver/authentication/validate', [
'accessToken' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU0Nzk1NTMsImV4cCI6MTU3NTQ3OTU1MywiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJyZW1vdmVkIiwic3ViIjoiZWx5fDEifQ.xDMs5B48nH6p3a1k3WoZKtW4zoNHGGaLD1OGTFte-sUJb2fNMR65LuuBW8DzqO2odgco2xX660zqbhB-tp2OsA',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Token expired.',
]);
}
public function expiredLegacyAccessToken(AuthserverSteps $I) {
$I->wantTo('get error on expired legacy accessToken');
$I->sendPOST('/api/authserver/authentication/validate', [
'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', // Already expired token from the fixtures
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();

View File

@@ -1,5 +1,7 @@
<?php
namespace api\tests\functional\oauth;
declare(strict_types=1);
namespace api\tests\functional\dev\applications;
use api\tests\_pages\OauthRoute;
use api\tests\FunctionalTester;

View File

@@ -1,5 +1,7 @@
<?php
namespace api\tests\functional\oauth;
declare(strict_types=1);
namespace api\tests\functional\dev\applications;
use api\tests\_pages\OauthRoute;
use api\tests\FunctionalTester;

View File

@@ -1,5 +1,7 @@
<?php
namespace api\tests\functional\oauth;
declare(strict_types=1);
namespace api\tests\functional\dev\applications;
use api\tests\_pages\OauthRoute;
use api\tests\FunctionalTester;

View File

@@ -1,5 +1,7 @@
<?php
namespace api\tests\functional\oauth;
declare(strict_types=1);
namespace api\tests\functional\dev\applications;
use api\tests\_pages\IdentityInfoRoute;
use api\tests\functional\_steps\OauthSteps;

View File

@@ -1,5 +1,7 @@
<?php
namespace api\tests\functional\oauth;
declare(strict_types=1);
namespace api\tests\functional\dev\applications;
use api\tests\_pages\OauthRoute;
use api\tests\FunctionalTester;

View File

@@ -1,5 +1,7 @@
<?php
namespace api\tests\functional\oauth;
declare(strict_types=1);
namespace api\tests\functional\dev\applications;
use api\tests\_pages\OauthRoute;
use api\tests\FunctionalTester;

View File

@@ -1,113 +1,91 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\oauth;
use api\tests\_pages\OauthRoute;
use api\tests\functional\_steps\OauthSteps;
use api\tests\FunctionalTester;
class AccessTokenCest {
/**
* @var OauthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new OauthRoute($I);
public function successfullyIssueToken(OauthSteps $I) {
$I->wantTo('complete oauth flow and obtain access_token');
$authCode = $I->obtainAuthCode();
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'authorization_code',
'code' => $authCode,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'redirect_uri' => 'http://ely.by',
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);
$I->canSeeResponseJsonMatchesJsonPath('$.access_token');
$I->canSeeResponseJsonMatchesJsonPath('$.expires_in');
$I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token');
}
public function testIssueTokenWithWrongArgs(OauthSteps $I) {
$I->wantTo('check behavior on on request without any credentials');
$this->route->issueToken();
public function successfullyIssueOfflineToken(OauthSteps $I) {
$I->wantTo('complete oauth flow with offline_access scope and obtain access_token and refresh_token');
$authCode = $I->obtainAuthCode(['offline_access']);
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'authorization_code',
'code' => $authCode,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'redirect_uri' => 'http://ely.by',
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);
$I->canSeeResponseJsonMatchesJsonPath('$.access_token');
$I->canSeeResponseJsonMatchesJsonPath('$.expires_in');
$I->canSeeResponseJsonMatchesJsonPath('$.refresh_token');
}
public function callEndpointWithByEmptyRequest(OauthSteps $I) {
$I->wantTo('check behavior on on request without any params');
$I->sendPOST('/api/oauth2/v1/token');
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([
'error' => 'invalid_request',
'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "grant_type" parameter.',
'error' => 'unsupported_grant_type',
'message' => 'The authorization grant type is not supported by the authorization server.',
]);
}
public function issueTokenByPassingInvalidAuthCode(OauthSteps $I) {
$I->wantTo('check behavior on passing invalid auth code');
$this->route->issueToken($this->buildParams(
'wrong-auth-code',
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'http://ely.by'
));
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'authorization_code',
'code' => 'wrong-auth-code',
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'redirect_uri' => 'http://ely.by',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([
'error' => 'invalid_request',
'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "code" parameter.',
]);
}
$authCode = $I->getAuthCode();
public function issueTokenByPassingInvalidRedirectUri(OauthSteps $I) {
$I->wantTo('check behavior on passing invalid redirect_uri');
$this->route->issueToken($this->buildParams(
$authCode,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'http://some-other.domain'
));
$authCode = $I->obtainAuthCode();
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'authorization_code',
'code' => $authCode,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'redirect_uri' => 'http://some-other.domain',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'invalid_client',
'message' => 'Client authentication failed.',
'message' => 'Client authentication failed',
]);
}
public function testIssueToken(OauthSteps $I) {
$authCode = $I->getAuthCode();
$this->route->issueToken($this->buildParams(
$authCode,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'http://ely.by'
));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);
$I->canSeeResponseJsonMatchesJsonPath('$.access_token');
$I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token');
$I->canSeeResponseJsonMatchesJsonPath('$.expires_in');
}
public function testIssueTokenWithRefreshToken(OauthSteps $I) {
$authCode = $I->getAuthCode(['offline_access']);
$this->route->issueToken($this->buildParams(
$authCode,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'http://ely.by'
));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);
$I->canSeeResponseJsonMatchesJsonPath('$.access_token');
$I->canSeeResponseJsonMatchesJsonPath('$.refresh_token');
$I->canSeeResponseJsonMatchesJsonPath('$.expires_in');
}
private function buildParams($code = null, $clientId = null, $clientSecret = null, $redirectUri = null) {
$params = ['grant_type' => 'authorization_code'];
if ($code !== null) {
$params['code'] = $code;
}
if ($clientId !== null) {
$params['client_id'] = $clientId;
}
if ($clientSecret !== null) {
$params['client_secret'] = $clientSecret;
}
if ($redirectUri !== null) {
$params['redirect_uri'] = $redirectUri;
}
return $params;
}
}

View File

@@ -1,155 +1,21 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\oauth;
use api\rbac\Permissions as P;
use api\tests\_pages\OauthRoute;
use api\tests\FunctionalTester;
class AuthCodeCest {
/**
* @var OauthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new OauthRoute($I);
}
public function testValidateRequest(FunctionalTester $I) {
$this->testOauthParamsValidation($I, 'validate');
$I->wantTo('validate and obtain information about new auth request');
$this->route->validate($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
[P::MINECRAFT_SERVER_SESSION, 'account_info', 'account_email'],
'test-state'
));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => true,
'oAuth' => [
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session,account_info,account_email',
'state' => 'test-state',
],
'client' => [
'id' => 'ely',
'name' => 'Ely.by',
'description' => 'Всем знакомое елуби',
],
'session' => [
'scopes' => [
'minecraft_server_session',
'account_info',
'account_email',
],
],
]);
}
public function testValidateWithDescriptionReplaceRequest(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('validate and get information with description replacement');
$this->route->validate($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
null,
null,
[
'description' => 'all familiar eliby',
]
));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'client' => [
'description' => 'all familiar eliby',
],
]);
}
public function testCompleteValidationAction(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('validate all oAuth params on complete request');
$this->testOauthParamsValidation($I, 'complete');
}
public function testCompleteActionOnWrongConditions(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get accept_required if I don\'t require any scope, but this is first time request');
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code'
));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'accept_required',
'parameter' => '',
'statusCode' => 401,
]);
$I->wantTo('get accept_required if I require some scopes on first time');
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
[P::MINECRAFT_SERVER_SESSION]
));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'accept_required',
'parameter' => '',
'statusCode' => 401,
]);
}
public function testCompleteActionSuccess(FunctionalTester $I) {
public function completeSuccess(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get auth code if I require some scope and pass accept field');
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
[P::MINECRAFT_SERVER_SESSION]
), ['accept' => true]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
$I->wantTo('get auth code if I don\'t require any scope and don\'t pass accept field, but previously have ' .
'successful request');
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code'
));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
$I->wantTo('get auth code if I require some scopes and don\'t pass accept field, but previously have successful ' .
'request with same scopes');
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
[P::MINECRAFT_SERVER_SESSION]
));
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session',
]), ['accept' => true]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'success' => true,
@@ -157,26 +23,98 @@ class AuthCodeCest {
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
}
public function testAcceptRequiredOnNewScope(FunctionalTester $I) {
/**
* @before completeSuccess
*/
public function completeSuccessWithLessScopes(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get accept_required if I have previous successful request, but now require some new scope');
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
[P::MINECRAFT_SERVER_SESSION]
), ['accept' => true]);
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
[P::MINECRAFT_SERVER_SESSION, 'account_info']
));
$I->wantTo('get auth code with less scopes as passed in the previous request without accept param');
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
]));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
}
/**
* @before completeSuccess
*/
public function completeSuccessWithSameScopes(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get auth code with the same scopes as passed in the previous request without accept param');
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session',
]));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
}
public function acceptRequiredOnFirstAuthRequest1(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get accept_required if I don\'t require any scope, but this is first time request');
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
]));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'accept_required',
'parameter' => '',
'parameter' => null,
'statusCode' => 401,
]);
}
public function acceptRequiredOnFirstAuthRequest2(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get accept_required if I require some scopes on first time');
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session',
]));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'accept_required',
'parameter' => null,
'statusCode' => 401,
]);
}
public function acceptRequiredOnNewScope(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get accept_required if I have previous successful request, but now require some new scope');
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session',
]), ['accept' => true]);
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session account_info',
]));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'accept_required',
'parameter' => null,
'statusCode' => 401,
]);
}
@@ -184,72 +122,30 @@ class AuthCodeCest {
public function testCompleteActionWithDismissState(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('get access_denied error if I pass accept in false state');
$this->route->complete($this->buildQueryParams(
'ely',
'http://ely.by',
'code',
[P::MINECRAFT_SERVER_SESSION]
), ['accept' => false]);
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session',
]), ['accept' => false]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'access_denied',
'parameter' => '',
'parameter' => null,
'statusCode' => 401,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
}
private function buildQueryParams(
$clientId = null,
$redirectUri = null,
$responseType = null,
$scopes = [],
$state = null,
$customData = []
) {
$params = $customData;
if ($clientId !== null) {
$params['client_id'] = $clientId;
}
if ($redirectUri !== null) {
$params['redirect_uri'] = $redirectUri;
}
if ($responseType !== null) {
$params['response_type'] = $responseType;
}
if ($state !== null) {
$params['state'] = $state;
}
if (!empty($scopes)) {
if (is_array($scopes)) {
$scopes = implode(',', $scopes);
}
$params['scope'] = $scopes;
}
return $params;
}
private function testOauthParamsValidation(FunctionalTester $I, $action) {
$I->wantTo('check behavior on invalid request without one or few params');
$this->route->$action($this->buildQueryParams());
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'invalid_request',
'parameter' => 'client_id',
'statusCode' => 400,
]);
public function invalidClientId(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('check behavior on invalid client id');
$this->route->$action($this->buildQueryParams('non-exists-client', 'http://some-resource.by', 'code'));
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'non-exists-client',
'redirect_uri' => 'http://some-resource.by',
'response_type' => 'code',
]));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
@@ -257,23 +153,16 @@ class AuthCodeCest {
'error' => 'invalid_client',
'statusCode' => 401,
]);
}
$I->wantTo('check behavior on invalid response type');
$this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'kitty'));
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'unsupported_response_type',
'parameter' => 'kitty',
'statusCode' => 400,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
public function invalidScopes(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('check behavior on some invalid scopes');
$this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [
P::MINECRAFT_SERVER_SESSION,
'some_wrong_scope',
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session some_wrong_scope',
]));
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
@@ -284,18 +173,23 @@ class AuthCodeCest {
'statusCode' => 400,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
}
public function requestInternalScope(FunctionalTester $I) {
$I->amAuthenticated();
$I->wantTo('check behavior on request internal scope');
$this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [
P::MINECRAFT_SERVER_SESSION,
P::BLOCK_ACCOUNT,
]));
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session block_account',
]), ['accept' => true]); // TODO: maybe remove?
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'invalid_scope',
'parameter' => P::BLOCK_ACCOUNT,
'parameter' => 'block_account',
'statusCode' => 400,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');

View File

@@ -1,120 +1,86 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\oauth;
use api\tests\_pages\OauthRoute;
use api\tests\functional\_steps\OauthSteps;
use api\tests\FunctionalTester;
class ClientCredentialsCest {
/**
* @var OauthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new OauthRoute($I);
public function issueTokenWithPublicScopes(FunctionalTester $I) {
$I->wantTo('issue token as not trusted client and require only public scopes');
// We don't have any public scopes yet for this grant, so the test runs with an empty set
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials',
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => '',
]);
$this->assertSuccessResponse($I);
}
public function testIssueTokenWithWrongArgs(FunctionalTester $I) {
$I->wantTo('check behavior on on request without any credentials');
$this->route->issueToken($this->buildParams());
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([
'error' => 'invalid_request',
public function issueTokenWithInternalScopesAsNotTrustedClient(FunctionalTester $I) {
$I->wantTo('issue token as not trusted client and require some internal scope');
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials',
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'block_account',
]);
$I->wantTo('check behavior on passing invalid client_id');
$this->route->issueToken($this->buildParams(
'invalid-client',
'invalid-secret',
['invalid-scope']
));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'invalid_client',
]);
$I->wantTo('check behavior on passing invalid client_secret');
$this->route->issueToken($this->buildParams(
'ely',
'invalid-secret',
['invalid-scope']
));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'invalid_client',
]);
$I->wantTo('check behavior on passing invalid client_secret');
$this->route->issueToken($this->buildParams(
'ely',
'invalid-secret',
['invalid-scope']
));
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'invalid_client',
]);
}
public function testIssueTokenWithPublicScopes(OauthSteps $I) {
// TODO: we don't have any public scopes yet for this grant, so the test runs with an empty set
$this->route->issueToken($this->buildParams(
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
[]
));
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);
$I->canSeeResponseJsonMatchesJsonPath('$.access_token');
$I->canSeeResponseJsonMatchesJsonPath('$.expires_in');
}
public function testIssueTokenWithInternalScopes(OauthSteps $I) {
$this->route->issueToken($this->buildParams(
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
['account_block']
));
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'invalid_scope',
]);
}
$this->route->issueToken($this->buildParams(
'trusted-client',
'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9',
['account_block']
));
public function issueTokenWithInternalScopesAsTrustedClient(FunctionalTester $I) {
$I->wantTo('issue token as trusted client and require some internal scope');
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials',
'client_id' => 'trusted-client',
'client_secret' => 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9',
'scope' => 'block_account',
]);
$this->assertSuccessResponse($I);
}
public function issueTokenByPassingInvalidClientId(FunctionalTester $I) {
$I->wantToTest('behavior on passing invalid client_id');
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials',
'client_id' => 'invalid-client',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'block_account',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'invalid_client',
]);
}
public function issueTokenByPassingInvalidClientSecret(FunctionalTester $I) {
$I->wantTo('check behavior on passing invalid client_secret');
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials',
'client_id' => 'trusted-client',
'client_secret' => 'invalid-secret',
'scope' => 'block_account',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'invalid_client',
]);
}
private function assertSuccessResponse(FunctionalTester $I): void {
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);
$I->canSeeResponseJsonMatchesJsonPath('$.access_token');
$I->canSeeResponseJsonMatchesJsonPath('$.expires_in');
}
private function buildParams($clientId = null, $clientSecret = null, array $scopes = null) {
$params = ['grant_type' => 'client_credentials'];
if ($clientId !== null) {
$params['client_id'] = $clientId;
}
if ($clientSecret !== null) {
$params['client_secret'] = $clientSecret;
}
if ($scopes !== null) {
$params['scope'] = implode(',', $scopes);
}
return $params;
$I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token');
}
}

View File

@@ -1,83 +1,96 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\oauth;
use api\components\OAuth2\Storage\ScopeStorage as S;
use api\rbac\Permissions as P;
use api\tests\_pages\OauthRoute;
use api\tests\functional\_steps\OauthSteps;
use api\tests\FunctionalTester;
class RefreshTokenCest {
/**
* @var OauthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new OauthRoute($I);
public function refreshToken(OauthSteps $I) {
$I->wantTo('refresh token without passing the desired scopes');
$refreshToken = $I->getRefreshToken();
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
]);
$this->canSeeRefreshTokenSuccess($I);
}
public function testInvalidRefreshToken(OauthSteps $I) {
$this->route->issueToken($this->buildParams(
'some-invalid-refresh-token',
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM'
));
public function refreshTokenWithSameScopes(OauthSteps $I) {
$refreshToken = $I->getRefreshToken(['minecraft_server_session']);
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'minecraft_server_session',
]);
$this->canSeeRefreshTokenSuccess($I);
}
public function refreshTokenTwice(OauthSteps $I) {
$I->wantTo('refresh token two times in a row and ensure, that token isn\'t rotating');
$refreshToken = $I->getRefreshToken(['minecraft_server_session']);
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'minecraft_server_session',
]);
$this->canSeeRefreshTokenSuccess($I);
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'minecraft_server_session',
]);
$this->canSeeRefreshTokenSuccess($I);
}
public function refreshTokenUsingLegacyToken(FunctionalTester $I) {
$I->wantTo('refresh token using the legacy token');
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
'refresh_token' => 'op7kPGAgHlsXRBJtkFg7wKOTpodvtHVW5NxR7Tjr',
'client_id' => 'test1',
'client_secret' => 'eEvrKHF47sqiaX94HsX-xXzdGiz3mcsq',
'scope' => 'minecraft_server_session account_info',
]);
$this->canSeeRefreshTokenSuccess($I);
}
public function passInvalidRefreshToken(OauthSteps $I) {
$I->wantToTest('behaviour of the server when invalid refresh token passed');
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
'refresh_token' => 'some-invalid-refresh-token',
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'invalid_request',
'message' => 'The refresh token is invalid.',
]);
}
public function testRefreshToken(OauthSteps $I) {
$refreshToken = $I->getRefreshToken();
$this->route->issueToken($this->buildParams(
$refreshToken,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM'
));
$this->canSeeRefreshTokenSuccess($I);
}
public function testRefreshTokenWithSameScopes(OauthSteps $I) {
$refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]);
$this->route->issueToken($this->buildParams(
$refreshToken,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
[P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS]
));
$this->canSeeRefreshTokenSuccess($I);
}
public function testRefreshTokenTwice(OauthSteps $I) {
$refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]);
$this->route->issueToken($this->buildParams(
$refreshToken,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
[P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS]
));
$this->canSeeRefreshTokenSuccess($I);
$this->route->issueToken($this->buildParams(
$refreshToken,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
[P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS]
));
$this->canSeeRefreshTokenSuccess($I);
}
public function testRefreshTokenWithNewScopes(OauthSteps $I) {
$refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]);
$this->route->issueToken($this->buildParams(
$refreshToken,
'ely',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
[P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS, 'account_email']
));
public function requireNewScopes(OauthSteps $I) {
$I->wantToTest('behavior when required the new scope that was not issued with original token');
$refreshToken = $I->getRefreshToken(['minecraft_server_session']);
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'minecraft_server_session account_email',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
@@ -85,34 +98,8 @@ class RefreshTokenCest {
]);
}
private function buildParams($refreshToken = null, $clientId = null, $clientSecret = null, $scopes = []) {
$params = ['grant_type' => 'refresh_token'];
if ($refreshToken !== null) {
$params['refresh_token'] = $refreshToken;
}
if ($clientId !== null) {
$params['client_id'] = $clientId;
}
if ($clientSecret !== null) {
$params['client_secret'] = $clientSecret;
}
if (!empty($scopes)) {
if (is_array($scopes)) {
$scopes = implode(',', $scopes);
}
$params['scope'] = $scopes;
}
return $params;
}
private function canSeeRefreshTokenSuccess(OauthSteps $I) {
private function canSeeRefreshTokenSuccess(FunctionalTester $I) {
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace api\tests\functional\oauth;
use api\tests\FunctionalTester;
class ValidateCest {
public function completelyValidateValidRequest(FunctionalTester $I) {
$I->wantTo('validate and obtain information about new oauth request');
$I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session account_info account_email',
'state' => 'test-state',
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'success' => true,
'oAuth' => [
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session account_info account_email',
'state' => 'test-state',
],
'client' => [
'id' => 'ely',
'name' => 'Ely.by',
'description' => 'Всем знакомое елуби',
],
'session' => [
'scopes' => [
'minecraft_server_session',
'account_info',
'account_email',
],
],
]);
}
public function completelyValidateValidRequestWithOverriddenDescription(FunctionalTester $I) {
$I->wantTo('validate and get information with description replacement');
$I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'description' => 'all familiar eliby',
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseContainsJson([
'client' => [
'description' => 'all familiar eliby',
],
]);
}
public function unknownClientId(FunctionalTester $I) {
$I->wantTo('check behavior on invalid client id');
$I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'non-exists-client',
'redirect_uri' => 'http://some-resource.by',
'response_type' => 'code',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'invalid_client',
'statusCode' => 401,
]);
}
public function invalidScopes(FunctionalTester $I) {
$I->wantTo('check behavior on some invalid scopes');
$I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session some_wrong_scope',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'invalid_scope',
'parameter' => 'some_wrong_scope',
'statusCode' => 400,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
}
public function requestInternalScope(FunctionalTester $I) {
$I->wantTo('check behavior on request internal scope');
$I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely',
'redirect_uri' => 'http://ely.by',
'response_type' => 'code',
'scope' => 'minecraft_server_session block_account',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'invalid_scope',
'parameter' => 'block_account',
'statusCode' => 400,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\OAuth2\Entities;
use api\components\OAuth2\Entities\AccessTokenEntity;
use api\tests\unit\TestCase;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
class AccessTokenEntityTest extends TestCase {
public function testToString() {
/** @var ClientEntityInterface|\PHPUnit\Framework\MockObject\MockObject $client */
$client = $this->createMock(ClientEntityInterface::class);
$client->method('getIdentifier')->willReturn('mockClientId');
$entity = new AccessTokenEntity();
$entity->setClient($client);
$entity->setExpiryDateTime(new DateTimeImmutable());
$entity->addScope($this->createScopeEntity('first'));
$entity->addScope($this->createScopeEntity('second'));
$token = (string)$entity;
$payloads = json_decode(base64_decode(explode('.', $token)[1]), true);
$this->assertSame('first,second', $payloads['ely-scopes']);
}
private function createScopeEntity(string $id): ScopeEntityInterface {
/** @var ScopeEntityInterface|\PHPUnit\Framework\MockObject\MockObject $entity */
$entity = $this->createMock(ScopeEntityInterface::class);
$entity->method('getIdentifier')->willReturn($id);
return $entity;
}
}

View File

@@ -22,7 +22,11 @@ class ComponentTest extends TestCase {
$this->assertSame('ES256', $token->getHeader('alg'));
$this->assertEmpty(array_diff(array_keys($token->getClaims()), ['iat', 'exp']));
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1);
$this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2);
// Pass exp claim
$time = time() + 60;
$token = $this->component->create(['exp' => $time]);
$this->assertSame($time, $token->getClaim('exp'));
// Pass custom payloads
$token = $this->component->create(['find' => 'me']);

View File

@@ -5,16 +5,24 @@ namespace api\tests\unit\components\Tokens;
use api\components\Tokens\TokensFactory;
use api\tests\unit\TestCase;
use Carbon\Carbon;
use common\models\Account;
use common\models\AccountSession;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
class TokensFactoryTest extends TestCase {
public function testCreateForAccount() {
$factory = new TokensFactory();
$account = new Account();
$account->id = 1;
$token = TokensFactory::createForAccount($account);
// Create for account
$token = $factory->createForWebAccount($account);
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1);
$this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $token->getClaim('exp'), 2);
$this->assertSame('ely|1', $token->getClaim('sub'));
@@ -24,7 +32,9 @@ class TokensFactoryTest extends TestCase {
$session = new AccountSession();
$session->id = 2;
$token = TokensFactory::createForAccount($account, $session);
// Create for account with remember me
$token = $factory->createForWebAccount($account, $session);
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1);
$this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2);
$this->assertSame('ely|1', $token->getClaim('sub'));
@@ -32,4 +42,60 @@ class TokensFactoryTest extends TestCase {
$this->assertSame(2, $token->getClaim('jti'));
}
public function testCreateForOauthClient() {
$factory = new TokensFactory();
$client = $this->createMock(ClientEntityInterface::class);
$client->method('getIdentifier')->willReturn('clientId');
$scope1 = $this->createMock(ScopeEntityInterface::class);
$scope1->method('getIdentifier')->willReturn('scope1');
$scope2 = $this->createMock(ScopeEntityInterface::class);
$scope2->method('getIdentifier')->willReturn('scope2');
$expiryDateTime = Carbon::now()->addDay();
// Create for auth code grant
$accessToken = $this->createMock(AccessTokenEntityInterface::class);
$accessToken->method('getClient')->willReturn($client);
$accessToken->method('getScopes')->willReturn([$scope1, $scope2]);
$accessToken->method('getExpiryDateTime')->willReturn($expiryDateTime);
$accessToken->method('getUserIdentifier')->willReturn(1);
$token = $factory->createForOAuthClient($accessToken);
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1);
$this->assertEqualsWithDelta($expiryDateTime->getTimestamp(), $token->getClaim('exp'), 2);
$this->assertSame('ely|1', $token->getClaim('sub'));
$this->assertSame('client|clientId', $token->getClaim('aud'));
$this->assertSame('scope1,scope2', $token->getClaim('ely-scopes'));
// Create for client credentials grant
$accessToken = $this->createMock(AccessTokenEntityInterface::class);
$accessToken->method('getClient')->willReturn($client);
$accessToken->method('getScopes')->willReturn([$scope1, $scope2]);
$accessToken->method('getExpiryDateTime')->willReturn(Carbon::now()->subDay());
$accessToken->method('getUserIdentifier')->willReturn(null);
$token = $factory->createForOAuthClient($accessToken);
$this->assertSame('no value', $token->getClaim('exp', 'no value'));
$this->assertSame('no value', $token->getClaim('sub', 'no value'));
}
public function testCreateForMinecraftAccount() {
$factory = new TokensFactory();
$account = new Account();
$account->id = 1;
$clientToken = 'e44fae79-f80e-4975-952e-47e8a9ed9472';
$token = $factory->createForMinecraftAccount($account, $clientToken);
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 5);
$this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 2, $token->getClaim('exp'), 5);
$this->assertSame('minecraft_server_session', $token->getClaim('ely-scopes'));
$this->assertNotSame('e44fae79-f80e-4975-952e-47e8a9ed9472', $token->getClaim('ely-client-token'));
$this->assertSame('ely|1', $token->getClaim('sub'));
}
}

View File

@@ -5,13 +5,16 @@ namespace codeception\api\unit\components\User;
use api\components\User\Component;
use api\components\User\JwtIdentity;
use api\components\User\OAuth2Identity;
use api\components\User\LegacyOAuth2Identity;
use api\tests\unit\TestCase;
use common\models\Account;
use common\models\AccountSession;
use common\models\OauthClient;
use common\tests\fixtures\AccountFixture;
use common\tests\fixtures\AccountSessionFixture;
use common\tests\fixtures\MinecraftAccessKeyFixture;
use common\tests\fixtures\OauthClientFixture;
use common\tests\fixtures\OauthSessionFixture;
use Lcobucci\JWT\Claim\Basic;
use Lcobucci\JWT\Token;
@@ -32,6 +35,8 @@ class ComponentTest extends TestCase {
'accounts' => AccountFixture::class,
'sessions' => AccountSessionFixture::class,
'minecraftSessions' => MinecraftAccessKeyFixture::class,
'oauthClients' => OauthClientFixture::class,
'oauthSessions' => OauthSessionFixture::class,
];
}
@@ -41,7 +46,7 @@ class ComponentTest extends TestCase {
$this->assertNull($component->getActiveSession());
// Identity is a Oauth2Identity
$component->setIdentity(mock(OAuth2Identity::class));
$component->setIdentity(mock(LegacyOAuth2Identity::class));
$this->assertNull($component->getActiveSession());
// Identity is correct, but have no jti claim
@@ -88,6 +93,7 @@ class ComponentTest extends TestCase {
$component->terminateSessions($account, Component::KEEP_SITE_SESSIONS);
$this->assertEmpty($account->getMinecraftAccessKeys()->all());
$this->assertNotEmpty($account->getSessions()->all());
$this->assertEqualsWithDelta(time(), $account->getOauthSessions()->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER])->one()->revoked_at, 5);
// All sessions should be removed except the current one
$component->terminateSessions($account, Component::KEEP_CURRENT_SESSION);

View File

@@ -3,41 +3,31 @@ declare(strict_types=1);
namespace api\tests\unit\components\User;
use api\components\OAuth2\Component;
use api\components\OAuth2\Entities\AccessTokenEntity;
use api\components\User\IdentityFactory;
use api\components\User\JwtIdentity;
use api\components\User\OAuth2Identity;
use api\components\User\LegacyOAuth2Identity;
use api\tests\unit\TestCase;
use Carbon\Carbon;
use League\OAuth2\Server\AbstractServer;
use League\OAuth2\Server\Storage\AccessTokenInterface;
use Yii;
use common\tests\fixtures;
use yii\web\UnauthorizedHttpException;
class IdentityFactoryTest extends TestCase {
public function _fixtures(): array {
return [
fixtures\LegacyOauthAccessTokenFixture::class,
fixtures\LegacyOauthAccessTokenScopeFixture::class,
];
}
public function testFindIdentityByAccessToken() {
// Find identity by jwt token
// Find identity by the JWT
$identity = IdentityFactory::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.4Oidvuo4spvUf9hkpHR72eeqZUh2Zbxh_L8Od3vcgTj--0iOrcOEp6zwmEW6vF7BTHtjz2b3mXce61bqsCjXjQ');
$this->assertInstanceOf(JwtIdentity::class, $identity);
// Find identity by oauth2 token
$accessToken = new AccessTokenEntity(mock(AbstractServer::class));
$accessToken->setExpireTime(time() + 3600);
$accessToken->setId('mock-token');
/** @var AccessTokenInterface|\Mockery\MockInterface $accessTokensStorage */
$accessTokensStorage = mock(AccessTokenInterface::class);
$accessTokensStorage->shouldReceive('get')->with('mock-token')->andReturn($accessToken);
/** @var Component|\Mockery\MockInterface $component */
$component = mock(Component::class);
$component->shouldReceive('getAccessTokenStorage')->andReturn($accessTokensStorage);
Yii::$app->set('oauth', $component);
$identity = IdentityFactory::findIdentityByAccessToken('mock-token');
$this->assertInstanceOf(OAuth2Identity::class, $identity);
// Find identity by the legacy OAuth2 token
$identity = IdentityFactory::findIdentityByAccessToken('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX');
$this->assertInstanceOf(LegacyOAuth2Identity::class, $identity);
}
public function testFindIdentityByAccessTokenWithEmptyValue() {

View File

@@ -7,6 +7,8 @@ use api\components\User\JwtIdentity;
use api\tests\unit\TestCase;
use Carbon\Carbon;
use common\tests\fixtures\AccountFixture;
use common\tests\fixtures\OauthClientFixture;
use common\tests\fixtures\OauthSessionFixture;
use yii\web\UnauthorizedHttpException;
class JwtIdentityTest extends TestCase {
@@ -14,6 +16,8 @@ class JwtIdentityTest extends TestCase {
public function _fixtures(): array {
return [
'accounts' => AccountFixture::class,
'oauthClients' => OauthClientFixture::class,
'oauthSessions' => OauthSessionFixture::class,
];
}
@@ -46,14 +50,18 @@ class JwtIdentityTest extends TestCase {
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTc3NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ._6hj6XUSmSLibgT9ZE1Pokf4oI9r-d6tEc1z2J-fBlr1710Qiso5yNcXqb3Z_xy7Qtemyq8jOlOZA8DvmkVBrg',
'Incorrect token',
];
yield 'revoked by oauth client' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudF9pbmZvLG1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImlhdCI6MTU2NDYxMDUwMCwic3ViIjoiZWx5fDEiLCJhdWQiOiJjbGllbnR8dGxhdW5jaGVyIn0.YzUzvnREEoQPu8CvU6WLdysUU0bC_xzigQPs2LK1su38uysSYgSbPzNOZYkQnvcmVLehHY-ON44x-oA8Os-9ZA',
'Token has been revoked',
];
yield 'revoked by unauthorized minecraft launcher' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoibWluZWNyYWZ0X3NlcnZlcl9zZXNzaW9uIiwiZWx5LWNsaWVudC10b2tlbiI6IllBTVhneTBBcEI5Z2dUX1VYNjNJaTdKcGtNd2ZwTmxaaE8yVVVEeEZ3YTFmZ2g4dksyN0RtV25vN2xqbk1pWWJwQ1VuS09YVnR2V1YtVVg1dWRQVVFsLU4xY3BBZlJBX2EtZW1BZyIsImlhdCI6MTU2NDYxMDUwMCwic3ViIjoiZWx5fDEifQ.LtE9cQJ4z5dGVkDZl50M2HZH6kOYHgGz2RIycS_lzU9YLhosQ3ux7i2KI7qGI7BNuxO5zJ1OkxF2r9Qc240EpA',
'Token has been revoked',
];
yield 'invalid signature' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw',
'Incorrect token',
];
yield 'invalid sub' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw',
'Incorrect token',
];
yield 'empty token' => ['', 'Incorrect token'];
}
@@ -66,6 +74,10 @@ class JwtIdentityTest extends TestCase {
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDk5OTk5In0.1pAnhkR-_ZqzjLBR-PNIMJUXRSUK3aYixrFNKZg2ynPNPiDvzh8U-iBTT6XRfMP5nvfXZucRpoPVoiXtx40CUQ');
$this->assertNull($identity->getAccount());
// Sub contains invalid value
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw');
$this->assertNull($identity->getAccount());
// Token without sub claim
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Mn0.QxmYgSflZOQmhzYRr8bowU767yu4yKgTVaho0MPuyCmUfZO_0O0SQASMKVILf-wlT0ODTTG7vD753a2MTAmPmw');
$this->assertNull($identity->getAccount());

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\User;
use api\components\User\LegacyOAuth2Identity;
use api\tests\unit\TestCase;
use common\tests\fixtures;
use yii\web\UnauthorizedHttpException;
class LegacyOAuth2IdentityTest extends TestCase {
public function _fixtures(): array {
return [
fixtures\LegacyOauthAccessTokenFixture::class,
fixtures\LegacyOauthAccessTokenScopeFixture::class,
];
}
public function testFindIdentityByAccessToken() {
$identity = LegacyOAuth2Identity::findIdentityByAccessToken('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX');
$this->assertSame('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX', $identity->getId());
}
public function testFindIdentityByAccessTokenWithNonExistsToken() {
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Incorrect token');
LegacyOAuth2Identity::findIdentityByAccessToken('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
}
public function testFindIdentityByAccessTokenWithExpiredToken() {
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Token expired');
LegacyOAuth2Identity::findIdentityByAccessToken('rc0sOF1SLdOxuD3bJcCQENmGTeYrGgy12qJScMx4');
}
}

View File

@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\User;
use api\components\OAuth2\Component;
use api\components\OAuth2\Entities\AccessTokenEntity;
use api\components\User\OAuth2Identity;
use api\tests\unit\TestCase;
use League\OAuth2\Server\AbstractServer;
use League\OAuth2\Server\Storage\AccessTokenInterface;
use Yii;
use yii\web\UnauthorizedHttpException;
class OAuth2IdentityTest extends TestCase {
public function testFindIdentityByAccessToken() {
$accessToken = new AccessTokenEntity(mock(AbstractServer::class));
$accessToken->setExpireTime(time() + 3600);
$accessToken->setId('mock-token');
$this->mockFoundedAccessToken($accessToken);
$identity = OAuth2Identity::findIdentityByAccessToken('mock-token');
$this->assertSame('mock-token', $identity->getId());
}
public function testFindIdentityByAccessTokenWithNonExistsToken() {
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Incorrect token');
OAuth2Identity::findIdentityByAccessToken('not exists token');
}
public function testFindIdentityByAccessTokenWithExpiredToken() {
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Token expired');
$accessToken = new AccessTokenEntity(mock(AbstractServer::class));
$accessToken->setExpireTime(time() - 3600);
$this->mockFoundedAccessToken($accessToken);
OAuth2Identity::findIdentityByAccessToken('mock-token');
}
private function mockFoundedAccessToken(AccessTokenEntity $accessToken) {
/** @var AccessTokenInterface|\Mockery\MockInterface $accessTokensStorage */
$accessTokensStorage = mock(AccessTokenInterface::class);
$accessTokensStorage->shouldReceive('get')->with('mock-token')->andReturn($accessToken);
/** @var Component|\Mockery\MockInterface $component */
$component = mock(Component::class);
$component->shouldReceive('getAccessTokenStorage')->andReturn($accessTokensStorage);
Yii::$app->set('oauth', $component);
}
}

View File

@@ -21,19 +21,20 @@ class AuthenticationResultTest extends TestCase {
}
public function testGetAsResponse() {
$token = Yii::$app->tokens->create();
$time = time() + 3600;
$token = Yii::$app->tokens->create(['exp' => $time]);
$jwt = (string)$token;
$model = new AuthenticationResult($token);
$result = $model->formatAsOAuth2Response();
$this->assertSame($jwt, $result['access_token']);
$this->assertEqualsWithDelta(3600, $result['expires_in'], 1);
$this->assertSame(3600, $result['expires_in']);
$this->assertArrayNotHasKey('refresh_token', $result);
$model = new AuthenticationResult($token, 'refresh_token');
$result = $model->formatAsOAuth2Response();
$this->assertSame($jwt, $result['access_token']);
$this->assertEqualsWithDelta(3600, $result['expires_in'], 1);
$this->assertSame(3600, $result['expires_in']);
$this->assertSame('refresh_token', $result['refresh_token']);
}

View File

@@ -131,6 +131,7 @@ class LoginFormTest extends TestCase {
'login' => 'erickskrauch',
'password' => '12345678',
'account' => new Account([
'id' => 1,
'username' => 'erickskrauch',
'password' => '12345678',
'status' => Account::STATUS_ACTIVE,

View File

@@ -5,14 +5,12 @@ namespace codeception\api\unit\models\authentication;
use api\models\authentication\RefreshTokenForm;
use api\tests\unit\TestCase;
use Codeception\Specify;
use common\models\AccountSession;
use common\tests\fixtures\AccountSessionFixture;
use Yii;
use yii\web\Request;
class RefreshTokenFormTest extends TestCase {
use Specify;
public function _fixtures(): array {
return [
@@ -21,9 +19,8 @@ class RefreshTokenFormTest extends TestCase {
}
public function testRenew() {
/** @var Request|\Mockery\MockInterface $request */
$request = mock(Request::class . '[getUserIP]')->makePartial();
$request->shouldReceive('getUserIP')->andReturn('10.1.2.3');
$request = $this->createPartialMock(Request::class, ['getUserIP']);
$request->method('getUserIP')->willReturn('10.1.2.3');
Yii::$app->set('request', $request);
$model = new RefreshTokenForm();

View File

@@ -3,139 +3,60 @@ declare(strict_types=1);
namespace codeception\api\unit\modules\authserver\models;
use api\models\authentication\LoginForm;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\models\AuthenticateData;
use api\modules\authserver\models\AuthenticationForm;
use api\tests\unit\TestCase;
use common\models\Account;
use common\models\MinecraftAccessKey;
use common\tests\_support\ProtectedCaller;
use common\models\OauthClient;
use common\models\OauthSession;
use common\tests\fixtures\AccountFixture;
use common\tests\fixtures\MinecraftAccessKeyFixture;
use common\tests\fixtures\OauthClientFixture;
use Ramsey\Uuid\Uuid;
class AuthenticationFormTest extends TestCase {
use ProtectedCaller;
public function _fixtures(): array {
return [
'accounts' => AccountFixture::class,
'minecraftAccessKeys' => MinecraftAccessKeyFixture::class,
'oauthClients' => OauthClientFixture::class,
];
}
public function testAuthenticateByWrongNicknamePass() {
$this->expectException(ForbiddenOperationException::class);
$this->expectExceptionMessage('Invalid credentials. Invalid nickname or password.');
$authForm = $this->createAuthForm();
$authForm->username = 'wrong-username';
$authForm->password = 'wrong-password';
$authForm->clientToken = Uuid::uuid4();
$authForm->authenticate();
}
public function testAuthenticateByWrongEmailPass() {
$this->expectException(ForbiddenOperationException::class);
$this->expectExceptionMessage('Invalid credentials. Invalid email or password.');
$authForm = $this->createAuthForm();
$authForm->username = 'wrong-email@ely.by';
$authForm->password = 'wrong-password';
$authForm->clientToken = Uuid::uuid4();
$authForm->authenticate();
}
public function testAuthenticateByValidCredentialsIntoBlockedAccount() {
$this->expectException(ForbiddenOperationException::class);
$this->expectExceptionMessage('This account has been suspended.');
$authForm = $this->createAuthForm(Account::STATUS_BANNED);
$authForm->username = 'dummy';
$authForm->password = 'password_0';
$authForm->clientToken = Uuid::uuid4();
$authForm->authenticate();
}
public function testAuthenticateByValidCredentials() {
$authForm = $this->createAuthForm();
$minecraftAccessKey = new MinecraftAccessKey();
$minecraftAccessKey->access_token = Uuid::uuid4();
$authForm->expects($this->once())
->method('createMinecraftAccessToken')
->willReturn($minecraftAccessKey);
$authForm->username = 'dummy';
$authForm = new AuthenticationForm();
$authForm->username = 'admin';
$authForm->password = 'password_0';
$authForm->clientToken = Uuid::uuid4();
$result = $authForm->authenticate();
$this->assertInstanceOf(AuthenticateData::class, $result);
$this->assertSame($minecraftAccessKey->access_token, $result->getMinecraftAccessKey()->access_token);
$authForm->clientToken = Uuid::uuid4()->toString();
$result = $authForm->authenticate()->getResponseData();
$this->assertRegExp('/^[\w=-]+\.[\w=-]+\.[\w=-]+$/', $result['accessToken']);
$this->assertSame($authForm->clientToken, $result['clientToken']);
$this->assertSame('df936908-b2e1-544d-96f8-2977ec213022', $result['selectedProfile']['id']);
$this->assertSame('Admin', $result['selectedProfile']['name']);
$this->assertFalse($result['selectedProfile']['legacy']);
$this->assertTrue(OauthSession::find()->andWhere([
'account_id' => 1,
'client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER,
])->exists());
}
public function testCreateMinecraftAccessToken() {
/**
* @dataProvider getInvalidCredentialsCases
*/
public function testAuthenticateByWrongNicknamePass(string $expectedExceptionMessage, string $login, string $password) {
$this->expectException(ForbiddenOperationException::class);
$this->expectExceptionMessage($expectedExceptionMessage);
$authForm = new AuthenticationForm();
$authForm->clientToken = Uuid::uuid4();
/** @var Account $account */
$account = $this->tester->grabFixture('accounts', 'admin');
/** @var MinecraftAccessKey $result */
$result = $this->callProtected($authForm, 'createMinecraftAccessToken', $account);
$this->assertInstanceOf(MinecraftAccessKey::class, $result);
$this->assertSame($account->id, $result->account_id);
$this->assertSame($authForm->clientToken, $result->client_token);
$this->assertInstanceOf(MinecraftAccessKey::class, MinecraftAccessKey::findOne($result->access_token));
$authForm->username = $login;
$authForm->password = $password;
$authForm->clientToken = Uuid::uuid4()->toString();
$authForm->authenticate();
}
public function testCreateMinecraftAccessTokenWithExistsClientId() {
$authForm = new AuthenticationForm();
$minecraftFixture = $this->tester->grabFixture('minecraftAccessKeys', 'admin-token');
$authForm->clientToken = $minecraftFixture['client_token'];
/** @var Account $account */
$account = $this->tester->grabFixture('accounts', 'admin');
/** @var MinecraftAccessKey $result */
$result = $this->callProtected($authForm, 'createMinecraftAccessToken', $account);
$this->assertInstanceOf(MinecraftAccessKey::class, $result);
$this->assertSame($account->id, $result->account_id);
$this->assertSame($authForm->clientToken, $result->client_token);
$this->assertNull(MinecraftAccessKey::findOne($minecraftFixture['access_token']));
$this->assertInstanceOf(MinecraftAccessKey::class, MinecraftAccessKey::findOne($result->access_token));
}
private function createAuthForm($status = Account::STATUS_ACTIVE) {
/** @var LoginForm|\PHPUnit\Framework\MockObject\MockObject $loginForm */
$loginForm = $this->getMockBuilder(LoginForm::class)
->setMethods(['getAccount'])
->getMock();
$account = new Account();
$account->username = 'dummy';
$account->email = 'dummy@ely.by';
$account->status = $status;
$account->setPassword('password_0');
$loginForm
->method('getAccount')
->willReturn($account);
/** @var AuthenticationForm|\PHPUnit\Framework\MockObject\MockObject $authForm */
$authForm = $this->getMockBuilder(AuthenticationForm::class)
->setMethods(['createLoginForm', 'createMinecraftAccessToken'])
->getMock();
$authForm
->method('createLoginForm')
->willReturn($loginForm);
return $authForm;
public function getInvalidCredentialsCases() {
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'];
}
}

View File

@@ -22,10 +22,8 @@ class Yii extends \yii\BaseYii {
* @property \GuzzleHttp\Client $guzzle
* @property \common\components\EmailsRenderer\Component $emailsRenderer
* @property \mito\sentry\Component $sentry
* @property \api\components\OAuth2\Component $oauth
* @property \common\components\StatsD $statsd
* @property \yii\queue\Queue $queue
* @property \api\components\Tokens\Component $tokens
*/
abstract class BaseApplication extends yii\base\Application {
}
@@ -34,8 +32,11 @@ abstract class BaseApplication extends yii\base\Application {
* Class WebApplication
* Include only Web application related components here
*
* @property \api\components\User\Component $user User component.
* @property \api\components\ReCaptcha\Component $reCaptcha
* @property \api\components\User\Component $user
* @property \api\components\ReCaptcha\Component $reCaptcha
* @property \api\components\OAuth2\Component $oauth
* @property \api\components\Tokens\Component $tokens
* @property \api\components\Tokens\TokensFactory $tokensFactory
*
* @method \api\components\User\Component getUser()
*/

Some files were not shown because too many files have changed in this diff Show More