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;
use api\components\OAuth2\Grants\AuthCodeGrant;
use api\components\OAuth2\Grants\RefreshTokenGrant;
use api\components\OAuth2\Keys\EmptyKey;
use DateInterval;
use League\OAuth2\Server\AuthorizationServer;
@ -23,8 +25,8 @@ class Component extends BaseComponent {
if ($this->_authServer === null) {
$clientsRepo = new Repositories\ClientRepository();
$accessTokensRepo = new Repositories\AccessTokenRepository();
$scopesRepo = new Repositories\ScopeRepository();
$publicScopesRepo = new Repositories\PublicScopeRepository();
$internalScopesRepo = new Repositories\InternalScopeRepository();
$authCodesRepo = new Repositories\AuthCodeRepository();
$refreshTokensRepo = new Repositories\RefreshTokenRepository();
@ -33,17 +35,24 @@ class Component extends BaseComponent {
$authServer = new AuthorizationServer(
$clientsRepo,
$accessTokensRepo,
$scopesRepo,
new Repositories\EmptyScopeRepository(),
new EmptyKey(),
'123' // TODO: extract to the variable
);
/** @noinspection PhpUnhandledExceptionInspection */
$authCodeGrant = new Grant\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant->disableRequireCodeChallengeForPublicClients();
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL);
$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;
}

View File

@ -1,44 +1,32 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Entities;
use api\components\OAuth2\Repositories\SessionStorage;
use ErrorException;
use League\OAuth2\Server\Entity\SessionEntity as OriginalSessionEntity;
use api\components\Tokens\TokensFactory;
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;
class AccessTokenEntity extends \League\OAuth2\Server\Entity\AccessTokenEntity {
protected $sessionId;
public function getSessionId() {
return $this->sessionId;
class AccessTokenEntity implements AccessTokenEntityInterface {
use EntityTrait;
use TokenEntityTrait {
getExpiryDateTime as parentGetExpiryDateTime;
}
public function setSessionId($sessionId) {
$this->sessionId = $sessionId;
public function __toString(): string {
// TODO: strip "offline_access" scope from the scopes list
return (string)TokensFactory::createForOAuthClient($this);
}
/**
* @inheritdoc
* @return static
*/
public function setSession(OriginalSessionEntity $session) {
parent::setSession($session);
$this->sessionId = $session->getId();
return $this;
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
}
public function getSession() {
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 getExpiryDateTime() {
// TODO: extend token life depending on scopes list
return $this->parentGetExpiryDateTime();
}
}

View File

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

View File

@ -11,11 +11,24 @@ class ClientEntity implements ClientEntityInterface {
use EntityTrait;
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->name = $name;
$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 {
use EntityTrait;
public function __construct($id) {
public function __construct(int $id) {
$this->identifier = $id;
}

View File

@ -1,239 +1,23 @@
<?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\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;
use api\components\OAuth2\Repositories\PublicScopeRepository;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Grant\AuthCodeGrant as BaseAuthCodeGrant;
class AuthCodeGrant extends AbstractGrant {
class AuthCodeGrant extends BaseAuthCodeGrant {
protected $identifier = 'authorization_code';
protected $responseType = 'code';
protected $authTokenTTL = 600;
protected $requireClientSecret = true;
public function setAuthTokenTTL(int $authTokenTTL): void {
$this->authTokenTTL = $authTokenTTL;
protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface {
foreach ($accessToken->getScopes() as $scope) {
if ($scope->getIdentifier() === PublicScopeRepository::OFFLINE_ACCESS) {
return parent::issueRefreshToken($accessToken);
}
}
public function setRequireClientSecret(bool $required): void {
$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);
return null;
}
}

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
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 League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant;
class RefreshTokenGrant extends AbstractGrant {
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;
}
class RefreshTokenGrant extends BaseRefreshTokenGrant {
/**
* 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);
}
/**
* 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();
protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface {
return null;
}
}

View File

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

View File

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

@ -25,6 +25,10 @@ class ClientRepository implements ClientRepositoryInterface {
return false;
}
if ($client->type !== OauthClient::TYPE_APPLICATION) {
return false;
}
if ($clientSecret !== null && $clientSecret !== $client->secret) {
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 {
private const OFFLINE_ACCESS = 'offline_access';
public const OFFLINE_ACCESS = 'offline_access';
private const CHANGE_SKIN = 'change_skin';
private const ACCOUNT_INFO = 'account_info';
private const ACCOUNT_EMAIL = 'account_email';

View File

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

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\AccountSession;
use Lcobucci\JWT\Token;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use Yii;
class TokensFactory {
public const SUB_ACCOUNT_PREFIX = 'ely|';
public const AUD_CLIENT_PREFIX = 'client|';
public static function createForAccount(Account $account, AccountSession $session = null): Token {
$payloads = [
'ely-scopes' => 'accounts_web_user',
'sub' => self::SUB_ACCOUNT_PREFIX . $account->id,
'sub' => self::buildSub($account->id),
];
if ($session === null) {
// If we don't remember a session, the token should live longer
@ -29,4 +32,27 @@ class TokensFactory {
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 {
$request = $this->getRequest();
$params = (array)$request->getParsedBody();
$clientId = $params['client_id'] ?? '';
$grantType = $params['grant_type'] ?? 'null';
try {
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt");
$responseObj = new Response(200);
$this->server->respondToAccessTokenRequest($request, $responseObj);
$clientId = $params['client_id'];
// TODO: build response from the responseObj
$response = [];
$response = $this->server->respondToAccessTokenRequest($request, new Response(200));
$result = json_decode((string)$response->getBody(), true);
Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}");
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success");
@ -173,10 +170,10 @@ class OauthProcess {
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail");
Yii::$app->response->statusCode = $e->getHttpStatusCode();
$response = $this->buildIssueErrorResponse($e);
$result = $this->buildIssueErrorResponse($e);
}
return $response;
return $result;
}
private function findClient(string $clientId): ?OauthClient {
@ -290,7 +287,7 @@ class OauthProcess {
* information about the parameter that caused the error.
* 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
* @return array
@ -306,6 +303,7 @@ class OauthProcess {
break;
case 'Cannot decrypt the authorization code':
$message .= ' Check the "code" parameter.';
break;
}
return [
@ -328,7 +326,6 @@ class OauthProcess {
}
private function getScopesList(AuthorizationRequest $request): array {
// TODO: replace with an arrow function in PHP 7.4
return array_map(function(ScopeEntityInterface $scope): string {
return $scope->getIdentifier();
}, $request->getScopes());

View File

@ -5,30 +5,10 @@ namespace api\tests\_pages;
/**
* @deprecated
* TODO: remove
*/
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
*/

View File

@ -3,8 +3,7 @@ declare(strict_types=1);
namespace api\tests\functional\_steps;
use api\components\OAuth2\Repositories\ScopeStorage as S;
use api\tests\_pages\OauthRoute;
use api\components\OAuth2\Repositories\PublicScopeRepository;
use api\tests\FunctionalTester;
class OauthSteps extends FunctionalTester {
@ -32,31 +31,29 @@ class OauthSteps extends FunctionalTester {
}
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);
return $response['refresh_token'];
}
public function issueToken($authCode): array {
$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): string {
$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),
]);

View File

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

View File

@ -183,7 +183,7 @@ class AuthCodeCest {
'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([

View File

@ -1,120 +1,87 @@
<?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(OauthSteps $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,83 @@
<?php
declare(strict_types=1);
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\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 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([
'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 +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) {
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'token_type' => 'Bearer',
]);

View File

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

View File

@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace common\models;
use Yii;
@ -24,6 +26,7 @@ use yii\db\ActiveRecord;
* Behaviors:
* @property Account|null $account
* @property OauthSession[] $sessions
* @property-read OauthRefreshToken[] $refreshTokens
*/
class OauthClient extends ActiveRecord {
@ -31,7 +34,7 @@ class OauthClient extends ActiveRecord {
public const TYPE_MINECRAFT_SERVER = 'minecraft-server';
public static function tableName(): string {
return '{{%oauth_clients}}';
return 'oauth_clients';
}
public function behaviors(): array {
@ -55,6 +58,10 @@ class OauthClient extends ActiveRecord {
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 {
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
*
* Relations:
* @property OauthClient $client
* @property Account $account
* @property-read OauthClient $client
* @property-read Account $account
* @property-read OauthRefreshToken[] $refreshTokens
*/
class OauthSession extends ActiveRecord {
@ -43,6 +44,10 @@ class OauthSession extends ActiveRecord {
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 {
if (empty($this->scopes) && $this->legacy_id !== null) {
return Yii::$app->redis->smembers($this->getLegacyRedisScopesKey());

View File

@ -1,15 +1,11 @@
<?php
declare(strict_types=1);
namespace common\tests\_support;
use Codeception\Module;
use Codeception\TestInterface;
use common\tests\fixtures\AccountFixture;
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 common\tests\fixtures;
use yii\test\FixtureTrait;
use yii\test\InitDbFixture;
@ -50,13 +46,14 @@ class FixtureHelper extends Module {
public function fixtures() {
return [
'accounts' => AccountFixture::class,
'accountSessions' => AccountSessionFixture::class,
'emailActivations' => EmailActivationFixture::class,
'usernamesHistory' => UsernameHistoryFixture::class,
'oauthClients' => OauthClientFixture::class,
'oauthSessions' => OauthSessionFixture::class,
'minecraftAccessKeys' => MinecraftAccessKeyFixture::class,
'accounts' => fixtures\AccountFixture::class,
'accountSessions' => fixtures\AccountSessionFixture::class,
'emailActivations' => fixtures\EmailActivationFixture::class,
'usernamesHistory' => fixtures\UsernameHistoryFixture::class,
'oauthClients' => fixtures\OauthClientFixture::class,
'oauthSessions' => fixtures\OauthSessionFixture::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
declare(strict_types=1);
namespace common\tests\fixtures;
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;
class m190914_181236_rework_oauth_sessions_table extends Migration {
class m190914_181236_rework_oauth_related_tables extends Migration {
public function safeUp() {
$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_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE');
$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() {
$this->dropTable('oauth_refresh_tokens');
$this->dropColumn('oauth_sessions', 'scopes');
$this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions');
$this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions');