Restore full functionality of OAuth2 server [skip ci]

This commit is contained in:
ErickSkrauch 2019-09-22 00:17:21 +03:00
parent 45101d6453
commit 5536c34b9c
39 changed files with 506 additions and 1157 deletions

View File

@ -3,6 +3,8 @@ declare(strict_types=1);
namespace api\components\OAuth2; namespace api\components\OAuth2;
use api\components\OAuth2\Grants\AuthCodeGrant;
use api\components\OAuth2\Grants\RefreshTokenGrant;
use api\components\OAuth2\Keys\EmptyKey; use api\components\OAuth2\Keys\EmptyKey;
use DateInterval; use DateInterval;
use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\AuthorizationServer;
@ -23,8 +25,8 @@ class Component extends BaseComponent {
if ($this->_authServer === null) { if ($this->_authServer === null) {
$clientsRepo = new Repositories\ClientRepository(); $clientsRepo = new Repositories\ClientRepository();
$accessTokensRepo = new Repositories\AccessTokenRepository(); $accessTokensRepo = new Repositories\AccessTokenRepository();
$scopesRepo = new Repositories\ScopeRepository();
$publicScopesRepo = new Repositories\PublicScopeRepository(); $publicScopesRepo = new Repositories\PublicScopeRepository();
$internalScopesRepo = new Repositories\InternalScopeRepository();
$authCodesRepo = new Repositories\AuthCodeRepository(); $authCodesRepo = new Repositories\AuthCodeRepository();
$refreshTokensRepo = new Repositories\RefreshTokenRepository(); $refreshTokensRepo = new Repositories\RefreshTokenRepository();
@ -33,17 +35,24 @@ class Component extends BaseComponent {
$authServer = new AuthorizationServer( $authServer = new AuthorizationServer(
$clientsRepo, $clientsRepo,
$accessTokensRepo, $accessTokensRepo,
$scopesRepo, new Repositories\EmptyScopeRepository(),
new EmptyKey(), new EmptyKey(),
'123' // TODO: extract to the variable '123' // TODO: extract to the variable
); );
/** @noinspection PhpUnhandledExceptionInspection */ /** @noinspection PhpUnhandledExceptionInspection */
$authCodeGrant = new Grant\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); $authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant->disableRequireCodeChallengeForPublicClients(); $authCodeGrant->disableRequireCodeChallengeForPublicClients();
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL); $authServer->enableGrantType($authCodeGrant, $accessTokenTTL);
$authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling $authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$authServer->enableGrantType(new Grant\RefreshTokenGrant($refreshTokensRepo), $accessTokenTTL);
$authServer->enableGrantType(new Grant\ClientCredentialsGrant(), $accessTokenTTL); // TODO: extends refresh token life time to forever
$refreshTokenGrant = new RefreshTokenGrant($refreshTokensRepo);
$authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL);
$refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$clientCredentialsGrant = new Grant\ClientCredentialsGrant();
$authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
$this->_authServer = $authServer; $this->_authServer = $authServer;
} }

View File

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

View File

@ -13,6 +13,4 @@ class AuthCodeEntity implements AuthCodeEntityInterface {
use AuthCodeTrait; use AuthCodeTrait;
use TokenEntityTrait; use TokenEntityTrait;
// TODO: constructor
} }

View File

@ -11,11 +11,24 @@ class ClientEntity implements ClientEntityInterface {
use EntityTrait; use EntityTrait;
use ClientTrait; use ClientTrait;
public function __construct(string $id, string $name, $redirectUri, bool $isTrusted = false) { /**
* @var bool
*/
private $isTrusted;
public function __construct(string $id, string $name, $redirectUri, bool $isTrusted) {
$this->identifier = $id; $this->identifier = $id;
$this->name = $name; $this->name = $name;
$this->redirectUri = $redirectUri; $this->redirectUri = $redirectUri;
$this->isConfidential = $isTrusted; $this->isTrusted = $isTrusted;
}
public function isConfidential(): bool {
return true;
}
public function isTrusted(): bool {
return $this->isTrusted;
} }
} }

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

@ -9,7 +9,7 @@ use League\OAuth2\Server\Entities\UserEntityInterface;
class UserEntity implements UserEntityInterface { class UserEntity implements UserEntityInterface {
use EntityTrait; use EntityTrait;
public function __construct($id) { public function __construct(int $id) {
$this->identifier = $id; $this->identifier = $id;
} }

View File

@ -1,239 +1,23 @@
<?php <?php
declare(strict_types=1);
namespace api\components\OAuth2\Grants; namespace api\components\OAuth2\Grants;
use api\components\OAuth2\Entities\AccessTokenEntity; use api\components\OAuth2\Repositories\PublicScopeRepository;
use api\components\OAuth2\Entities\AuthCodeEntity; use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use api\components\OAuth2\Entities\ClientEntity; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use api\components\OAuth2\Entities\RefreshTokenEntity; use League\OAuth2\Server\Grant\AuthCodeGrant as BaseAuthCodeGrant;
use api\components\OAuth2\Entities\SessionEntity;
use api\components\OAuth2\Repositories\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;
class AuthCodeGrant extends AbstractGrant { class AuthCodeGrant extends BaseAuthCodeGrant {
protected $identifier = 'authorization_code'; protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface {
foreach ($accessToken->getScopes() as $scope) {
protected $responseType = 'code'; if ($scope->getIdentifier() === PublicScopeRepository::OFFLINE_ACCESS) {
return parent::issueRefreshToken($accessToken);
protected $authTokenTTL = 600; }
protected $requireClientSecret = true;
public function setAuthTokenTTL(int $authTokenTTL): void {
$this->authTokenTTL = $authTokenTTL;
} }
public function setRequireClientSecret(bool $required): void { return null;
$this->requireClientSecret = $required;
}
public function shouldRequireClientSecret(): bool {
return $this->requireClientSecret;
}
/**
* Check authorize parameters
*
* @return AuthorizeParams Authorize request parameters
* @throws Exception\OAuthException
*
* @throws
*/
public function checkAuthorizeParams(): AuthorizeParams {
// Get required params
$clientId = $this->server->getRequest()->query->get('client_id');
if ($clientId === null) {
throw new Exception\InvalidRequestException('client_id');
}
$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);
} }
} }

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 +0,0 @@
<?php
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;
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);
}
}

View File

@ -1,183 +1,25 @@
<?php <?php
declare(strict_types=1);
namespace api\components\OAuth2\Grants; namespace api\components\OAuth2\Grants;
use api\components\OAuth2\Entities\AccessTokenEntity; use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use api\components\OAuth2\Entities\RefreshTokenEntity; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use api\components\OAuth2\Utils\Scopes; use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant;
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;
class RefreshTokenGrant extends AbstractGrant { class RefreshTokenGrant extends BaseRefreshTokenGrant {
protected $identifier = 'refresh_token';
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;
}
/** /**
* In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes * Currently we're not rotating refresh tokens.
* list, while by OAuth2 standard it they should be separated by a space. Shit happens :) * So we overriding this method to always return null, which means,
* So override scopes validation function to reformat passed value. * that refresh_token will not be issued.
* *
* @param string $scopeParam * @param AccessTokenEntityInterface $accessToken
* @param BaseClientEntity $client
* @param string $redirectUri
* *
* @return \League\OAuth2\Server\Entity\ScopeEntity[] * @return RefreshTokenEntityInterface|null
*/ */
public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) { protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface {
return parent::validateScopes(Scopes::format($scopeParam), $client, $redirectUri); 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
*/
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',
$this->server->getRequest()->getPassword()
);
if ($clientSecret === null && $this->shouldRequireClientSecret()) {
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) === false) {
$this->server->getEventEmitter()->emit(new ClientAuthenticationFailedEvent($this->server->getRequest()));
throw new Exception\InvalidClientException();
}
$oldRefreshTokenParam = $this->server->getRequest()->request->get('refresh_token');
if ($oldRefreshTokenParam === null) {
throw new Exception\InvalidRequestException('refresh_token');
}
// Validate refresh token
$oldRefreshToken = $this->server->getRefreshTokenStorage()->get($oldRefreshTokenParam);
if (($oldRefreshToken instanceof BaseRefreshTokenEntity) === false) {
throw new Exception\InvalidRefreshException();
}
// Ensure the old refresh token hasn't expired
if ($oldRefreshToken->isExpired()) {
throw new Exception\InvalidRefreshException();
}
/** @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);
}
$session = $oldRefreshToken->getSession();
}
$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();
} }
} }

View File

@ -12,7 +12,7 @@ class EmptyKey implements CryptKeyInterface {
} }
public function getPassPhrase(): ?string { public function getPassPhrase(): ?string {
return ''; return null;
} }
} }

View File

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace api\components\OAuth2\Repositories; namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\AccessTokenEntity;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
@ -13,44 +14,36 @@ class AccessTokenRepository implements AccessTokenRepositoryInterface {
* Create a new access token * Create a new access token
* *
* @param ClientEntityInterface $clientEntity * @param ClientEntityInterface $clientEntity
* @param \League\OAuth2\Server\Entities\ScopeEntityInterface $scopes * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes
* @param mixed $userIdentifier * @param mixed $userIdentifier
* *
* @return AccessTokenEntityInterface * @return AccessTokenEntityInterface
*/ */
public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null) { public function getNewToken(
// TODO: Implement getNewToken() method. 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;
* Persists a new access token to permanent storage.
*
* @param AccessTokenEntityInterface $accessTokenEntity
*
* @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException
*/
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) {
// TODO: Implement persistNewAccessToken() method.
} }
/** public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void {
* Revoke an access token. // We don't store access tokens, so there's no need to do anything here
*
* @param string $tokenId
*/
public function revokeAccessToken($tokenId) {
// TODO: Implement revokeAccessToken() method.
} }
/** public function revokeAccessToken($tokenId): void {
* Check if the access token has been revoked. // We don't store access tokens, so there's no need to do anything here
* }
* @param string $tokenId
* public function isAccessTokenRevoked($tokenId): bool {
* @return bool Return true if this token has been revoked return false;
*/
public function isAccessTokenRevoked($tokenId) {
// TODO: Implement isAccessTokenRevoked() method.
} }
} }

View File

@ -25,6 +25,10 @@ class ClientRepository implements ClientRepositoryInterface {
return false; return false;
} }
if ($client->type !== OauthClient::TYPE_APPLICATION) {
return false;
}
if ($clientSecret !== null && $clientSecret !== $client->secret) { if ($clientSecret !== null && $clientSecret !== $client->secret) {
return false; return false;
} }

View File

@ -1,80 +0,0 @@
<?php
namespace api\components\OAuth2\Repositories;
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

@ -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,54 @@
<?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,
];
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
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;
}
}

View File

@ -11,7 +11,8 @@ use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
class PublicScopeRepository implements ScopeRepositoryInterface { class PublicScopeRepository implements ScopeRepositoryInterface {
private const OFFLINE_ACCESS = 'offline_access'; public const OFFLINE_ACCESS = 'offline_access';
private const CHANGE_SKIN = 'change_skin'; private const CHANGE_SKIN = 'change_skin';
private const ACCOUNT_INFO = 'account_info'; private const ACCOUNT_INFO = 'account_info';
private const ACCOUNT_EMAIL = 'account_email'; private const ACCOUNT_EMAIL = 'account_email';

View File

@ -3,49 +3,35 @@ declare(strict_types=1);
namespace api\components\OAuth2\Repositories; namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\RefreshTokenEntity;
use common\models\OauthRefreshToken;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use Webmozart\Assert\Assert;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface { class RefreshTokenRepository implements RefreshTokenRepositoryInterface {
/** public function getNewRefreshToken(): ?RefreshTokenEntityInterface {
* Creates a new refresh token return new RefreshTokenEntity();
*
* @return RefreshTokenEntityInterface|null
*/
public function getNewRefreshToken(): RefreshTokenEntityInterface {
// TODO: Implement getNewRefreshToken() method.
} }
/** public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void {
* Create a new refresh token_name. $model = new OauthRefreshToken();
* $model->id = $refreshTokenEntity->getIdentifier();
* @param RefreshTokenEntityInterface $refreshTokenEntity $model->account_id = $refreshTokenEntity->getAccessToken()->getUserIdentifier();
* $model->client_id = $refreshTokenEntity->getAccessToken()->getClient()->getIdentifier();
* @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException
*/ Assert::true($model->save());
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity) {
// TODO: Implement persistNewRefreshToken() method.
} }
/** public function revokeRefreshToken($tokenId): void {
* Revoke the refresh token. // Currently we're not rotating refresh tokens so do not revoke
* // token during any OAuth2 grant
* @param string $tokenId
*/
public function revokeRefreshToken($tokenId) {
// TODO: Implement revokeRefreshToken() method.
} }
/** public function isRefreshTokenRevoked($tokenId): bool {
* Check if the refresh token has been revoked. // TODO: validate old refresh tokens
* return !OauthRefreshToken::find()->andWhere(['id' => $tokenId])->exists();
* @param string $tokenId
*
* @return bool Return true if this token has been revoked
*/
public function isRefreshTokenRevoked($tokenId) {
// TODO: Implement isRefreshTokenRevoked() method.
} }
} }

View File

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\ScopeEntity;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
class ScopeRepository implements ScopeRepositoryInterface {
public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface {
// TODO: validate not exists scopes
return new ScopeEntity($identifier);
}
/**
* Given a client, grant type and optional user identifier validate the set of scopes requested are valid and optionally
* append additional scopes or remove requested scopes.
*
* @param ScopeEntityInterface $scopes
* @param string $grantType
* @param \League\OAuth2\Server\Entities\ClientEntityInterface $clientEntity
* @param null|string $userIdentifier
*
* @return ScopeEntityInterface
*/
public function finalizeScopes(
array $scopes,
$grantType,
\League\OAuth2\Server\Entities\ClientEntityInterface $clientEntity,
$userIdentifier = null
): array {
// TODO: Implement finalizeScopes() method.
}
}

View File

@ -1,93 +0,0 @@
<?php
namespace api\components\OAuth2\Repositories;
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

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Traits;
trait ValidateScopesTrait {
public function validateScopes($scopes, $redirectUri = null): array {
return parent::validateScopes($scopes, $redirectUri = null);
}
}

View File

@ -7,16 +7,19 @@ use Carbon\Carbon;
use common\models\Account; use common\models\Account;
use common\models\AccountSession; use common\models\AccountSession;
use Lcobucci\JWT\Token; use Lcobucci\JWT\Token;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use Yii; use Yii;
class TokensFactory { class TokensFactory {
public const SUB_ACCOUNT_PREFIX = 'ely|'; public const SUB_ACCOUNT_PREFIX = 'ely|';
public const AUD_CLIENT_PREFIX = 'client|';
public static function createForAccount(Account $account, AccountSession $session = null): Token { public static function createForAccount(Account $account, AccountSession $session = null): Token {
$payloads = [ $payloads = [
'ely-scopes' => 'accounts_web_user', 'ely-scopes' => 'accounts_web_user',
'sub' => self::SUB_ACCOUNT_PREFIX . $account->id, 'sub' => self::buildSub($account->id),
]; ];
if ($session === null) { if ($session === null) {
// If we don't remember a session, the token should live longer // If we don't remember a session, the token should live longer
@ -29,4 +32,27 @@ class TokensFactory {
return Yii::$app->tokens->create($payloads); return Yii::$app->tokens->create($payloads);
} }
public static function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token {
$payloads = [
'aud' => self::buildAud($accessToken->getClient()->getIdentifier()),
'ely-scopes' => array_map(static function(ScopeEntityInterface $scope): string {
return $scope->getIdentifier();
}, $accessToken->getScopes()),
'exp' => $accessToken->getExpiryDateTime()->getTimestamp(),
];
if ($accessToken->getUserIdentifier() !== null) {
$payloads['sub'] = self::buildSub($accessToken->getUserIdentifier());
}
return Yii::$app->tokens->create($payloads);
}
private static function buildSub(int $accountId): string {
return self::SUB_ACCOUNT_PREFIX . $accountId;
}
private static function buildAud(string $clientId): string {
return self::AUD_CLIENT_PREFIX . $clientId;
}
} }

View File

@ -156,16 +156,13 @@ class OauthProcess {
public function getToken(): array { public function getToken(): array {
$request = $this->getRequest(); $request = $this->getRequest();
$params = (array)$request->getParsedBody(); $params = (array)$request->getParsedBody();
$clientId = $params['client_id'] ?? '';
$grantType = $params['grant_type'] ?? 'null'; $grantType = $params['grant_type'] ?? 'null';
try { try {
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt"); Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt");
$responseObj = new Response(200); $response = $this->server->respondToAccessTokenRequest($request, new Response(200));
$this->server->respondToAccessTokenRequest($request, $responseObj); $result = json_decode((string)$response->getBody(), true);
$clientId = $params['client_id'];
// TODO: build response from the responseObj
$response = [];
Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}"); Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}");
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success"); Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success");
@ -173,10 +170,10 @@ class OauthProcess {
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail"); Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail");
Yii::$app->response->statusCode = $e->getHttpStatusCode(); Yii::$app->response->statusCode = $e->getHttpStatusCode();
$response = $this->buildIssueErrorResponse($e); $result = $this->buildIssueErrorResponse($e);
} }
return $response; return $result;
} }
private function findClient(string $clientId): ?OauthClient { private function findClient(string $clientId): ?OauthClient {
@ -290,7 +287,7 @@ class OauthProcess {
* information about the parameter that caused the error. * information about the parameter that caused the error.
* This method is intended to build a more understandable description. * This method is intended to build a more understandable description.
* *
* Part of the existing texts is a legacy from the previous implementation. * Part of the existing texts are the legacy from the previous implementation.
* *
* @param OAuthServerException $e * @param OAuthServerException $e
* @return array * @return array
@ -306,6 +303,7 @@ class OauthProcess {
break; break;
case 'Cannot decrypt the authorization code': case 'Cannot decrypt the authorization code':
$message .= ' Check the "code" parameter.'; $message .= ' Check the "code" parameter.';
break;
} }
return [ return [
@ -328,7 +326,6 @@ class OauthProcess {
} }
private function getScopesList(AuthorizationRequest $request): array { private function getScopesList(AuthorizationRequest $request): array {
// TODO: replace with an arrow function in PHP 7.4
return array_map(function(ScopeEntityInterface $scope): string { return array_map(function(ScopeEntityInterface $scope): string {
return $scope->getIdentifier(); return $scope->getIdentifier();
}, $request->getScopes()); }, $request->getScopes());

View File

@ -5,30 +5,10 @@ namespace api\tests\_pages;
/** /**
* @deprecated * @deprecated
* TODO: remove
*/ */
class OauthRoute extends BasePage { class OauthRoute extends BasePage {
/**
* @deprecated
*/
public function validate(array $queryParams): void {
$this->getActor()->sendGET('/api/oauth2/v1/validate', $queryParams);
}
/**
* @deprecated
*/
public function complete(array $queryParams = [], array $postParams = []): void {
$this->getActor()->sendPOST('/api/oauth2/v1/complete?' . http_build_query($queryParams), $postParams);
}
/**
* @deprecated
*/
public function issueToken(array $postParams = []): void {
$this->getActor()->sendPOST('/api/oauth2/v1/token', $postParams);
}
/** /**
* @deprecated * @deprecated
*/ */

View File

@ -3,8 +3,7 @@ declare(strict_types=1);
namespace api\tests\functional\_steps; namespace api\tests\functional\_steps;
use api\components\OAuth2\Repositories\ScopeStorage as S; use api\components\OAuth2\Repositories\PublicScopeRepository;
use api\tests\_pages\OauthRoute;
use api\tests\FunctionalTester; use api\tests\FunctionalTester;
class OauthSteps extends FunctionalTester { class OauthSteps extends FunctionalTester {
@ -32,31 +31,29 @@ class OauthSteps extends FunctionalTester {
} }
public function getRefreshToken(array $permissions = []): string { public function getRefreshToken(array $permissions = []): string {
$authCode = $this->obtainAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions));
$response = $this->issueToken($authCode); $response = $this->issueToken($authCode);
return $response['refresh_token']; return $response['refresh_token'];
} }
public function issueToken($authCode): array { public function issueToken(string $authCode): array {
$route = new OauthRoute($this); $this->sendPOST('/api/oauth2/v1/token', [
$route->issueToken([ 'grant_type' => 'authorization_code',
'code' => $authCode, 'code' => $authCode,
'client_id' => 'ely', 'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'redirect_uri' => 'http://ely.by', 'redirect_uri' => 'http://ely.by',
'grant_type' => 'authorization_code',
]); ]);
return json_decode($this->grabResponse(), true); return json_decode($this->grabResponse(), true);
} }
public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true): string { public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string {
$route = new OauthRoute($this); $this->sendPOST('/api/oauth2/v1/token', [
$route->issueToken([ 'grant_type' => 'client_credentials',
'client_id' => $useTrusted ? 'trusted-client' : 'default-client', 'client_id' => $useTrusted ? 'trusted-client' : 'default-client',
'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', 'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W',
'grant_type' => 'client_credentials',
'scope' => implode(',', $permissions), 'scope' => implode(',', $permissions),
]); ]);

View File

@ -81,10 +81,10 @@ class AccessTokenCest {
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'redirect_uri' => 'http://some-other.domain', 'redirect_uri' => 'http://some-other.domain',
]); ]);
$I->canSeeResponseCodeIs(400); $I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([ $I->canSeeResponseContainsJson([
'error' => 'invalid_client', 'error' => 'invalid_client',
'message' => 'Client authentication failed.', 'message' => 'Client authentication failed',
]); ]);
} }

View File

@ -183,7 +183,7 @@ class AuthCodeCest {
'redirect_uri' => 'http://ely.by', 'redirect_uri' => 'http://ely.by',
'response_type' => 'code', 'response_type' => 'code',
'scope' => 'minecraft_server_session block_account', 'scope' => 'minecraft_server_session block_account',
])); ]), ['accept' => true]); // TODO: maybe remove?
$I->canSeeResponseCodeIs(400); $I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson(); $I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([ $I->canSeeResponseContainsJson([

View File

@ -1,120 +1,87 @@
<?php <?php
declare(strict_types=1);
namespace api\tests\functional\oauth; namespace api\tests\functional\oauth;
use api\tests\_pages\OauthRoute;
use api\tests\functional\_steps\OauthSteps; use api\tests\functional\_steps\OauthSteps;
use api\tests\FunctionalTester; use api\tests\FunctionalTester;
class ClientCredentialsCest { class ClientCredentialsCest {
/** public function issueTokenWithPublicScopes(FunctionalTester $I) {
* @var OauthRoute $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
private $route; $I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials',
public function _before(FunctionalTester $I) { 'client_id' => 'ely',
$this->route = new OauthRoute($I); 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => '',
]);
$this->assertSuccessResponse($I);
} }
public function testIssueTokenWithWrongArgs(FunctionalTester $I) { public function issueTokenWithInternalScopesAsNotTrustedClient(FunctionalTester $I) {
$I->wantTo('check behavior on on request without any credentials'); $I->wantTo('issue token as not trusted client and require some internal scope');
$this->route->issueToken($this->buildParams()); $I->sendPOST('/api/oauth2/v1/token', [
$I->canSeeResponseCodeIs(400); 'grant_type' => 'client_credentials',
$I->canSeeResponseContainsJson([ 'client_id' => 'ely',
'error' => 'invalid_request', '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->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson(); $I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([ $I->canSeeResponseContainsJson([
'error' => 'invalid_scope', 'error' => 'invalid_scope',
]); ]);
}
$this->route->issueToken($this->buildParams( public function issueTokenWithInternalScopesAsTrustedClient(OauthSteps $I) {
'trusted-client', $I->wantTo('issue token as trusted client and require some internal scope');
'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', $I->sendPOST('/api/oauth2/v1/token', [
['account_block'] '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->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([ $I->canSeeResponseContainsJson([
'token_type' => 'Bearer', 'token_type' => 'Bearer',
]); ]);
$I->canSeeResponseJsonMatchesJsonPath('$.access_token'); $I->canSeeResponseJsonMatchesJsonPath('$.access_token');
$I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); $I->canSeeResponseJsonMatchesJsonPath('$.expires_in');
} $I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token');
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;
} }
} }

View File

@ -1,83 +1,83 @@
<?php <?php
declare(strict_types=1);
namespace api\tests\functional\oauth; namespace api\tests\functional\oauth;
use api\components\OAuth2\Repositories\ScopeStorage as S;
use api\rbac\Permissions as P;
use api\tests\_pages\OauthRoute;
use api\tests\functional\_steps\OauthSteps; use api\tests\functional\_steps\OauthSteps;
use api\tests\FunctionalTester;
class RefreshTokenCest { class RefreshTokenCest {
/** public function refreshToken(OauthSteps $I) {
* @var OauthRoute $I->wantTo('refresh token without passing the desired scopes');
*/ $refreshToken = $I->getRefreshToken();
private $route; $I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'refresh_token',
public function _before(FunctionalTester $I) { 'refresh_token' => $refreshToken,
$this->route = new OauthRoute($I); 'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
]);
$this->canSeeRefreshTokenSuccess($I);
} }
public function testInvalidRefreshToken(OauthSteps $I) { public function refreshTokenWithSameScopes(OauthSteps $I) {
$this->route->issueToken($this->buildParams( $refreshToken = $I->getRefreshToken(['minecraft_server_session']);
'some-invalid-refresh-token', $I->sendPOST('/api/oauth2/v1/token', [
'ely', 'grant_type' => 'refresh_token',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM' 'refresh_token' => $refreshToken,
)); 'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'minecraft_server_session offline_access',
]);
$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 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([ $I->canSeeResponseContainsJson([
'error' => 'invalid_request', 'error' => 'invalid_request',
'message' => 'The refresh token is invalid.', 'message' => 'The refresh token is invalid.',
]); ]);
} }
public function testRefreshToken(OauthSteps $I) { public function requireNewScopes(OauthSteps $I) {
$refreshToken = $I->getRefreshToken(); $I->wantToTest('behavior when required the new scope that was not issued with original token');
$this->route->issueToken($this->buildParams( $refreshToken = $I->getRefreshToken(['minecraft_server_session']);
$refreshToken, $I->sendPOST('/api/oauth2/v1/token', [
'ely', 'grant_type' => 'refresh_token',
'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM' 'refresh_token' => $refreshToken,
)); 'client_id' => 'ely',
$this->canSeeRefreshTokenSuccess($I); 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
} 'scope' => 'minecraft_server_session account_email',
]);
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']
));
$I->canSeeResponseCodeIs(400); $I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson(); $I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([ $I->canSeeResponseContainsJson([
@ -85,34 +85,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(OauthSteps $I) {
$I->canSeeResponseCodeIs(200); $I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([ $I->canSeeResponseContainsJson([
'token_type' => 'Bearer', 'token_type' => 'Bearer',
]); ]);

View File

@ -41,6 +41,7 @@ use const common\LATEST_RULES_VERSION;
* @property UsernameHistory[] $usernameHistory * @property UsernameHistory[] $usernameHistory
* @property AccountSession[] $sessions * @property AccountSession[] $sessions
* @property MinecraftAccessKey[] $minecraftAccessKeys * @property MinecraftAccessKey[] $minecraftAccessKeys
* @property-read OauthRefreshToken[] $oauthRefreshTokens
* *
* Behaviors: * Behaviors:
* @mixin TimestampBehavior * @mixin TimestampBehavior
@ -101,6 +102,10 @@ class Account extends ActiveRecord {
return $this->hasMany(OauthClient::class, ['account_id' => 'id']); return $this->hasMany(OauthClient::class, ['account_id' => 'id']);
} }
public function getOauthRefreshTokens(): ActiveQuery {
return $this->hasMany(OauthRefreshToken::class, ['account_id' => 'id']);
}
public function getUsernameHistory(): ActiveQuery { public function getUsernameHistory(): ActiveQuery {
return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']); return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']);
} }

View File

@ -1,4 +1,6 @@
<?php <?php
declare(strict_types=1);
namespace common\models; namespace common\models;
use Yii; use Yii;
@ -24,6 +26,7 @@ use yii\db\ActiveRecord;
* Behaviors: * Behaviors:
* @property Account|null $account * @property Account|null $account
* @property OauthSession[] $sessions * @property OauthSession[] $sessions
* @property-read OauthRefreshToken[] $refreshTokens
*/ */
class OauthClient extends ActiveRecord { class OauthClient extends ActiveRecord {
@ -31,7 +34,7 @@ class OauthClient extends ActiveRecord {
public const TYPE_MINECRAFT_SERVER = 'minecraft-server'; public const TYPE_MINECRAFT_SERVER = 'minecraft-server';
public static function tableName(): string { public static function tableName(): string {
return '{{%oauth_clients}}'; return 'oauth_clients';
} }
public function behaviors(): array { public function behaviors(): array {
@ -55,6 +58,10 @@ class OauthClient extends ActiveRecord {
return $this->hasMany(OauthSession::class, ['client_id' => 'id']); return $this->hasMany(OauthSession::class, ['client_id' => 'id']);
} }
public function getRefreshTokens(): ActiveQuery {
return $this->hasMany(OauthRefreshToken::class, ['client_id' => 'id']);
}
public static function find(): OauthClientQuery { public static function find(): OauthClientQuery {
return Yii::createObject(OauthClientQuery::class, [static::class]); return Yii::createObject(OauthClientQuery::class, [static::class]);
} }

View File

@ -1,23 +0,0 @@
<?php
namespace common\models;
final class OauthOwnerType {
/**
* Used for sessions belonging directly to account.ely.by users
* who have performed password authentication and are using the web interface
*/
public const ACCOUNT = 'accounts';
/**
* Used when a user uses OAuth2 authorization_code protocol to allow an application
* to access and perform actions on its own behalf
*/
public const USER = 'user';
/**
* Used for clients authorized via OAuth2 client_credentials protocol
*/
public const CLIENT = 'client';
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace common\models;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
/**
* Fields:
* @property string $id
* @property int $account_id
* @property int $client_id
* @property int $issued_at
*
* Relations:
* @property-read OauthSession $session
* @property-read Account $account
* @property-read OauthClient $client
*/
class OauthRefreshToken extends ActiveRecord {
public static function tableName(): string {
return 'oauth_refresh_tokens';
}
public function behaviors(): array {
return [
[
'class' => TimestampBehavior::class,
'createdAtAttribute' => 'issued_at',
'updatedAtAttribute' => false,
],
];
}
public function getSession(): ActiveQuery {
return $this->hasOne(OauthSession::class, ['account_id' => 'account_id', 'client_id' => 'client_id']);
}
public function getAccount(): ActiveQuery {
return $this->hasOne(Account::class, ['id' => 'account_id']);
}
public function getClient(): ActiveQuery {
return $this->hasOne(OauthClient::class, ['id' => 'client_id']);
}
}

View File

@ -17,8 +17,9 @@ use yii\db\ActiveRecord;
* @property integer $created_at * @property integer $created_at
* *
* Relations: * Relations:
* @property OauthClient $client * @property-read OauthClient $client
* @property Account $account * @property-read Account $account
* @property-read OauthRefreshToken[] $refreshTokens
*/ */
class OauthSession extends ActiveRecord { class OauthSession extends ActiveRecord {
@ -43,6 +44,10 @@ class OauthSession extends ActiveRecord {
return $this->hasOne(Account::class, ['id' => 'owner_id']); return $this->hasOne(Account::class, ['id' => 'owner_id']);
} }
public function getRefreshTokens(): ActiveQuery {
return $this->hasMany(OauthRefreshToken::class, ['account_id' => 'account_id', 'client_id' => 'client_id']);
}
public function getScopes(): array { public function getScopes(): array {
if (empty($this->scopes) && $this->legacy_id !== null) { if (empty($this->scopes) && $this->legacy_id !== null) {
return Yii::$app->redis->smembers($this->getLegacyRedisScopesKey()); return Yii::$app->redis->smembers($this->getLegacyRedisScopesKey());

View File

@ -1,15 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace common\tests\_support; namespace common\tests\_support;
use Codeception\Module; use Codeception\Module;
use Codeception\TestInterface; use Codeception\TestInterface;
use common\tests\fixtures\AccountFixture; use common\tests\fixtures;
use common\tests\fixtures\AccountSessionFixture;
use common\tests\fixtures\EmailActivationFixture;
use common\tests\fixtures\MinecraftAccessKeyFixture;
use common\tests\fixtures\OauthClientFixture;
use common\tests\fixtures\OauthSessionFixture;
use common\tests\fixtures\UsernameHistoryFixture;
use yii\test\FixtureTrait; use yii\test\FixtureTrait;
use yii\test\InitDbFixture; use yii\test\InitDbFixture;
@ -50,13 +46,14 @@ class FixtureHelper extends Module {
public function fixtures() { public function fixtures() {
return [ return [
'accounts' => AccountFixture::class, 'accounts' => fixtures\AccountFixture::class,
'accountSessions' => AccountSessionFixture::class, 'accountSessions' => fixtures\AccountSessionFixture::class,
'emailActivations' => EmailActivationFixture::class, 'emailActivations' => fixtures\EmailActivationFixture::class,
'usernamesHistory' => UsernameHistoryFixture::class, 'usernamesHistory' => fixtures\UsernameHistoryFixture::class,
'oauthClients' => OauthClientFixture::class, 'oauthClients' => fixtures\OauthClientFixture::class,
'oauthSessions' => OauthSessionFixture::class, 'oauthSessions' => fixtures\OauthSessionFixture::class,
'minecraftAccessKeys' => MinecraftAccessKeyFixture::class, 'oauthRefreshTokens' => fixtures\OauthRefreshTokensFixture::class,
'minecraftAccessKeys' => fixtures\MinecraftAccessKeyFixture::class,
]; ];
} }

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace common\tests\fixtures;
use common\models\OauthRefreshToken;
use yii\test\ActiveFixture;
class OauthRefreshTokensFixture extends ActiveFixture {
public $modelClass = OauthRefreshToken::class;
public $dataFile = '@root/common/tests/fixtures/data/oauth-refresh-tokens.php';
public $depends = [
OauthSessionFixture::class,
];
}

View File

@ -1,4 +1,6 @@
<?php <?php
declare(strict_types=1);
namespace common\tests\fixtures; namespace common\tests\fixtures;
use common\models\OauthSession; use common\models\OauthSession;

View File

@ -0,0 +1,2 @@
<?php
return [];

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
use console\db\Migration; use console\db\Migration;
class m190914_181236_rework_oauth_sessions_table extends Migration { class m190914_181236_rework_oauth_related_tables extends Migration {
public function safeUp() { public function safeUp() {
$this->delete('oauth_sessions', ['NOT', ['owner_type' => 'user']]); $this->delete('oauth_sessions', ['NOT', ['owner_type' => 'user']]);
@ -33,9 +33,20 @@ class m190914_181236_rework_oauth_sessions_table extends Migration {
$this->addForeignKey('FK_oauth_session_to_account', 'oauth_sessions', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('FK_oauth_session_to_account', 'oauth_sessions', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE');
$this->addForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE');
$this->addColumn('oauth_sessions', 'scopes', $this->json()->toString('scopes') . ' AFTER `legacy_id`'); $this->addColumn('oauth_sessions', 'scopes', $this->json()->toString('scopes') . ' AFTER `legacy_id`');
$this->createTable('oauth_refresh_tokens', [
'id' => $this->string(80)->notNull()->unique(),
'account_id' => $this->db->getTableSchema('oauth_sessions', true)->getColumn('account_id')->dbType . ' NOT NULL',
'client_id' => $this->db->getTableSchema('oauth_sessions', true)->getColumn('client_id')->dbType . ' NOT NULL',
'issued_at' => $this->integer(11)->unsigned()->notNull(),
$this->primary('id'),
]);
$this->addForeignKey('FK_oauth_refresh_token_to_oauth_session', 'oauth_refresh_tokens', ['account_id', 'client_id'], 'oauth_sessions', ['account_id', 'client_id'], 'CASCADE');
} }
public function safeDown() { public function safeDown() {
$this->dropTable('oauth_refresh_tokens');
$this->dropColumn('oauth_sessions', 'scopes'); $this->dropColumn('oauth_sessions', 'scopes');
$this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions');
$this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions');