diff options
| author | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2022-07-15 11:47:35 +0000 |
|---|---|---|
| committer | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2022-07-15 11:47:35 +0000 |
| commit | 55852ef4819e5eafce9ae53dc4de2d84cdad1778 (patch) | |
| tree | 9aedcdf89f416a7936f7df80da339a537082b5d5 /lib/classes | |
| parent | a9585dad3547a4ebbadd00f44065f95017d18684 (diff) | |
StEP-366: Add OAuth2 support to Stud.IP
Closes #1035 and #1198
Merge request studip/studip!635
Diffstat (limited to 'lib/classes')
27 files changed, 1178 insertions, 0 deletions
diff --git a/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php b/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php new file mode 100644 index 0000000..ce44485 --- /dev/null +++ b/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php @@ -0,0 +1,103 @@ +<?php + +namespace JsonApi\Middlewares\Auth; + +use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\ResourceServer; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\OAuth2\Container; +use Studip\OAuth2\Models\AccessToken; +use Studip\OAuth2\Models\Client; + +class OAuth2Strategy implements Strategy +{ + /** @var callable */ + protected $authenticator; + + /** @var Request */ + protected $request; + + /** @var ?\User */ + protected $user; + + /** + * @param callable $authenticator + */ + public function __construct(Request $request, $authenticator) + { + $this->request = $request; + $this->authenticator = $authenticator; + } + + public function check() + { + return !is_null($this->user()); + } + + public function user() + { + if (!is_null($this->user)) { + return $this->user; + } + + $this->user = $this->detect(); + + return $this->user; + } + + public function addChallenge(Response $response) + { + return $response->withHeader('Authorization', ''); + } + + private function detect(): ?\User + { + $bearerToken = $this->bearerToken($this->request); + if (!$bearerToken) { + return null; + } + + $container = new Container(); + $server = $container->get(ResourceServer::class); + + try { + $psrRequest = $server->validateAuthenticatedRequest($this->request); + + $userId = $psrRequest->getAttribute('oauth_user_id'); + $user = \User::find($userId); + if (!$user) { + return null; + } + + $clientId = $psrRequest->getAttribute('oauth_client_id'); + if (Client::revoked($clientId)) { + return null; + } + + return $user; + } catch (OAuthServerException $oauthException) { + // TODO: reporting? + } + + return null; + } + + /** + * @return string|null + */ + private function bearerToken(Request $request) + { + if ($request->hasHeader('Authorization')) { + $header = $request->getHeaderLine('Authorization'); + $position = strrpos($header, 'Bearer '); + if ($position !== false) { + $header = substr($header, $position + 7); + + return strpos($header, ',') !== false ? strstr($header, ',', true) : $header; + } + } + + return null; + } +} diff --git a/lib/classes/JsonApi/Middlewares/Authentication.php b/lib/classes/JsonApi/Middlewares/Authentication.php index 3e4ee17..5b097d6 100644 --- a/lib/classes/JsonApi/Middlewares/Authentication.php +++ b/lib/classes/JsonApi/Middlewares/Authentication.php @@ -48,6 +48,7 @@ class Authentication $guards = [ new Auth\SessionStrategy(), new Auth\HttpBasicAuthStrategy($request, $this->authenticator), + new Auth\OAuth2Strategy($request, $this->authenticator), new Auth\OAuth1Strategy($request, $this->authenticator), ]; diff --git a/lib/classes/OAuth2/Bridge/AccessTokenEntity.php b/lib/classes/OAuth2/Bridge/AccessTokenEntity.php new file mode 100644 index 0000000..987f0a0 --- /dev/null +++ b/lib/classes/OAuth2/Bridge/AccessTokenEntity.php @@ -0,0 +1,34 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; + +class AccessTokenEntity implements AccessTokenEntityInterface +{ + use AccessTokenTrait; + use EntityTrait; + use TokenEntityTrait; + + /** + * Create a new token instance. + * + * @param string $userIdentifier + * + * @return void + */ + public function __construct($userIdentifier, array $scopes, ClientEntityInterface $client) + { + $this->setUserIdentifier($userIdentifier); + + foreach ($scopes as $scope) { + $this->addScope($scope); + } + + $this->setClient($client); + } +} diff --git a/lib/classes/OAuth2/Bridge/AccessTokenRepository.php b/lib/classes/OAuth2/Bridge/AccessTokenRepository.php new file mode 100644 index 0000000..2762f6b --- /dev/null +++ b/lib/classes/OAuth2/Bridge/AccessTokenRepository.php @@ -0,0 +1,74 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; +use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; +use Studip\OAuth2\Models\AccessToken; + +class AccessTokenRepository implements AccessTokenRepositoryInterface +{ + use ScopesHelper; + + /** + * Create a new access token. + * + * @param ScopeEntityInterface[] $scopes + * @param mixed $userIdentifier + * + * @return AccessTokenEntityInterface + */ + public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null) + { + return new AccessTokenEntity($userIdentifier, $scopes, $clientEntity); + } + + /** + * Persists a new access token to permanent storage. + * + * @throws UniqueTokenIdentifierConstraintViolationException + */ + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void + { + AccessToken::create([ + 'id' => $accessTokenEntity->getIdentifier(), + 'user_id' => $accessTokenEntity->getUserIdentifier(), + 'client_id' => $accessTokenEntity->getClient()->getIdentifier(), + 'scopes' => $this->formatScopes($accessTokenEntity->getScopes()), + 'revoked' => 0, + 'expires_at' => $accessTokenEntity->getExpiryDateTime()->getTimestamp(), + ]); + + // TODO: Logging and metrics + } + + /** + * Revoke an access token. + * + * @param string $tokenId + */ + public function revokeAccessToken($tokenId): void + { + $accesstoken = AccessToken::find($tokenId); + if ($accesstoken) { + $accesstoken->revoke(); + } + } + + /** + * 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): bool + { + $accesstoken = AccessToken::find($tokenId); + + return $accesstoken ? $accesstoken->isRevoked() : true; + } +} diff --git a/lib/classes/OAuth2/Bridge/AuthCodeEntity.php b/lib/classes/OAuth2/Bridge/AuthCodeEntity.php new file mode 100644 index 0000000..5514968 --- /dev/null +++ b/lib/classes/OAuth2/Bridge/AuthCodeEntity.php @@ -0,0 +1,15 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\AuthCodeEntityInterface; +use League\OAuth2\Server\Entities\Traits\AuthCodeTrait; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; + +class AuthCodeEntity implements AuthCodeEntityInterface +{ + use EntityTrait; + use TokenEntityTrait; + use AuthCodeTrait; +} diff --git a/lib/classes/OAuth2/Bridge/AuthCodeRepository.php b/lib/classes/OAuth2/Bridge/AuthCodeRepository.php new file mode 100644 index 0000000..5676622 --- /dev/null +++ b/lib/classes/OAuth2/Bridge/AuthCodeRepository.php @@ -0,0 +1,69 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\AuthCodeEntityInterface; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; +use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; +use Studip\OAuth2\Models\AuthCode; + +class AuthCodeRepository implements AuthCodeRepositoryInterface +{ + use ScopesHelper; + + /** + * Creates a new AuthCode. + */ + public function getNewAuthCode(): AuthCodeEntityInterface + { + return new AuthCodeEntity(); + } + + /** + * Persists a new auth code to permanent storage. + * + * @return void + * + * @throws UniqueTokenIdentifierConstraintViolationException + */ + public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity) + { + AuthCode::create([ + 'id' => $authCodeEntity->getIdentifier(), + 'user_id' => $authCodeEntity->getUserIdentifier(), + 'client_id' => $authCodeEntity->getClient()->getIdentifier(), + 'scopes' => $this->formatScopes($authCodeEntity->getScopes()), + 'revoked' => 0, + 'expires_at' => $authCodeEntity->getExpiryDateTime()->getTimestamp(), + ]); + + // TODO: Logging and metrics + } + + /** + * Revoke an auth code. + * + * @param string $codeId + */ + public function revokeAuthCode($codeId): void + { + $authCode = AuthCode::find($codeId); + if ($authCode) { + $authCode->revoke(); + } + } + + /** + * Check if the auth code has been revoked. + * + * @param string $codeId + * + * @return bool Return true if this code has been revoked + */ + public function isAuthCodeRevoked($codeId): bool + { + $authCode = AuthCode::find($codeId); + + return $authCode ? $authCode->isRevoked() : true; + } +} diff --git a/lib/classes/OAuth2/Bridge/ClientEntity.php b/lib/classes/OAuth2/Bridge/ClientEntity.php new file mode 100644 index 0000000..106caa0 --- /dev/null +++ b/lib/classes/OAuth2/Bridge/ClientEntity.php @@ -0,0 +1,27 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\Traits\ClientTrait; +use League\OAuth2\Server\Entities\Traits\EntityTrait; + +class ClientEntity implements ClientEntityInterface +{ + use ClientTrait; + use EntityTrait; + + /** + * @param string $identifier + * @param string $name + * @param string|string[] $redirectUri + * @param bool $isConfidential + */ + public function __construct($identifier, $name, $redirectUri, $isConfidential) + { + $this->identifier = $identifier; + $this->name = $name; + $this->redirectUri = $redirectUri; + $this->isConfidential = $isConfidential; + } +} diff --git a/lib/classes/OAuth2/Bridge/ClientRepository.php b/lib/classes/OAuth2/Bridge/ClientRepository.php new file mode 100644 index 0000000..b6fd4f6 --- /dev/null +++ b/lib/classes/OAuth2/Bridge/ClientRepository.php @@ -0,0 +1,59 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Repositories\ClientRepositoryInterface; +use Studip\OAuth2\Models\Client; + +class ClientRepository implements ClientRepositoryInterface +{ + /** + * Get a client. + * + * @param string $clientIdentifier The client's identifier + */ + public function getClientEntity($clientIdentifier): ?ClientEntityInterface + { + $sorm = Client::findActive($clientIdentifier); + if (!$sorm) { + return null; + } + + return new ClientEntity( + $clientIdentifier, + $sorm['name'], + explode(',', $sorm['redirect']), + $sorm->confidential() + ); + } + + /** + * Validate a client's secret. + * + * @param string $clientIdentifier The client's identifier + * @param string|null $clientSecret The client's secret (if sent) + * @param string|null $grantType The type of grant the client is using (if sent) + */ + public function validateClient($clientIdentifier, $clientSecret, $grantType): bool + { + if ($grantType !== 'authorization_code') { + return false; + } + $client = Client::findActive($clientIdentifier); + if (!$client) { + return false; + } + + return !$client->confidential() || $this->verifySecret((string) $clientSecret, $client->secret); + } + + /** + * @param string $clientSecret + * @param string $storedHash + */ + protected function verifySecret($clientSecret, $storedHash): bool + { + return password_verify($clientSecret, $storedHash); + } +} diff --git a/lib/classes/OAuth2/Bridge/RefreshTokenEntity.php b/lib/classes/OAuth2/Bridge/RefreshTokenEntity.php new file mode 100644 index 0000000..a0dda5e --- /dev/null +++ b/lib/classes/OAuth2/Bridge/RefreshTokenEntity.php @@ -0,0 +1,13 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; + +class RefreshTokenEntity implements RefreshTokenEntityInterface +{ + use RefreshTokenTrait; + use EntityTrait; +} diff --git a/lib/classes/OAuth2/Bridge/RefreshTokenRepository.php b/lib/classes/OAuth2/Bridge/RefreshTokenRepository.php new file mode 100644 index 0000000..44cb16c --- /dev/null +++ b/lib/classes/OAuth2/Bridge/RefreshTokenRepository.php @@ -0,0 +1,63 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; +use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use Studip\OAuth2\Models\RefreshToken; + +class RefreshTokenRepository implements RefreshTokenRepositoryInterface +{ + /** + * Creates a new refresh token. + */ + public function getNewRefreshToken(): RefreshTokenEntityInterface + { + return new RefreshTokenEntity(); + } + + /** + * Create a new refresh token_name. + * + * @throws UniqueTokenIdentifierConstraintViolationException + */ + public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void + { + RefreshToken::create([ + 'id' => $refreshTokenEntity->getIdentifier(), + 'access_token_id' => $refreshTokenEntity->getAccessToken()->getIdentifier(), + 'revoked' => 0, + 'expires_at' => $refreshTokenEntity->getExpiryDateTime()->getTimestamp(), + ]); + + // TODO: Logging and metrics + } + + /** + * Revoke the refresh token. + * + * @param string $tokenId + */ + public function revokeRefreshToken($tokenId): void + { + $refreshToken = RefreshToken::find($tokenId); + if ($refreshToken) { + $refreshToken->revoke(); + } + } + + /** + * 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): bool + { + $refreshToken = RefreshToken::find($tokenId); + + return $refreshToken ? $refreshToken->isRevoked() : true; + } +} diff --git a/lib/classes/OAuth2/Bridge/ScopeEntity.php b/lib/classes/OAuth2/Bridge/ScopeEntity.php new file mode 100644 index 0000000..844600a --- /dev/null +++ b/lib/classes/OAuth2/Bridge/ScopeEntity.php @@ -0,0 +1,18 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\ScopeTrait; + +class ScopeEntity implements ScopeEntityInterface +{ + use ScopeTrait; + use EntityTrait; + + public function __construct(string $identifier) + { + $this->setIdentifier($identifier); + } +} diff --git a/lib/classes/OAuth2/Bridge/ScopeRepository.php b/lib/classes/OAuth2/Bridge/ScopeRepository.php new file mode 100644 index 0000000..65d666e --- /dev/null +++ b/lib/classes/OAuth2/Bridge/ScopeRepository.php @@ -0,0 +1,60 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; +use Psr\Container\ContainerInterface; +use Studip\OAuth2\Models\Scope; + +class ScopeRepository implements ScopeRepositoryInterface +{ + /** @var array<string, string> */ + private $scopes; + + public function __construct(ContainerInterface $container) + { + $this->scopes = Scope::scopes(); + } + + /** + * Return information about a scope. + * + * @param string $identifier The scope identifier + */ + public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface + { + if (!isset($this->scopes[$identifier])) { + return null; + } + + 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 ClientEntityInterface $clientEntity + * @param null|string $userIdentifier + * + * @return ScopeEntityInterface[] + */ + public function finalizeScopes( + array $scopes, + $grantType, + ClientEntityInterface $clientEntity, + $userIdentifier = null + ) { + return array_filter( + $scopes, + function ($scope) { + return isset($this->scopes[$scope->getIdentifier()]); + } + ); + } +} diff --git a/lib/classes/OAuth2/Bridge/ScopesHelper.php b/lib/classes/OAuth2/Bridge/ScopesHelper.php new file mode 100644 index 0000000..d075381 --- /dev/null +++ b/lib/classes/OAuth2/Bridge/ScopesHelper.php @@ -0,0 +1,18 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +trait ScopesHelper +{ + public function formatScopes(array $scopes): string + { + return json_encode($this->scopesToArray($scopes)); + } + + public function scopesToArray(array $scopes): array + { + return array_map(function ($scope) { + return $scope->getIdentifier(); + }, $scopes); + } +} diff --git a/lib/classes/OAuth2/Bridge/UserEntity.php b/lib/classes/OAuth2/Bridge/UserEntity.php new file mode 100644 index 0000000..02ba52f --- /dev/null +++ b/lib/classes/OAuth2/Bridge/UserEntity.php @@ -0,0 +1,16 @@ +<?php + +namespace Studip\OAuth2\Bridge; + +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\UserEntityInterface; + +class UserEntity implements UserEntityInterface +{ + use EntityTrait; + + public function __construct(string $identifier) + { + $this->setIdentifier($identifier); + } +} diff --git a/lib/classes/OAuth2/Container.php b/lib/classes/OAuth2/Container.php new file mode 100644 index 0000000..e46b127 --- /dev/null +++ b/lib/classes/OAuth2/Container.php @@ -0,0 +1,121 @@ +<?php + +namespace Studip\OAuth2; + +use DateInterval; +use DI\ContainerBuilder; +use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\Grant\AuthCodeGrant; +use League\OAuth2\Server\Grant\RefreshTokenGrant; +use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; +use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; +use League\OAuth2\Server\Repositories\ClientRepositoryInterface; +use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; +use League\OAuth2\Server\ResourceServer; +use Psr\Container\ContainerInterface; +use Studip\OAuth2\Exceptions\SetupError; + +class Container +{ + /** @var ContainerInterface */ + private $container; + + /** + * @return mixed + */ + public function get(string $key) + { + return $this->container->get($key); + } + + public function __construct() + { + $containerBuilder = new ContainerBuilder(); + $this->addConfiguration($containerBuilder); + $this->addDependencies($containerBuilder); + $this->container = $containerBuilder->build(); + } + + private function addConfiguration(ContainerBuilder $containerBuilder): void + { + $basePath = $GLOBALS['STUDIP_BASE_PATH']; + $containerBuilder->addDefinitions([ + 'encryption_key' => $basePath . '/config/oauth2/encryption_key.php', + 'private_key' => $basePath . '/config/oauth2/private.key', + 'public_key' => $basePath . '/config/oauth2/public.key', + + // TODO: use these and more of them + 'tokens_expire_in' => 'P1Y', + 'refresh_tokens_expire_in' => 'P1Y', + ]); + } + + private function addDependencies(ContainerBuilder $containerBuilder): void + { + $containerBuilder->addDefinitions([ + AccessTokenRepositoryInterface::class => \DI\get(Bridge\AccessTokenRepository::class), + AuthCodeRepositoryInterface::class => \DI\get(Bridge\AuthCodeRepository::class), + ClientRepositoryInterface::class => \DI\get(Bridge\ClientRepository::class), + RefreshTokenRepositoryInterface::class => \DI\get(Bridge\RefreshTokenRepository::class), + ScopeRepositoryInterface::class => \DI\get(Bridge\ScopeRepository::class), + + AuthorizationServer::class => function ( + ContainerInterface $container, + AccessTokenRepositoryInterface $accessTokenRepository, + ClientRepositoryInterface $clientRepository, + ScopeRepositoryInterface $scopeRepository, + AuthCodeGrant $authCodeGrant, + RefreshTokenGrant $refreshGrant + ) { + $encryptionKeyFile = $container->get('encryption_key'); + $privateKey = $container->get('private_key'); + if (!is_readable($encryptionKeyFile) || !is_readable($privateKey)) { + throw new SetupError(); + } + + $encryptionKey = include $encryptionKeyFile; + + $server = new AuthorizationServer( + $clientRepository, + $accessTokenRepository, + $scopeRepository, + $privateKey, + $encryptionKey + ); + + $server->enableGrantType($authCodeGrant, new DateInterval('PT1H')); + $server->enableGrantType($refreshGrant, new DateInterval('PT1H')); + + return $server; + }, + + AuthCodeGrant::class => function ( + AuthCodeRepositoryInterface $authCodeRepository, + RefreshTokenRepositoryInterface $refreshTokenRepository + ) { + $grant = new AuthCodeGrant($authCodeRepository, $refreshTokenRepository, new DateInterval('PT10M')); + $grant->setRefreshTokenTTL(new DateInterval('P1M')); + + return $grant; + }, + + RefreshTokenGrant::class => function (RefreshTokenRepositoryInterface $refreshTokenRepository) { + $refreshGrant = new RefreshTokenGrant($refreshTokenRepository); + $refreshGrant->setRefreshTokenTTL(new DateInterval('P1M')); + + return $refreshGrant; + }, + + ResourceServer::class => function ( + ContainerInterface $container, + AccessTokenRepositoryInterface $accessTokenRepository + ) { + $publicKey = $container->get('public_key'); + $resourceServer = new ResourceServer($accessTokenRepository, $publicKey); + + return $resourceServer; + }, + ]); + } +} diff --git a/lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php b/lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php new file mode 100644 index 0000000..69949b1 --- /dev/null +++ b/lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php @@ -0,0 +1,16 @@ +<?php + +namespace Studip\OAuth2\Exceptions; + +class InvalidAuthTokenException extends \AccessDeniedException +{ + /** + * Create a new InvalidAuthTokenException for different auth tokens. + * + * @return static + */ + public static function different() + { + return new static('The provided auth token for the request is different from the session auth token.'); + } +} diff --git a/lib/classes/OAuth2/Exceptions/SetupError.php b/lib/classes/OAuth2/Exceptions/SetupError.php new file mode 100644 index 0000000..e7b9928 --- /dev/null +++ b/lib/classes/OAuth2/Exceptions/SetupError.php @@ -0,0 +1,15 @@ +<?php + +namespace Studip\OAuth2\Exceptions; + +use League\OAuth2\Server\Exception\OAuthServerException; + +class SetupError extends OAuthServerException +{ + public function __construct() + { + $message = _('Das OAuth2-Setup dieser Stud.IP-Installation ist fehlerhaft.'); + + parent::__construct($message, 500, 'invalid_setup', 500); + } +} diff --git a/lib/classes/OAuth2/KeyInformation.php b/lib/classes/OAuth2/KeyInformation.php new file mode 100644 index 0000000..8fe7a4f --- /dev/null +++ b/lib/classes/OAuth2/KeyInformation.php @@ -0,0 +1,47 @@ +<?php + +namespace Studip\OAuth2; + +class KeyInformation +{ + /** @var string */ + private $filename; + + public function __construct(string $filename) + { + $this->filename = $filename; + } + + public function filename(): string + { + return $this->filename; + } + + public function exists(): bool + { + return file_exists($this->filename); + } + + public function isReadable(): bool + { + return is_readable($this->filename); + } + + public function hasProperMode(): bool + { + return $this->mode() === '600' || $this->mode() === '660'; + } + + public function mode(): string + { + $result = ''; + if ($this->isReadable()) { + $stat = stat($this->filename); + if ($stat !== false) { + $result = substr(sprintf('%o', $stat['mode']), -3); + } + } + + return $result; + } +} diff --git a/lib/classes/OAuth2/Models/AccessToken.php b/lib/classes/OAuth2/Models/AccessToken.php new file mode 100644 index 0000000..3c57973 --- /dev/null +++ b/lib/classes/OAuth2/Models/AccessToken.php @@ -0,0 +1,50 @@ +<?php + +namespace Studip\OAuth2\Models; + +/** + * @property int $id + * @property string $user_id + * @property string $client_id + * @property string $scopes + * @property bool $revoked + * @property int $expires_at + * @property int $mkdate + * @property int $chdate + */ +class AccessToken extends \SimpleORMap +{ + use RevokedHelper; + + protected static function configure($config = []) + { + $config['db_table'] = 'oauth2_access_tokens'; + + $config['belongs_to']['client'] = [ + 'class_name' => Client::class, + 'foreign_key' => 'client_id', + ]; + + $config['belongs_to']['user'] = [ + 'class_name' => \User::class, + 'foreign_key' => 'user_id', + ]; + + $config['has_many']['refresh_tokens'] = [ + 'class_name' => RefreshToken::class, + 'assoc_foreign_key' => 'access_token_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + ]; + + parent::configure($config); + } + + public static function findValidTokens(\User $user) + { + return static::findBySQL( + 'user_id = ? AND revoked = ? AND expires_at > ?', + [$user->id, 0, time()] + ); + } +} diff --git a/lib/classes/OAuth2/Models/AuthCode.php b/lib/classes/OAuth2/Models/AuthCode.php new file mode 100644 index 0000000..1a43c8c --- /dev/null +++ b/lib/classes/OAuth2/Models/AuthCode.php @@ -0,0 +1,35 @@ +<?php + +namespace Studip\OAuth2\Models; + +/** + * @property int $id + * @property string $user_id + * @property string $client_id + * @property string $scopes + * @property bool $revoked + * @property int $expires_at + * @property int $mkdate + * @property int $chdate + */ +class AuthCode extends \SimpleORMap +{ + use RevokedHelper; + + protected static function configure($config = []) + { + $config['db_table'] = 'oauth2_auth_codes'; + + $config['belongs_to']['client'] = [ + 'class_name' => Client::class, + 'foreign_key' => 'client_id', + ]; + + $config['belongs_to']['user'] = [ + 'class_name' => \User::class, + 'foreign_key' => 'user_id', + ]; + + parent::configure($config); + } +} diff --git a/lib/classes/OAuth2/Models/Client.php b/lib/classes/OAuth2/Models/Client.php new file mode 100644 index 0000000..935e812 --- /dev/null +++ b/lib/classes/OAuth2/Models/Client.php @@ -0,0 +1,122 @@ +<?php + +namespace Studip\OAuth2\Models; + +/** + * @property int $id + * @property string $name + * @property string|null $secret + * @property string $redirect + * @property bool $revoked + * @property int $mkdate + * @property int $chdate + */ +class Client extends \SimpleORMap +{ + use RevokedHelper; + + /** @var string $plainsecret This is only filled when creating a new Client via `Client::createClient`. */ + public $plainsecret; + + protected static function configure($config = []) + { + $config['db_table'] = 'oauth2_clients'; + + $config['belongs_to']['user'] = [ + 'class_name' => \User::class, + 'foreign_key' => 'user_id', + ]; + + $config['has_many']['auth_codes'] = [ + 'class_name' => AuthCode::class, + 'assoc_foreign_key' => 'client_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY chdate', + ]; + + $config['has_many']['access_tokens'] = [ + 'class_name' => AccessToken::class, + 'assoc_foreign_key' => 'client_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY chdate', + ]; + + parent::configure($config); + } + + /** + * Store a new client. + * + * @return static + */ + public static function createClient( + string $name, + string $redirect, + bool $confidential, + string $owner, + string $homepage, + ?string $description, + ?string $adminNotes + ) { + $secret = null; + $plainsecret = null; + if ($confidential) { + $plainsecret = randomString(40); + $secret = password_hash($plainsecret, PASSWORD_BCRYPT); + } + + $client = self::create([ + 'name' => $name, + 'secret' => $secret, + 'redirect' => $redirect, + 'revoked' => 0, + 'owner' => $owner, + 'homepage' => $homepage, + 'description' => $description, + 'admin_notes' => $adminNotes, + ]); + $client->plainsecret = $plainsecret; + + return $client; + } + + /** + * @param int|string $clientId + * + * @return ?static + */ + public static function findActive($clientId) + { + $client = self::find($clientId); + + return $client && !$client->isRevoked() ? $client : null; + } + + /** + * @param string $clientId + * + * @return bool + */ + public static function revoked($clientId): bool + { + return static::findActive($clientId) === null; + } + + /** + * @return bool + */ + public function confidential(): bool + { + return !empty($this->secret); + } + + /** + * @return string[] + */ + public function redirectURIs(): array + { + return explode(',', $this->redirect); + } +} diff --git a/lib/classes/OAuth2/Models/RefreshToken.php b/lib/classes/OAuth2/Models/RefreshToken.php new file mode 100644 index 0000000..cf9a253 --- /dev/null +++ b/lib/classes/OAuth2/Models/RefreshToken.php @@ -0,0 +1,40 @@ +<?php + +namespace Studip\OAuth2\Models; + +/** + * @property int $id + * @property string $access_token_id + * @property string $client_id + * @property bool $revoked + * @property int $expires_at + */ +class RefreshToken extends \SimpleORMap +{ + use RevokedHelper; + + protected static function configure($config = []) + { + $config['db_table'] = 'oauth2_refresh_tokens'; + + $config['belongs_to']['access_token'] = [ + 'class_name' => AccessToken::class, + 'foreign_key' => 'access_token_id', + ]; + + parent::configure($config); + } + + /** + * Revokes refresh tokens by access token id. + * + * @param string $tokenId + */ + public static function revokeByAccessTokenId($tokenId): void + { + $refreshTokens = self::findBySQL('access_token_id = ?', [$tokenId]); + foreach ($refreshTokens as $refreshToken) { + $refreshToken->revoke(); + } + } +} diff --git a/lib/classes/OAuth2/Models/RevokedHelper.php b/lib/classes/OAuth2/Models/RevokedHelper.php new file mode 100644 index 0000000..8c973aa --- /dev/null +++ b/lib/classes/OAuth2/Models/RevokedHelper.php @@ -0,0 +1,25 @@ +<?php + +namespace Studip\OAuth2\Models; + +trait RevokedHelper +{ + /** + * @return bool + */ + public function isRevoked() + { + return (bool) $this->revoked; + } + + /** + * Revoke the token instance. + * + * @return void + */ + public function revoke() + { + $this->revoked = 1; + $this->store(); + } +} diff --git a/lib/classes/OAuth2/Models/Scope.php b/lib/classes/OAuth2/Models/Scope.php new file mode 100644 index 0000000..86bf815 --- /dev/null +++ b/lib/classes/OAuth2/Models/Scope.php @@ -0,0 +1,59 @@ +<?php + +namespace Studip\OAuth2\Models; + +class Scope +{ + /** + * @var string + */ + public $id; + + /** + * @var string + */ + public $description; + + /** + * @param string $id + * @param string $description + * + * @return void + */ + public function __construct($id, $description) + { + $this->id = $id; + $this->description = $description; + } + + /** + * @return static[] + */ + public static function scopes() + { + return [ + 'api' => new Scope('api', _('Gewährt vollständigen Lese-/Schreibzugriff auf die API.')), + ]; + } + + /** + * @return array + */ + public function toArray() + { + return [ + 'id' => $this->id, + 'description' => $this->description, + ]; + } + + /** + * @param int $options + * + * @return string + */ + public function toJson($options = 0) + { + return json_encode($this->toArray(), $options); + } +} diff --git a/lib/classes/OAuth2/NegotiatesWithPsr7.php b/lib/classes/OAuth2/NegotiatesWithPsr7.php new file mode 100644 index 0000000..0edf243 --- /dev/null +++ b/lib/classes/OAuth2/NegotiatesWithPsr7.php @@ -0,0 +1,44 @@ +<?php + +namespace Studip\OAuth2; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Slim\Psr7\Response; +use Trails_Response; + +trait NegotiatesWithPsr7 +{ + protected function getPsrRequest(): ServerRequestInterface + { + return \Slim\Psr7\Factory\ServerRequestFactory::createFromGlobals(); + } + + protected function getPsrResponse(): ResponseInterface + { + return new Response(); + } + + protected function convertPsrResponse(ResponseInterface $response): Trails_Response + { + $trailsResponse = new Trails_Response((string) $response->getBody(), [], $response->getStatusCode()); + foreach ($response->getHeaders() as $key => $values) { + foreach ($values as $value) { + $trailsResponse->add_header($key, $value); + } + } + + return $trailsResponse; + } + + protected function renderPsrResponse(ResponseInterface $response): void + { + $this->set_status($response->getStatusCode()); + $this->render_text((string) $response->getBody()); + foreach ($response->getHeaders() as $key => $values) { + foreach ($values as $value) { + $this->response->add_header($key, $value); + } + } + } +} diff --git a/lib/classes/OAuth2/SetupInformation.php b/lib/classes/OAuth2/SetupInformation.php new file mode 100644 index 0000000..01b0d09 --- /dev/null +++ b/lib/classes/OAuth2/SetupInformation.php @@ -0,0 +1,31 @@ +<?php + +namespace Studip\OAuth2; + +use Psr\Container\ContainerInterface; + +class SetupInformation +{ + /** @var ContainerInterface */ + private $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public function encryptionKey(): KeyInformation + { + return new KeyInformation($this->container->get('encryption_key')); + } + + public function privateKey(): KeyInformation + { + return new KeyInformation($this->container->get('private_key')); + } + + public function publicKey(): KeyInformation + { + return new KeyInformation($this->container->get('public_key')); + } +} diff --git a/lib/classes/PageLayout.php b/lib/classes/PageLayout.php index ecb34b7..ee26d7c 100644 --- a/lib/classes/PageLayout.php +++ b/lib/classes/PageLayout.php @@ -511,6 +511,9 @@ class PageLayout */ public static function postMessage(LayoutMessage $message, $id = null) { + if (!isset($_SESSION['messages'])) { + $_SESSION['messages'] = []; + } if ($id === null ) { $_SESSION['messages'][] = $message; } else { |
