diff --git a/.gitignore b/.gitignore index 1f1829da0fa7d599026b250a712dbf0d2995ea20..869656cf596007becd9c3fd3b1eaedecc0522ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ tests/_helpers/_generated tests/_output/ .idea +/config/oauth2/*.key +/config/oauth2/encryption_key.php diff --git a/app/controllers/admin/oauth2.php b/app/controllers/admin/oauth2.php new file mode 100644 index 0000000000000000000000000000000000000000..ae4f5f9c760e78ed4d1d313c6a805c22307334fa --- /dev/null +++ b/app/controllers/admin/oauth2.php @@ -0,0 +1,46 @@ +<?php + +use Studip\OAuth2\Container; +use Studip\OAuth2\Models\Client; +use Studip\OAuth2\SetupInformation; + +class Admin_Oauth2Controller extends AuthenticatedController +{ + /** + * @param string $action + * @param string[] $args + * + * @return void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $GLOBALS['perm']->check('root'); + + Navigation::activateItem('/admin/config/oauth2'); + PageLayout::setTitle(_('OAuth2 Verwaltung')); + + $this->types = [ + 'website' => _('Website'), + 'desktop' => _('Herkömmliches Desktopprogramm'), + 'mobile' => _('Mobile App'), + ]; + + // Sidebar + $views = new ViewsWidget(); + $views->addLink( + _('Übersicht'), + $this->indexURL() + )->setActive($action === 'index'); + Sidebar::get()->addWidget($views); + + $this->container = new Container(); + } + + public function index_action(): void + { + $this->setup = $this->container->get(SetupInformation::class); + $this->clients = Client::findBySql('1 ORDER BY chdate DESC'); + } +} diff --git a/app/controllers/api/oauth2/applications.php b/app/controllers/api/oauth2/applications.php new file mode 100644 index 0000000000000000000000000000000000000000..d08ec1e9bdaeb78b487133b643b630022e576d92 --- /dev/null +++ b/app/controllers/api/oauth2/applications.php @@ -0,0 +1,101 @@ +<?php +use Studip\OAuth2\Models\AccessToken; +use Studip\OAuth2\Models\Scope; + +/** + * @property array $applications + */ +class Api_Oauth2_ApplicationsController extends AuthenticatedController +{ + public function index_action(): void + { + Navigation::activateItem('/profile/settings/oauth2'); + PageLayout::setTitle(_('Autorisierte Drittanwendungen')); + Helpbar::get()->addPlainText( + _('Autorisierte Drittanwendungen'), + _("Sie können Ihren Stud.IP-Zugang über OAuth mit Anwendungen von Drittanbietern verbinden.\n\nWenn Sie eine OAuth-App autorisieren, sollten Sie sicherstellen, dass Sie der Anwendung vertrauen, überprüfen, wer sie entwickelt hat, und die Art der Informationen überprüfen, auf die die Anwendung zugreifen möchte.") + ); + + $user = User::findCurrent(); + $this->applications = $this->getApplications($user); + } + + public function details_action(AccessToken $accessToken): void + { + $user = User::findCurrent(); + if ($accessToken['user_id'] !== $user->id) { + throw new AccessDeniedException(); + } + + PageLayout::setTitle(_('Autorisierte OAuth2-Drittanwendung')); + $this->application = $this->formatApplication($accessToken); + + if (!$this->application) { + throw new Trails_Exception(500, 'Error finding client.'); + } + } + + public function revoke_action(): void + { + CSRFProtection::verifyUnsafeRequest(); + + $user = User::findCurrent(); + $accessToken = AccessToken::find(Request::option('application')); + if (!$accessToken) { + throw new Trails_Exception(404); + } + if ($accessToken['user_id'] !== $user->id) { + throw new AccessDeniedException(); + } + + $accessToken->revoke(); + + $this->redirect('api/oauth2/applications'); + } + + private function getApplications(User $user): array + { + return array_reduce( + AccessToken::findValidTokens($user), + function ($applications, $accessToken) { + $application = $this->formatApplication($accessToken); + if ($application) { + $applications[] = $application; + } + + return $applications; + }, + [] + ); + } + + private function formatApplication(AccessToken $accessToken): ?array + { + $allScopes = Scope::scopes(); + + if (!$accessToken->client) { + return null; + } + + return [ + 'id' => $accessToken['id'], + 'name' => $accessToken->client['name'], + 'description' => $accessToken->client['description'], + 'owner' => $accessToken->client['owner'], + 'homepage' => $accessToken->client['homepage'], + 'created' => new DateTime('@' . $accessToken->client['mkdate']), + + 'scopes' => array_reduce( + json_decode($accessToken['scopes']), + function ($scopes, $scopeIdentifier) use ($allScopes) { + if (isset($allScopes[$scopeIdentifier])) { + $scopes[] = $allScopes[$scopeIdentifier]; + } + + return $scopes; + }, + [] + ) + ]; + } +} diff --git a/app/controllers/api/oauth2/authorize.php b/app/controllers/api/oauth2/authorize.php new file mode 100644 index 0000000000000000000000000000000000000000..5628d49563dfa1e058fd7999aca8fb21e458b8b0 --- /dev/null +++ b/app/controllers/api/oauth2/authorize.php @@ -0,0 +1,175 @@ +<?php +require_once __DIR__ . '/oauth2_controller.php'; + +use League\OAuth2\Server\RequestTypes\AuthorizationRequest; +use Studip\OAuth2\Bridge\UserEntity; +use Studip\OAuth2\Exceptions\InvalidAuthTokenException; +use Studip\OAuth2\Models\Scope; + +class Api_Oauth2_AuthorizeController extends OAuth2Controller +{ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + if ('index' !== $action) { + throw new Trails_Exception(404); + } + + $action = $this->determineAction(); + } + + private function determineAction(): string + { + $method = $this->getMethod(); + + if (Request::submitted('auth_token')) { + $GLOBALS['auth']->login_if('nobody' === $GLOBALS['user']->id); + CSRFProtection::verifyUnsafeRequest(); + + switch ($method) { + case 'POST': + return 'approved'; + + case 'DELETE': + return 'denied'; + } + } + + return 'authorize'; + } + + public function authorize_action(): void + { + $psrRequest = $this->getPsrRequest(); + $authRequest = $this->server->validateAuthorizationRequest($psrRequest); + + $scopes = $authRequest->getScopes(); + $client = $authRequest->getClient(); + + $authToken = randomString(32); + $this->freezeSessionVars($authRequest, $authToken); + + // show login form if not logged in + $authPlugin = Config::get()->getValue('API_OAUTH_AUTH_PLUGIN'); + if ('nobody' === $GLOBALS['user']->id && 'Standard' !== $authPlugin && !Request::option('sso')) { + $queryParams = $psrRequest->getQueryParams(); + $queryParams['sso'] = strtolower($authPlugin); + $this->redirect($this->authorizeURL($queryParams)); + + return; + } else { + $GLOBALS['auth']->login_if('nobody' === $GLOBALS['user']->id); + } + + $this->client = $client; + $this->user = $GLOBALS['user']; + $this->scopes = $this->scopesFor($scopes); + $this->authToken = $authToken; + $this->state = $authRequest->getState(); + + PageLayout::disableHeader(); + $this->render_template( + 'api/oauth2/authorize.php', + $GLOBALS['template_factory']->open('layouts/base.php') + ); + } + + public function approved_action(): void + { + [$authRequest, $authToken] = $this->thawSessionVars(); + $this->assertValidAuthToken($authToken); + + $authRequest->setUser(new UserEntity($GLOBALS['user']->id)); + $authRequest->setAuthorizationApproved(true); + + $response = $this->server->completeAuthorizationRequest($authRequest, $this->getPsrResponse()); + + $this->renderPsrResponse($response); + } + + public function denied_action(): void + { + [$authRequest, $authToken] = $this->thawSessionVars(); + $this->assertValidAuthToken($authToken); + + $authRequest->setUser(new UserEntity($GLOBALS['user']->id)); + $authRequest->setAuthorizationApproved(false); + + $clientUris = $authRequest->getClient()->getRedirectUri(); + + $uri = $authRequest->getRedirectUri(); + if (!in_array($uri, $clientUris)) { + $uri = current($clientUris); + } + + $uri = URLHelper::getURL($uri, [ + 'error' => 'access_denied', + 'state' => Request::get('state'), + ], true); + $this->redirect($uri); + } + + private function getMethod(): string + { + $method = Request::method(); + if ('POST' === $method && Request::submitted('_method')) { + $_method = strtoupper(Request::get('_method')); + if (in_array($_method, ['DELETE', 'PATCH', 'PUT'])) { + $method = $_method; + } + } + + return $method; + } + + /** + * Make sure the auth token matches the one in the session. + * + * @throws InvalidAuthTokenException + */ + private function assertValidAuthToken(string $authToken): void + { + if (Request::submitted('auth_token') && $authToken !== Request::get('auth_token')) { + throw InvalidAuthTokenException::different(); + } + } + + private function freezeSessionVars(AuthorizationRequest $authRequest, string $authToken): void + { + $_SESSION['oauth2'] = [ + 'authRequest' => serialize($authRequest), + 'authToken' => $authToken, + ]; + } + + private function thawSessionVars(): array + { + $authRequest = null; + $authToken = null; + if ( + isset($_SESSION['oauth2']) && + is_array($_SESSION['oauth2']) && + isset($_SESSION['oauth2']['authRequest']) && + isset($_SESSION['oauth2']['authToken']) + ) { + $authRequest = unserialize($_SESSION['oauth2']['authRequest']); + $authToken = $_SESSION['oauth2']['authToken']; + } + + return [$authRequest, $authToken]; + } + + private function scopesFor(array $scopeEntities): array + { + $scopes = Scope::scopes(); + $scopeModels = []; + foreach ($scopeEntities as $scopeEntity) { + if (isset($scopes[$scopeEntity->getIdentifier()])) { + $scopeModels[] = $scopes[$scopeEntity->getIdentifier()]; + } + } + + return $scopeModels; + } +} diff --git a/app/controllers/api/oauth2/clients.php b/app/controllers/api/oauth2/clients.php new file mode 100644 index 0000000000000000000000000000000000000000..c16b67dabccc297f03e1c7377420c9e5c325b048 --- /dev/null +++ b/app/controllers/api/oauth2/clients.php @@ -0,0 +1,166 @@ +<?php + +use Studip\OAuth2\Models\Client; + +class Api_Oauth2_ClientsController extends AuthenticatedController +{ + /** + * @param string $action + * @param string[] $args + * + * @return void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $GLOBALS['perm']->check('root'); + } + + public function add_action(): void + { + Navigation::activateItem('/admin/config/oauth2'); + PageLayout::setTitle(_('OAuth2-Client hinzufügen')); + } + + public function store_action(): void + { + CSRFProtection::verifyUnsafeRequest(); + + $this->redirect('admin/oauth2'); + + list($valid, $data, $errors) = $this->validateCreateClientRequest(); + + if (!$valid) { + PageLayout::postError(_('Das Erstellen eines OAuth2-Clients war nicht erfolgreich.'), $errors); + return; + } + + $client = $this->createAuthCodeClient($data); + $this->outputClientCredentials($client); + } + + public function delete_action(Client $client): void + { + CSRFProtection::verifyUnsafeRequest(); + + $clientId = $client['id']; + $clientName = $client['name']; + $client->delete(); + + PageLayout::postSuccess(sprintf(_('Der OAuth2-Client #%d ("%s") wurde gelöscht.'), $clientId, $clientName)); + $this->redirect('admin/oauth2'); + } + + /** + * Create a authorization code client. + * + * @param array<string, mixed> + */ + private function createAuthCodeClient(array $data): Client + { + return Client::createClient( + $data['name'], + $data['redirect'], + $data['confidential'], + $data['owner'], + $data['homepage'], + $data['description'], + $data['admin_notes'] + ); + } + + /** + * Show feedback to the user depending on the confidentiality of the `$client`. + */ + private function outputClientCredentials(Client $client): void + { + if ($client->confidential()) { + PageLayout::postWarning(_('Der OAuth2-Client wurde erstellt.'), [ + sprintf(_('Die <em lang="en"> client_id </em> lautet: <pre>%s</pre>'), $client['id']), + sprintf(_('Das <em lang="en"> client_secret </em> lautet: <pre>%s</pre>'), $client->plainsecret), + _( + 'Notieren Sie sich bitte das <em lang="en"> client_secret </em>. Es wird Ihnen nur <strong> dieses eine Mal </strong> angezeigt.' + ), + ]); + } else { + PageLayout::postSuccess(_('Der OAuth2-Client wurde erstellt.'), [ + sprintf(_('Die <em lang="en"> client_id </em> lautet: <pre>%s</pre>'), $client['id']), + ]); + } + } + + /** + * Validate the request parameters when creating a new client. + * + * @return array{0: bool, 1: array<string, mixed>, 2: string[]} + */ + private function validateCreateClientRequest() + { + $valid = true; + $data = []; + $errors = []; + + // required + $name = Request::get('name'); + $redirectURIs = Request::get('redirect'); + $confidentiality = Request::get('confidentiality'); + $owner = Request::get('owner'); + $homepage = Request::get('homepage'); + + // optional + $data['description'] = Request::get('description'); + $data['admin_notes'] = Request::get('admin_notes'); + + foreach (compact('name', 'redirectURIs', 'confidentiality', 'owner', 'homepage') as $key => $value) { + if (!isset($value)) { + $errors[] = sprintf(_('Parameter "%s" fehlt.'), $key); + $valid = false; + } + } + + // validate $name + $data['name'] = trim($name); + if ($name === '') { + $errors[] = _('Der Parameter "name" darf nicht leer sein.'); + $valid = false; + } + + // validate $redirectURIS + $redirect = []; + $redirectLines = preg_split("/[\n\r]/", $redirectURIs, -1, PREG_SPLIT_NO_EMPTY); + foreach ($redirectLines as $line) { + $url = filter_var($line, FILTER_SANITIZE_URL); + if (false === filter_var($url, FILTER_VALIDATE_URL)) { + $errors = _('Der Parameter "redirect" darf nur gültige URLs enthalten.'); + $valid = false; + break; + } + $redirect[] = $url; + } + $data['redirect'] = join(',', $redirect); + + // validate $confidentiality + if (!in_array($confidentiality, ['public', 'confidential'])) { + $errors[] = _('Der Parameter "confidentiality" darf nur gültige URLs enthalten.'); + $valid = false; + } + $data['confidential'] = $confidentiality === 'confidential'; + + // validate $owner + $data['owner'] = trim($owner); + if ($owner === '') { + $errors[] = _('Der Parameter "owner" darf nicht leer sein.'); + $valid = false; + } + + // validate $homepage + $data['homepage'] = filter_var($homepage, FILTER_SANITIZE_URL); + if (false === filter_var($homepage, FILTER_VALIDATE_URL)) { + $errors = _('Der Parameter "homepage" muss eine gültige URL enthalten.'); + $valid = false; + } + + return [$valid, $data, $errors]; + } +} diff --git a/app/controllers/api/oauth2/oauth2_controller.php b/app/controllers/api/oauth2/oauth2_controller.php new file mode 100644 index 0000000000000000000000000000000000000000..fd02ea9ee19384fb7cedf07fe2a4adcf6837dd90 --- /dev/null +++ b/app/controllers/api/oauth2/oauth2_controller.php @@ -0,0 +1,52 @@ +<?php + +use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\Exception\OAuthServerException; +use Studip\OAuth2\NegotiatesWithPsr7; + +abstract class OAuth2Controller extends StudipController +{ + use NegotiatesWithPsr7; + + /** + * @return void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + page_open([ + 'sess' => 'Seminar_Session', + 'auth' => 'Seminar_Default_Auth', + 'perm' => 'Seminar_Perm', + 'user' => 'Seminar_User', + ]); + + $this->set_layout(null); + + $this->container = new Studip\OAuth2\Container(); + $this->server = $this->getAuthorizationServer(); + } + + /** + * Exception handler called when the performance of an action raises an + * exception. + * + * @param Exception $exception the thrown exception + */ + public function rescue($exception) + { + if ($exception instanceof OAuthServerException) { + $psrResponse = $exception->generateHttpResponse($this->getPsrResponse()); + + return $this->convertPsrResponse($psrResponse); + } + + return new Trails_Response($exception->getMessage(), [], 500); + } + + protected function getAuthorizationServer(): AuthorizationServer + { + return $this->container->get(AuthorizationServer::class); + } +} diff --git a/app/controllers/api/oauth2/token.php b/app/controllers/api/oauth2/token.php new file mode 100644 index 0000000000000000000000000000000000000000..0ae7ffbd2d87fe3bf73155e9568421495bc5c5c4 --- /dev/null +++ b/app/controllers/api/oauth2/token.php @@ -0,0 +1,29 @@ +<?php +require_once __DIR__ . '/oauth2_controller.php'; + +class Api_Oauth2_TokenController extends OAuth2Controller +{ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + if ('index' !== $action) { + throw new Trails_Exception(404); + } + + if (!Request::isPost()) { + throw new Trails_Exception(405); + } + + $action = 'issue_token'; + } + + public function issue_token_action(): void + { + $psrRequest = $this->getPsrRequest(); + $psrResponse = $this->getPsrResponse(); + $response = $this->server->respondToAccessTokenRequest($psrRequest, $psrResponse); + + $this->renderPsrResponse($response); + } +} diff --git a/app/views/admin/oauth2/_clients.php b/app/views/admin/oauth2/_clients.php new file mode 100644 index 0000000000000000000000000000000000000000..0e3db8aaa3bc9a598af04bb87869f09fc80103af --- /dev/null +++ b/app/views/admin/oauth2/_clients.php @@ -0,0 +1,79 @@ +<? + $sidebar = Sidebar::get(); + $actions = new ActionsWidget(); + $actions->addLink( + _('OAuth2-Client hinzufügen'), + $controller->url_for('api/oauth2/clients/add'), + Icon::create('add') + ); + $sidebar->addWidget($actions); +?> + +<? if (isset($clients) && count($clients)) { ?> + <h2> + <?= _('Registrierte OAuth2-Clients') ?> + </h2> + + <? foreach ($clients as $client) { ?> + <article class="studip"> + <header> + <h1> + <b><?= htmlReady($client['name']) ?></b> + </h1> + <nav> + <form + action ="<?= $controller->link_for('api/oauth2/clients/delete', $client) ?>" + method="post"> + <?= CSRFProtection::tokenTag() ?> + <?= ActionMenu::get() + ->addButton( + sprintf(_('OAuth2-Client "%s" löschen'), $client['name']), + 'delete_client', + Icon::create('trash'), + [ + 'data-confirm' => _('Wollen Sie den OAuth2-Client wirklich löschen?'), + 'title' => sprintf(_('OAuth2-Client "%s" löschen'), $client['name']), + ] + ) + ->render() ?> + </form> + </nav> + </header> + + <div> + <dl> + <dt><?= _('Beschreibung') ?></dt> + <dd><?= htmlReady($client['description']) ?></dd> + + <dt><?= _('Entwickelt durch') ?></dt> + <dd> + <a rel="noreferrer noopener" target="_blank" + href="<?= htmlReady($client['homepage']) ?>"> + <?= htmlReady($client['owner']) ?> + </a> + </dd> + + <dt><?= _('client_id') ?></dt> + <dd> <?= htmlReady($client['id']) ?> </dd> + + <dt><?= _('Redirect-URIs') ?></dt> + <dd> + <ul> + <? foreach ($client->redirectUris() as $uri) { ?> + <li><?= htmlReady($uri) ?></li> + <? } ?> + </ul> + </dd> + + <dt><?= _('Kann kryptographische Geheimnisse bewahren?') ?></dt> + <dd><?= $client->confidential() ? _('Ja') : _('Nein') ?></dd> + + <dt><?= _('Notizen (nur für Root-Accounts sichtbar)') ?></dt> + <dd> + <?= htmlReady($client['admin_notes']) ?> + </dd> + </dl> + </div> + </article> + <? } ?> +<? } ?> diff --git a/app/views/admin/oauth2/_notices.php b/app/views/admin/oauth2/_notices.php new file mode 100644 index 0000000000000000000000000000000000000000..a67539c4e7c08f5f7ad52c9d467dfae05a2457c7 --- /dev/null +++ b/app/views/admin/oauth2/_notices.php @@ -0,0 +1,10 @@ +<? if (!isset($clients) || !count($clients)) { ?> + <?= MessageBox::info( + _('Es wurde noch kein OAuth2-Client erstellt.') . + '<br/>' . + \Studip\LinkButton::createAdd( + _('OAuth2-Client hinzufügen'), + $controller->link_for('api/oauth2/clients/add') + ) + ) ?> +<? } ?> diff --git a/app/views/admin/oauth2/_setup.php b/app/views/admin/oauth2/_setup.php new file mode 100644 index 0000000000000000000000000000000000000000..2469a1b63b221b20351569f01fd1a5f22ca96b52 --- /dev/null +++ b/app/views/admin/oauth2/_setup.php @@ -0,0 +1,17 @@ +<ul> + <li> + <? $privateKey = $setup->privateKey(); ?> + <b lang="en">Private Key</b> (<?= htmlReady($privateKey->filename()) ?>) + <?= $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $privateKey]) ?> + </li> + <li> + <? $publicKey = $setup->publicKey(); ?> + <b lang="en">Public Key</b> (<?= htmlReady($publicKey->filename()) ?>) + <?= $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $publicKey]) ?> + </li> + <li> + <? $encryptionKey = $setup->encryptionKey(); ?> + <b lang="en">Encryption Key</b> (<?= htmlReady($encryptionKey->filename()) ?>) + <?= $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $encryptionKey]) ?> + </li> +</ul> diff --git a/app/views/admin/oauth2/_setup_key.php b/app/views/admin/oauth2/_setup_key.php new file mode 100644 index 0000000000000000000000000000000000000000..d1ee3229c99c8a4e83f8668d27a60d841541fbe4 --- /dev/null +++ b/app/views/admin/oauth2/_setup_key.php @@ -0,0 +1,28 @@ +<?php + $checkmark = function (bool $checked): Icon { + return $checked + ? Icon::create('accept', Icon::ROLE_STATUS_GREEN) + : Icon::create('decline', Icon::ROLE_STATUS_RED); + }; + + $predicate = function ($checked, $positive, $negative) { + return $checked ? $positive : $negative; + }; +?> +<ul> + <li style="list-style-image: url(<?= $checkmark($key->exists())->asImagePath() ?>)"> + <?= $predicate($key->exists(), _('Datei existiert.'), _('Datei existiert nicht.')) ?> + </li> + <li style="list-style-image: url(<?= $checkmark($key->isReadable())->asImagePath() ?>)"> + <?= $predicate($key->isReadable(), _('Datei ist lesbar.'), _('Datei ist nicht lesbar.')) ?> + </li> + <? if ($key->isReadable()) { ?> + <li style="list-style-image: url(<?= $checkmark($key->hasProperMode())->asImagePath() ?>)"> + <?= $predicate( + $key->hasProperMode(), + sprintf(_('Korrekte Zugriffsberechtigung: %s'), $key->mode()), + sprintf(_('Falsche Zugriffsberechtigung: %s'), $key->mode()) + ) ?> + </li> + <? } ?> +</ul> diff --git a/app/views/admin/oauth2/index.php b/app/views/admin/oauth2/index.php new file mode 100644 index 0000000000000000000000000000000000000000..8ac0f188e15b1c2fecdb3a6205db8e7fdd9b4ffe --- /dev/null +++ b/app/views/admin/oauth2/index.php @@ -0,0 +1,14 @@ +<?= $this->render_partial('admin/oauth2/_notices') ?> + +<article class="studip admin-oauth2--setup"> + <header> + <h1> + <a name="setup"> + <?= _('OAuth2-Setup') ?> + </a> + </h1> + </header> + <?= $this->render_partial('admin/oauth2/_setup') ?> +</article> + +<?= $this->render_partial('admin/oauth2/_clients') ?> diff --git a/app/views/api/oauth/authorize.php b/app/views/api/oauth/authorize.php index 8330e9f658d1d4019d0d39c5955094f2b177b4ac..6c665328ad29675331b8dab3bdaa39aad8e96693 100644 --- a/app/views/api/oauth/authorize.php +++ b/app/views/api/oauth/authorize.php @@ -23,11 +23,12 @@ htmlReady($GLOBALS['user']->username) ) ?><br> <small> - <?= sprintf( - _('Sind sie nicht <strong>%s</strong>, so <a href="%s">melden Sie sich bitte ab</a> und versuchen es erneut.'), - htmlReady($GLOBALS['user']->getFullName()), - URLHelper::getLink('logout.php') - ) ?> + <a href="<?= URLHelper::getLink('logout.php') ?>"> + <?= sprintf( + _('Sind sie nicht <strong>%s</strong>, so melden Sie sich bitte ab und versuchen es erneut.'), + htmlReady($GLOBALS['user']->getFullName()) + ) ?> + </a> </small> </p> </section> diff --git a/app/views/api/oauth2/applications/details.php b/app/views/api/oauth2/applications/details.php new file mode 100644 index 0000000000000000000000000000000000000000..12533a07543f0bf17313415c7aa495ad3662f149 --- /dev/null +++ b/app/views/api/oauth2/applications/details.php @@ -0,0 +1,24 @@ +<dl> + <dt><?= _('Name') ?></dt> + <dl><?= htmlReady($application['name']) ?></dl> + + <dt><?= _('Beschreibung') ?></dt> + <dl><?= htmlReady($application['description']) ?></dl> + + <dt><?= _('Von wem wird der OAuth2-Client entwickelt?') ?></dt> + <dl> + <a rel="noreferrer noopener" target="_blank" + href="<?= htmlReady($application['homepage']) ?>"> + <?= htmlReady($application['owner']) ?> + </a> + </dl> + + <dt><?= _('Berechtigungen') ?></dt> + <dd> + <ul> + <? foreach ($application['scopes'] as $scope) { ?> + <li><?= htmlReady($scope->description) ?></li> + <? } ?> + </ul> + </dd> +</dl> diff --git a/app/views/api/oauth2/applications/index.php b/app/views/api/oauth2/applications/index.php new file mode 100644 index 0000000000000000000000000000000000000000..6a1462a538069ad0ec8975a79b33bb3e0c1251b1 --- /dev/null +++ b/app/views/api/oauth2/applications/index.php @@ -0,0 +1,52 @@ +<? if (isset($applications) && count($applications)) { ?> + <? foreach ($applications as $application) { ?> + <article class="studip"> + <header> + <h1> + <a href="<?= $controller->link_for('api/oauth2/applications/details/' . $application['id']) ?>" data-dialog="size=auto"> + <?= htmlReady($application['name']) ?> + </a> + </h1> + <nav> + <form + action ="<?= $controller->link_for('api/oauth2/applications/revoke') ?>" + method="post"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="application" value="<?= htmlReady($application['id']) ?>"> + <?= ActionMenu::get() + ->addButton( + _('Autorisierung widerrufen'), + 'revoke_authorisation', + Icon::create('trash'), + [ + 'data-confirm' => _('Wollen Sie die OAuth2-Autorisierung wirklich widerrufen?'), + 'title' => _('Autorisierung widerrufen'), + ] + ) + ->render() ?> + </form> + </nav> + </header> + + <div> + <span class="oauth2-application--owned-by"> + <?= _('Entwickelt durch:') ?> + <a rel="noreferrer noopener" target="_blank" + href="<?= htmlReady($application['homepage']) ?>"> + <?= htmlReady($application['owner']) ?> + </a> + </span> + </div> + + <ul> + <? foreach ($application['scopes'] as $scope) { ?> + <li><?= htmlReady($scope->description) ?></li> + <? } ?> + </ul> + </article> + <? } ?> +<? } else { ?> + <?= \MessageBox::info( + _('Keine autorisierten Drittanwendungen'), + [ _('Sie haben keine Anwendungen, die zum Zugriff auf Ihr Konto berechtigt sind.') ]) ?> +<? } ?> diff --git a/app/views/api/oauth2/authorize.php b/app/views/api/oauth2/authorize.php new file mode 100644 index 0000000000000000000000000000000000000000..693968adf86e818596b56e9280e09cf31718dbac --- /dev/null +++ b/app/views/api/oauth2/authorize.php @@ -0,0 +1,60 @@ +<section class="oauth authorize"> + <header> + <h1><?= _('Autorisierungsanfrage') ?></h1> + </header> + + <p> + <?= sprintf( + _('Die Applikation <strong>"%s"</strong> möchte auf Ihre Daten zugreifen.'), + htmlReady($client->getName()) + ) ?> + </p> + + <? if (count($scopes) > 0) { ?> + <div class="scopes"> + <p><strong><?= _('Diese Applikation hat Zugriff auf:') ?></strong></p> + <ul> + <? foreach ($scopes as $scope) { ?> + <li><?= htmlReady($scope->description) ?></li> + <? } ?> + </ul> + </div> + <? } ?> + + <div class="buttons"> + <form action="<?= $controller->url_for('api/oauth2/authorize') ?>" method="post"> + <?= \CSRFProtection::tokenTag() ?> + <input type="hidden" name="_method" value="delete"> + <input type="hidden" name="state" value="<?= htmlReady($state) ?>"> + <input type="hidden" name="client_id" value="<?= htmlReady($client->id) ?>"> + <input type="hidden" name="auth_token" value="<?= htmlReady($authToken) ?>"> + <?= Studip\Button::create(_('Verweigern'), 'deny') ?> + </form> + + <form action="<?= $controller->url_for('api/oauth2/authorize') ?>" method="post"> + <?= \CSRFProtection::tokenTag() ?> + <input type="hidden" name="state" value="<?= htmlReady($state) ?>"> + <input type="hidden" name="client_id" value="<?= htmlReady($client->id) ?>"> + <input type="hidden" name="auth_token" value="<?= htmlReady($authToken) ?>"> + <?= Studip\Button::create(_('Erlauben'), 'allow') ?> + </form> + </div> + + <p> + <?= Avatar::getAvatar($GLOBALS['user']->id)->getImageTag(Avatar::SMALL) ?> + + <?= sprintf( + _('Angemeldet als <strong>%s</strong> (%s)'), + htmlReady($GLOBALS['user']->getFullName()), + htmlReady($GLOBALS['user']->username) + ) ?><br> + <small> + <a href="<?= URLHelper::getLink('logout.php') ?>"> + <?= sprintf( + _('Sind sie nicht <strong>%s</strong>, so melden Sie sich bitte ab und versuchen es erneut.'), + htmlReady($GLOBALS['user']->getFullName()) + ) ?> + </a> + </small> + </p> +</section> diff --git a/app/views/api/oauth2/clients/add.php b/app/views/api/oauth2/clients/add.php new file mode 100644 index 0000000000000000000000000000000000000000..855f446e989762acf929e2174e36b92adc487450 --- /dev/null +++ b/app/views/api/oauth2/clients/add.php @@ -0,0 +1,86 @@ +<form class="default" action="<?= $controller->url_for('api/oauth2/clients/store') ?>" method="post"> + <?= CSRFProtection::tokenTag() ?> + + <fieldset> + <legend> + <?= _('Basisdaten des OAuth2-Clients') ?> + </legend> + <label> + <span class="required"> + <?= _('Name') ?> + </span> + <input required type="text" name="name"> + </label> + + <label> + <span class="required"> + <?= _('Redirect-URIs') ?> + </span> + <textarea required name="redirect" placeholder="<?= _('schema://<redirect-uri-1>\nschema://<redirect-uri-2>') ?>" maxlength="1000"></textarea> + </label> + + <label> + <span> + <?= _('Beschreibung') ?> + </span> + <textarea name="description" maxlength="1000"></textarea> + </label> + </fieldset> + + <fieldset class="oauth2-clients--confidentiality"> + <legend class="required"> + <?= _('Kann der OAuth2-Client kryptographische Geheimnisse bewahren?') ?> + </legend> + + <div> + <input type="radio" name="confidentiality" value="public" id="oauth2-clients-confidentiality--public" required> + <label for="oauth2-clients-confidentiality--public"> + <?= _('Nein. Es handelt sich zum Beispiel um eine <span lang="en">Mobile App</span> oder <span lang="en">Single Page App</span>.') ?> + </label> + </div> + + <div> + <input type="radio" name="confidentiality" value="confidential" id="oauth2-clients-confidentiality--confidential"> + <label for="oauth2-clients-confidentiality--confidential"> + <?= _('Ja, dieser OAuth2-Client kann ein kryptographisches Geheimnis bewahren.') ?> + </label> + </div> + </fieldset> + + <fieldset> + <legend> + <?= _('Meta-Informationen') ?> + </legend> + + <label> + <span class="required"> + <?= _('Von wem wird der OAuth2-Client entwickelt?') ?> + </span> + <input required type="text" name="owner" maxlength="100"> + </label> + + <label> + <span class="required"> + <?= _('Homepage der Entwickelnden des OAuth2-Clients') ?> + </span> + <input required type="url" name="homepage" maxlength="200"> + </label> + + <label> + <span> + <?= _('Notizen (nur für Root-Accounts sichtbar)') ?> + </span> + <textarea name="admin_notes"></textarea> + </label> + + </fieldset> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Erstellen'), 'create_client', [ + 'title' => _('Neuen OAuth2-Client erstellen'), + ]) ?> + <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('admin/oauth2'), [ + 'title' => _('Zurück zur Übersicht'), + ]) ?> + </footer> +</form> diff --git a/cli/Commands/OAuth2/Keys.php b/cli/Commands/OAuth2/Keys.php new file mode 100644 index 0000000000000000000000000000000000000000..c9c073836fca83ef4b3609a78655001b77bbe6f5 --- /dev/null +++ b/cli/Commands/OAuth2/Keys.php @@ -0,0 +1,68 @@ +<?php + +namespace Studip\Cli\Commands\OAuth2; + +use phpseclib\Crypt\RSA; +use Studip\OAuth2\Container; +use Studip\OAuth2\KeyInformation; +use Studip\OAuth2\SetupInformation; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class Keys extends Command +{ + protected static $defaultName = 'oauth2:keys'; + + protected function configure(): void + { + $this->setDescription( + 'Erstelle alle kryptografischen Schlüssel, um Stud.IP als OAuth2-Authorization-Server zu verwenden.' + ); + $this->addOption('force', null, InputOption::VALUE_NONE, 'Überschreibe ggf. vorhandene Schlüssel'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $container = new Container(); + $setup = $container->get(SetupInformation::class); + + $encryptionKey = $setup->encryptionKey(); + $publicKey = $setup->publicKey(); + $privateKey = $setup->privateKey(); + + $force = $input->getOption('force'); + + if (($encryptionKey->exists() || $publicKey->exists() || $privateKey->exists()) && !$force) { + $io->error( + 'Schlüsseldateien liegen bereits vor. Verwenden Sie die Option --force, um diese zu überschreiben.' + ); + return Command::FAILURE; + } + + $this->storeKeyContentsToFile($encryptionKey, $this->generateEncryptionKey()); + + $keys = (new RSA())->createKey(4096); + $this->storeKeyContentsToFile($publicKey, $keys['publickey']); + $this->storeKeyContentsToFile($privateKey, $keys['privatekey']); + + $io->info('Schlüsseldateien erfolgreich angelegt.'); + + return Command::SUCCESS; + } + + private function storeKeyContentsToFile(KeyInformation $key, string $contents) + { + file_put_contents($key->filename(), $contents); + chmod($key->filename(), 0660); + } + + private function generateEncryptionKey(): string + { + return "<?php return '" . randomString(48) . "';"; + } +} diff --git a/cli/Commands/OAuth2/Purge.php b/cli/Commands/OAuth2/Purge.php new file mode 100644 index 0000000000000000000000000000000000000000..3f7561f79e853dcee7a9ebbd2daaa9ee8d1fc21f --- /dev/null +++ b/cli/Commands/OAuth2/Purge.php @@ -0,0 +1,58 @@ +<?php + +namespace Studip\Cli\Commands\OAuth2; + +use Studip\OAuth2\Models\AccessToken; +use Studip\OAuth2\Models\AuthCode; +use Studip\OAuth2\Models\RefreshToken; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +class Purge extends Command +{ + protected static $defaultName = 'oauth2:purge'; + + protected function configure(): void + { + $this->setDescription('Bereinige die OAuth2-Datenbanktabellen von widerrufenen und/oder abgelaufenen Token'); + $this->addOption('revoked', null, InputOption::VALUE_NONE, 'Entferne widerrufene Token'); + $this->addOption('expired', null, InputOption::VALUE_NONE, 'Entferne abgelaufene Token'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $expiryDate = strtotime('-7days midnight'); + + $revoked = $input->getOption('revoked'); + $expired = $input->getOption('expired'); + + if (($revoked && $expired) || (!$revoked && !$expired)) { + AccessToken::deleteBySQL('revoked = ? AND expires_at < ?', [1, $expiryDate]); + AuthCode::deleteBySQL('revoked = ? AND expires_at < ?', [1, $expiryDate]); + RefreshToken::deleteBySQL('revoked = ? AND expires_at < ?', [1, $expiryDate]); + + $io->info( + 'Alle Token, die widerrufen wurden oder vor mindestens 7 Tagen abgelaufen sind, wurden entfernt.' + ); + } elseif ($revoked) { + AccessToken::deleteBySQL('revoked = ?', [1]); + AuthCode::deleteBySQL('revoked = ?', [1]); + RefreshToken::deleteBySQL('revoked = ?', [1]); + + $io->info('Alle widerrufenen Token wurden entfernt.'); + } elseif ($expired) { + AccessToken::deleteBySQL('expires_at < ?', [$expiryDate]); + AuthCode::deleteBySQL('expires_at < ?', [$expiryDate]); + RefreshToken::deleteBySQL('expires_at < ?', [$expiryDate]); + + $io->info('Alle Token, die vor mindestens 7 Tagen abgelaufen sind, wurden entfernt.'); + } + + return Command::SUCCESS; + } +} diff --git a/cli/studip b/cli/studip index af6273f62b89690f2186fafb16dfb671498f5dab..2b242387280b10f7b8af612cbafec594123ab9c1 100755 --- a/cli/studip +++ b/cli/studip @@ -37,6 +37,8 @@ $commands = [ Commands\Migrate\MigrateList::class, Commands\Migrate\MigrateStatus::class, Commands\Migrate\Migrate::class, + Commands\OAuth2\Keys::class, + Commands\OAuth2\Purge::class, Commands\Plugins\PluginActivate::class, Commands\Plugins\PluginDeactivate::class, Commands\Plugins\PluginInfo::class, diff --git a/composer.json b/composer.json index 21f4c3b0cee9928fb949401bb3e0f49fa189fc4d..696383c3c73b5c47cdec2fd7ff38435527e1c5b2 100644 --- a/composer.json +++ b/composer.json @@ -49,8 +49,9 @@ "slim/psr7": "1.4", "slim/slim": "4.7.1", "php-di/php-di": "6.3.4", - "symfony/console": "5.3.6", + "symfony/console": "~5.3.16", "symfony/process": "^5.4", - "jumbojett/openid-connect-php": "^0.9.2" + "jumbojett/openid-connect-php": "^0.9.2", + "league/oauth2-server": "^8.3" } } diff --git a/composer.lock b/composer.lock index fed4287ec42fd68729b05053c0a7335a18b39ee8..6ee399c66cbcbbbd85f8530f37399aef101dd6e9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1e33133d5b5338062325db583d6e62a7", + "content-hash": "d8b9598df77b1d0451559f6951ad6c2d", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -86,12 +86,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "Assert\\": "lib/Assert" - }, "files": [ "lib/Assert/functions.php" - ] + ], + "psr-4": { + "Assert\\": "lib/Assert" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -242,12 +242,12 @@ }, "type": "library", "autoload": { - "psr-0": { - "HTMLPurifier": "library/" - }, "files": [ "library/HTMLPurifier.composer.php" ], + "psr-0": { + "HTMLPurifier": "library/" + }, "exclude-from-classmap": [ "/library/HTMLPurifier/Language/" ] @@ -335,12 +335,12 @@ "version": "v1.6", "source": { "type": "git", - "url": "https://github.com/gossi/docblock.git", + "url": "https://github.com/phpowermove/docblock.git", "reference": "d7e2f299279f5aebbfddeef1c119e26bef4bc7e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/gossi/docblock/zipball/d7e2f299279f5aebbfddeef1c119e26bef4bc7e9", + "url": "https://api.github.com/repos/phpowermove/docblock/zipball/d7e2f299279f5aebbfddeef1c119e26bef4bc7e9", "reference": "d7e2f299279f5aebbfddeef1c119e26bef4bc7e9", "shasum": "" }, @@ -376,8 +376,9 @@ ], "support": { "issues": "https://github.com/gossi/docblock/issues", - "source": "https://github.com/gossi/docblock/tree/master" + "source": "https://github.com/phpowermove/docblock/tree/v1.6" }, + "abandoned": "phpowermove/docblock", "time": "2017-07-01T18:10:54+00:00" }, { @@ -411,12 +412,12 @@ } }, "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -780,12 +781,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "Neomerx\\JsonApi\\": "src/" - }, "files": [ "src/I18n/format.php" - ] + ], + "psr-4": { + "Neomerx\\JsonApi\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -835,12 +836,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "FastRoute\\": "src/" - }, "files": [ "src/functions.php" - ] + ], + "psr-4": { + "FastRoute\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -891,12 +892,12 @@ } }, "autoload": { - "psr-4": { - "Opis\\Closure\\": "src/" - }, "files": [ "functions.php" - ] + ], + "psr-4": { + "Opis\\Closure\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1302,12 +1303,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "DI\\": "src/" - }, "files": [ "src/functions.php" - ] + ], + "psr-4": { + "DI\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2492,12 +2493,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2571,12 +2572,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2652,12 +2653,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -2732,12 +2733,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2796,12 +2797,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php56\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Php56\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2872,12 +2873,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -2951,12 +2952,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -3250,12 +3251,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "Symfony\\Component\\String\\": "" - }, "files": [ "Resources/functions.php" ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, "exclude-from-classmap": [ "/Tests/" ] @@ -3801,6 +3802,7 @@ "issues": "https://github.com/camspiers/json-pretty/issues", "source": "https://github.com/camspiers/json-pretty/tree/master" }, + "abandoned": true, "time": "2016-02-06T01:25:58+00:00" }, { @@ -3825,12 +3827,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "Clue\\StreamFilter\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4325,9 +4327,6 @@ "require": { "php": "^7.1 || ^8.0" }, - "replace": { - "myclabs/deep-copy": "self.version" - }, "require-dev": { "doctrine/collections": "^1.0", "doctrine/common": "^2.6", @@ -4335,12 +4334,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - }, "files": [ "src/DeepCopy/deep_copy.php" - ] + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4827,12 +4826,12 @@ } }, "autoload": { - "psr-4": { - "Http\\Message\\": "src/" - }, "files": [ "src/filters.php" - ] + ], + "psr-4": { + "Http\\Message\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5192,16 +5191,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.8.1", + "version": "1.7.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "8dbba631fa32f4b289404469c2afd6122fd61d67" + "reference": "cd0202ea1b1fc6d1bbe156c6e2e18a03e0ff160a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8dbba631fa32f4b289404469c2afd6122fd61d67", - "reference": "8dbba631fa32f4b289404469c2afd6122fd61d67", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/cd0202ea1b1fc6d1bbe156c6e2e18a03e0ff160a", + "reference": "cd0202ea1b1fc6d1bbe156c6e2e18a03e0ff160a", "shasum": "" }, "require": { @@ -5227,7 +5226,7 @@ "description": "PHPStan - PHP Static Analysis Tool", "support": { "issues": "https://github.com/phpstan/phpstan/issues", - "source": "https://github.com/phpstan/phpstan/tree/1.8.1" + "source": "https://github.com/phpstan/phpstan/tree/1.7.15" }, "funding": [ { @@ -5247,7 +5246,7 @@ "type": "tidelift" } ], - "time": "2022-07-12T16:08:06+00:00" + "time": "2022-06-20T08:29:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6900,8 +6899,7 @@ "ext-json": "*", "ext-pcre": "*", "ext-pdo": "*", - "ext-mbstring": "*", - "ext-dom": "*" + "ext-mbstring": "*" }, "platform-dev": [], "plugin-api-version": "2.3.0" diff --git a/config/oauth2/.gitkeep b/config/oauth2/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/db/migrations/5.2.15_create_oauth2_tables.php b/db/migrations/5.2.15_create_oauth2_tables.php new file mode 100644 index 0000000000000000000000000000000000000000..460d4e0638429e05febdbb992b0d4ee408c9ece0 --- /dev/null +++ b/db/migrations/5.2.15_create_oauth2_tables.php @@ -0,0 +1,83 @@ +<?php + +class CreateOauth2Tables extends Migration +{ + public function description() + { + return 'creates all necessary tables for the OAuth2 plugin'; + } + + public function up() + { + $db = DBManager::get(); + + $query = "CREATE TABLE IF NOT EXISTS `oauth2_access_tokens` ( + `id` VARCHAR(100) NOT NULL, + `user_id` CHAR(32) COLLATE `latin1_bin` NULL, + `client_id` BIGINT UNSIGNED NOT NULL, + `scopes` TEXT NULL, + `revoked` TINYINT(1) NOT NULL DEFAULT 0, + `expires_at` INT(11) NULL, + `mkdate` INT(11) NOT NULL, + `chdate` INT(11) NOT NULL, + + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) + )"; + $db->exec($query); + + $query = "CREATE TABLE IF NOT EXISTS `oauth2_auth_codes` ( + `id` VARCHAR(100) NOT NULL, + `user_id` CHAR(32) COLLATE `latin1_bin` NOT NULL, + `client_id` BIGINT UNSIGNED NOT NULL, + `scopes` TEXT NULL, + `revoked` TINYINT(1) NOT NULL DEFAULT 0, + `expires_at` INT(11) NULL, + `mkdate` INT(11) NOT NULL, + `chdate` INT(11) NOT NULL, + + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) + )"; + $db->exec($query); + + $query = "CREATE TABLE IF NOT EXISTS `oauth2_clients` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `secret` VARCHAR(100) NULL, + `redirect` TEXT NOT NULL, + `revoked` TINYINT(1) NOT NULL DEFAULT 0, + + `description` TEXT NULL, + `owner` VARCHAR(255) NULL, + `homepage` VARCHAR(255) NULL, + `admin_notes` TEXT NULL, + + `mkdate` INT(11) NOT NULL, + `chdate` INT(11) NOT NULL, + + PRIMARY KEY (`id`) + )"; + $db->exec($query); + + $query = "CREATE TABLE IF NOT EXISTS `oauth2_refresh_tokens` ( + `id` VARCHAR(100) NOT NULL, + `access_token_id` VARCHAR(100) NOT NULL, + `revoked` TINYINT(1) NOT NULL DEFAULT 0, + `expires_at` INT(11) NULL, + + PRIMARY KEY (`id`), + KEY `access_token_id` (`access_token_id`) + )"; + $db->exec($query); + } + + public function down() + { + $db = \DBManager::get(); + $db->exec('DROP TABLE IF EXISTS `oauth2_access_tokens`'); + $db->exec('DROP TABLE IF EXISTS `oauth2_auth_codes`'); + $db->exec('DROP TABLE IF EXISTS `oauth2_clients`'); + $db->exec('DROP TABLE IF EXISTS `oauth2_refresh_tokens`'); + } +} diff --git a/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php b/lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php new file mode 100644 index 0000000000000000000000000000000000000000..ce44485a10efd2bcd406b7d52313fb79b61b96c2 --- /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 3e4ee176a78a4998c1f5cb18f71788f8676ecc37..5b097d677a33086ebbba4af5a1ce1e16fd1092c9 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 0000000000000000000000000000000000000000..987f0a0abe3c07ab9c0299bffa3a604eeaa93af2 --- /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 0000000000000000000000000000000000000000..2762f6bbc49ca7b9ec975aa4054cd0b197a2c906 --- /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 0000000000000000000000000000000000000000..5514968aa33ed34714917ac94440f64cbb14b0f7 --- /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 0000000000000000000000000000000000000000..5676622269e9fc60bd5555d2b780d41d0d74646d --- /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 0000000000000000000000000000000000000000..106caa03733a36cfb4e77b130e96e1bb6464c835 --- /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 0000000000000000000000000000000000000000..b6fd4f6a85624ca00f36ccd6584a78f0885e0f59 --- /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 0000000000000000000000000000000000000000..a0dda5e3f4a928087152c72b1da3af8cd3aa1910 --- /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 0000000000000000000000000000000000000000..44cb16c6578efbc5c616940428962727191da52e --- /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 0000000000000000000000000000000000000000..844600a7a256fe7fe1c40884723aaf2dbdf8260a --- /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 0000000000000000000000000000000000000000..65d666e8486d916d555a8a188ab6d60273c20fb8 --- /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 0000000000000000000000000000000000000000..d075381ef23f24b9f03d82f9e6d53bc41cb47439 --- /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 0000000000000000000000000000000000000000..02ba52ffaf07bf4379f35b36c12e5c044ff0a85c --- /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 0000000000000000000000000000000000000000..e46b127eea27eafc3453bf25d15a6e9c6764560e --- /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 0000000000000000000000000000000000000000..69949b150b80044bd895ebf12950697f42005d0c --- /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 0000000000000000000000000000000000000000..e7b9928021b2da49cab59f9144c8e23142ceb55f --- /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 0000000000000000000000000000000000000000..8fe7a4f150b104ba28b5de0665598bb44251aeae --- /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 0000000000000000000000000000000000000000..3c57973d86b898693ddb582a291f801977f43c2c --- /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 0000000000000000000000000000000000000000..1a43c8c9a28fcc565d5172bd6bee45cbdefcbb74 --- /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 0000000000000000000000000000000000000000..935e81246c27328ad7da71359420a855e741d3bf --- /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 0000000000000000000000000000000000000000..cf9a253e377096fb03cffea01c30ada9a2f57147 --- /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 0000000000000000000000000000000000000000..8c973aaaa2d394e840f6b9626b0a3ff62d28e5f0 --- /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 0000000000000000000000000000000000000000..86bf81551db3109ec19e782b996b858db03ecabc --- /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 0000000000000000000000000000000000000000..0edf2431cfef7790b1e0e85d271b876d905e7c11 --- /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 0000000000000000000000000000000000000000..01b0d09a578ae9aea52bed6d42b9eb3af80e3065 --- /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 ecb34b7c1222d2173b6fc9ccb361d24bd650acca..ee26d7c4e21b76263cc1834f48b09f9b5f13d50a 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 { diff --git a/lib/functions.php b/lib/functions.php index 0c30bfafd6e7c23490ca0b96b1f4648c3f8ac88c..a7f9f3a0b2fed0d65983d918c85c1ce6e1902f31 100644 --- a/lib/functions.php +++ b/lib/functions.php @@ -1873,3 +1873,18 @@ function encodeURI(string $uri): string ]; return strtr(rawurlencode($uri), $replacements); } + +function randomString(int $length = 32): string +{ + $string = ''; + + while (($len = strlen($string)) < $length) { + $size = $length - $len; + + $bytes = random_bytes($size); + + $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size); + } + + return $string; +} diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php index 915ceb3b48a00786be7057ac2235ac6da850179c..2081af91aa6572448bc2c1a7e27fdfa32dcd71da 100644 --- a/lib/navigation/AdminNavigation.php +++ b/lib/navigation/AdminNavigation.php @@ -198,6 +198,8 @@ class AdminNavigation extends Navigation $navigation->addSubNavigation('api', new Navigation(_('API'), 'dispatch.php/admin/api')); } + $navigation->addSubNavigation('oauth2', new Navigation(_('OAuth2'), 'dispatch.php/admin/oauth2/index')); + $navigation->addSubNavigation('globalsearch', new Navigation(_('Globale Suche'), 'dispatch.php/globalsearch/settings')); $navigation->addSubNavigation('cache', new Navigation(_('Cache'), 'dispatch.php/admin/cache/settings')); } diff --git a/lib/navigation/ProfileNavigation.php b/lib/navigation/ProfileNavigation.php index 6507c57a7de08c2caf4efc38452bec037b725143..65faca9ae6be5d0b3ef0ce24ab0d9f4860ecb030 100644 --- a/lib/navigation/ProfileNavigation.php +++ b/lib/navigation/ProfileNavigation.php @@ -114,6 +114,8 @@ class ProfileNavigation extends Navigation $navigation->addSubNavigation('tfa', new Navigation(_('Zwei-Faktor-Authentifizierung'), 'dispatch.php/tfa')); } + $navigation->addSubNavigation('oauth2', new Navigation(_('Drittanwendungen'), 'dispatch.php/api/oauth2/applications')); + $navigation->addSubNavigation('accessibility', new Navigation( _('Barrierefreiheit'), 'dispatch.php/settings/accessibility' diff --git a/lib/phplib/Seminar_Auth.class.php b/lib/phplib/Seminar_Auth.class.php index bd796fabdc8870c9a48b26d16965f4557399a6e0..c1c2b860b8b57de1d884f7cce56954c3fdaa8cac 100644 --- a/lib/phplib/Seminar_Auth.class.php +++ b/lib/phplib/Seminar_Auth.class.php @@ -151,7 +151,7 @@ class Seminar_Auth $uid = $this->auth_validatelogin(); if ($uid) { $this->auth["uid"] = $uid; - $keep_session_vars = ['auth', 'forced_language', '_language', 'contrast']; + $keep_session_vars = ['auth', 'forced_language', '_language', 'contrast', 'oauth2']; if ($this->auth['perm'] === 'root') { $keep_session_vars[] = 'plugins_disabled'; } diff --git a/public/oauth2.php b/public/oauth2.php new file mode 100644 index 0000000000000000000000000000000000000000..18c986963b50af4641bb9d2378dd080e5f822fad --- /dev/null +++ b/public/oauth2.php @@ -0,0 +1,105 @@ +<?php + +use DI\ContainerBuilder; +use Slim\Factory\AppFactory; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use League\OAuth2\Server\AuthorizationServer; + +use Studip\OAuth2\AccessTokenRepository; +use Studip\OAuth2\AuthCodeRepository; +use Studip\OAuth2\ClientRepository; +use Studip\OAuth2\ScopeRepository; +use Studip\OAuth2\UserEntity; + +require '../lib/bootstrap.php'; +require '../composer/autoload.php'; + +function addRoutes(\Slim\App $app, AuthorizationServer $server): void +{ + $app->get('/authorize', function (Request $request, Response $response) use ($server) { + try { + // Validate the HTTP request and return an AuthorizationRequest object. + $authRequest = $server->validateAuthorizationRequest($request); + + // The auth request object can be serialized and saved into a user's session. + // You will probably want to redirect the user at this point to a login endpoint. + $_SESSION['oauth2_auth_request'] = serialize($authRequest); + var_dump($_SESSION['oauth2_auth_request']);exit; + + + // Once the user has logged in set the user on the AuthorizationRequest + $authRequest->setUser(new UserEntity()); // an instance of UserEntityInterface + + // At this point you should redirect the user to an authorization page. + // This form will ask the user to approve the client and the scopes requested. + + // Once the user has approved or denied the client update the status + // (true = approved, false = denied) + $authRequest->setAuthorizationApproved(true); + + // Return the HTTP redirect response + return $server->completeAuthorizationRequest($authRequest, $response); + } catch (OAuthServerException $exception) { + // All instances of OAuthServerException can be formatted into a HTTP response + return $exception->generateHttpResponse($response); + } catch (\Exception $exception) { + // Unknown exception + $body = new Stream(fopen('php://temp', 'r+')); + $body->write($exception->getMessage()); + return $response->withStatus(500)->withBody($body); + } + }); +} + +$clientRepository = new ClientRepository(); // instance of ClientRepositoryInterface +$scopeRepository = new ScopeRepository(); // instance of ScopeRepositoryInterface +$accessTokenRepository = new AccessTokenRepository(); // instance of AccessTokenRepositoryInterface +$authCodeRepository = new AuthCodeRepository(); // instance of AuthCodeRepositoryInterface +$refreshTokenRepository = new RefreshTokenRepository(); // instance of RefreshTokenRepositoryInterface + +$privateKey = 'file://path/to/private.key'; +//$privateKey = new CryptKey('file://path/to/private.key', 'passphrase'); // if private key has a pass phrase +$encryptionKey = 'lxZFUEsBCJ2Yb14IF2ygAHI5N4+ZAUXXaSeeJm6+twsUmIen'; // generate using base64_encode(random_bytes(32)) + + +// Setup the authorization server +$server = new \League\OAuth2\Server\AuthorizationServer( + $clientRepository, + $accessTokenRepository, + $scopeRepository, + $privateKey, + $encryptionKey +); + +page_open([ + 'sess' => 'Seminar_Session', + 'auth' => 'Seminar_Default_Auth', + 'perm' => 'Seminar_Perm', + 'user' => 'Seminar_User', +]); + +// Set base url for URLHelper class +URLHelper::setBaseUrl($GLOBALS['CANONICAL_RELATIVE_PATH_STUDIP']); + +$containerBuilder = new ContainerBuilder(); +$container = $containerBuilder->build(); + +AppFactory::setContainer($container); +$app = AppFactory::create(); +$container->set(\Slim\App::class, $app); + +$app->setBasePath('/oauth2.php'); + +$app->addRoutingMiddleware(); +addRoutes($app, $server); + +$displayErrors = false; +if (defined('\\Studip\\ENV')) { + $displayErrors = constant('\\Studip\\ENV') === 'development'; +} +$logError = true; +$logErrorDetails = true; +$errorMiddleware = $app->addErrorMiddleware($displayErrors, $logError, $logErrorDetails); + +$app->run(); diff --git a/resources/assets/stylesheets/scss/oauth2.scss b/resources/assets/stylesheets/scss/oauth2.scss new file mode 100644 index 0000000000000000000000000000000000000000..a633f1b607baf5ff8a0bec00fe2d16d66a99d043 --- /dev/null +++ b/resources/assets/stylesheets/scss/oauth2.scss @@ -0,0 +1,26 @@ +article.admin-oauth2--setup { + margin-bottom: 3em; +} + +.oauth2-clients--confidentiality > div { + display: flex; + align-items: flex-start; +} + +#api-oauth2-authorize-index { + + font-size: 16px; + + #layout-sidebar, #layout_footer { + display: none; + } + + .scopes, .buttons { + margin-top: 2rem; + margin-bottom: 2rem; + } + + .buttons { + display: flex; + } +} diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index fa5805e42362bade2d99644ec6572b353f3a657b..62bc7b59ca9c451205f4a39bd91a1bcf09687373 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -24,6 +24,7 @@ @import "scss/my_courses"; @import "scss/oer"; @import "scss/qrcode"; +@import "scss/oauth2"; @import "scss/report"; @import "scss/resources"; @import "scss/sidebar";