aboutsummaryrefslogtreecommitdiff
path: root/lib/classes
diff options
context:
space:
mode:
authorMarcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de>2022-07-15 11:47:35 +0000
committerMarcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de>2022-07-15 11:47:35 +0000
commit55852ef4819e5eafce9ae53dc4de2d84cdad1778 (patch)
tree9aedcdf89f416a7936f7df80da339a537082b5d5 /lib/classes
parenta9585dad3547a4ebbadd00f44065f95017d18684 (diff)
StEP-366: Add OAuth2 support to Stud.IP
Closes #1035 and #1198 Merge request studip/studip!635
Diffstat (limited to 'lib/classes')
-rw-r--r--lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php103
-rw-r--r--lib/classes/JsonApi/Middlewares/Authentication.php1
-rw-r--r--lib/classes/OAuth2/Bridge/AccessTokenEntity.php34
-rw-r--r--lib/classes/OAuth2/Bridge/AccessTokenRepository.php74
-rw-r--r--lib/classes/OAuth2/Bridge/AuthCodeEntity.php15
-rw-r--r--lib/classes/OAuth2/Bridge/AuthCodeRepository.php69
-rw-r--r--lib/classes/OAuth2/Bridge/ClientEntity.php27
-rw-r--r--lib/classes/OAuth2/Bridge/ClientRepository.php59
-rw-r--r--lib/classes/OAuth2/Bridge/RefreshTokenEntity.php13
-rw-r--r--lib/classes/OAuth2/Bridge/RefreshTokenRepository.php63
-rw-r--r--lib/classes/OAuth2/Bridge/ScopeEntity.php18
-rw-r--r--lib/classes/OAuth2/Bridge/ScopeRepository.php60
-rw-r--r--lib/classes/OAuth2/Bridge/ScopesHelper.php18
-rw-r--r--lib/classes/OAuth2/Bridge/UserEntity.php16
-rw-r--r--lib/classes/OAuth2/Container.php121
-rw-r--r--lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php16
-rw-r--r--lib/classes/OAuth2/Exceptions/SetupError.php15
-rw-r--r--lib/classes/OAuth2/KeyInformation.php47
-rw-r--r--lib/classes/OAuth2/Models/AccessToken.php50
-rw-r--r--lib/classes/OAuth2/Models/AuthCode.php35
-rw-r--r--lib/classes/OAuth2/Models/Client.php122
-rw-r--r--lib/classes/OAuth2/Models/RefreshToken.php40
-rw-r--r--lib/classes/OAuth2/Models/RevokedHelper.php25
-rw-r--r--lib/classes/OAuth2/Models/Scope.php59
-rw-r--r--lib/classes/OAuth2/NegotiatesWithPsr7.php44
-rw-r--r--lib/classes/OAuth2/SetupInformation.php31
-rw-r--r--lib/classes/PageLayout.php3
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 {