aboutsummaryrefslogtreecommitdiff
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
parenta9585dad3547a4ebbadd00f44065f95017d18684 (diff)
StEP-366: Add OAuth2 support to Stud.IP
Closes #1035 and #1198 Merge request studip/studip!635
-rw-r--r--.gitignore2
-rw-r--r--app/controllers/admin/oauth2.php46
-rw-r--r--app/controllers/api/oauth2/applications.php101
-rw-r--r--app/controllers/api/oauth2/authorize.php175
-rw-r--r--app/controllers/api/oauth2/clients.php166
-rw-r--r--app/controllers/api/oauth2/oauth2_controller.php52
-rw-r--r--app/controllers/api/oauth2/token.php29
-rw-r--r--app/views/admin/oauth2/_clients.php79
-rw-r--r--app/views/admin/oauth2/_notices.php10
-rw-r--r--app/views/admin/oauth2/_setup.php17
-rw-r--r--app/views/admin/oauth2/_setup_key.php28
-rw-r--r--app/views/admin/oauth2/index.php14
-rw-r--r--app/views/api/oauth/authorize.php11
-rw-r--r--app/views/api/oauth2/applications/details.php24
-rw-r--r--app/views/api/oauth2/applications/index.php52
-rw-r--r--app/views/api/oauth2/authorize.php60
-rw-r--r--app/views/api/oauth2/clients/add.php86
-rw-r--r--cli/Commands/OAuth2/Keys.php68
-rw-r--r--cli/Commands/OAuth2/Purge.php58
-rwxr-xr-xcli/studip2
-rw-r--r--composer.json5
-rw-r--r--composer.lock162
-rw-r--r--config/oauth2/.gitkeep0
-rw-r--r--db/migrations/5.2.15_create_oauth2_tables.php83
-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
-rw-r--r--lib/functions.php15
-rw-r--r--lib/navigation/AdminNavigation.php2
-rw-r--r--lib/navigation/ProfileNavigation.php2
-rw-r--r--lib/phplib/Seminar_Auth.class.php2
-rw-r--r--public/oauth2.php105
-rw-r--r--resources/assets/stylesheets/scss/oauth2.scss26
-rw-r--r--resources/assets/stylesheets/studip.scss1
58 files changed, 2571 insertions, 90 deletions
diff --git a/.gitignore b/.gitignore
index 1f1829d..869656c 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 0000000..ae4f5f9
--- /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 0000000..d08ec1e
--- /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 0000000..5628d49
--- /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 0000000..c16b67d
--- /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 0000000..fd02ea9
--- /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 0000000..0ae7ffb
--- /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 0000000..0e3db8a
--- /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 0000000..a67539c
--- /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 0000000..2469a1b
--- /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 0000000..d1ee322
--- /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 0000000..8ac0f18
--- /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 8330e9f..6c66532 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 0000000..12533a0
--- /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 0000000..6a1462a
--- /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 0000000..693968a
--- /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 0000000..855f446
--- /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 0000000..c9c0738
--- /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 0000000..3f7561f
--- /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 af6273f..2b24238 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 21f4c3b..696383c 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 fed4287..6ee399c 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 0000000..e69de29
--- /dev/null
+++ b/config/oauth2/.gitkeep
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 0000000..460d4e0
--- /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 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 {
diff --git a/lib/functions.php b/lib/functions.php
index 0c30bfa..a7f9f3a 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 915ceb3..2081af9 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 6507c57..65faca9 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 bd796fa..c1c2b86 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 0000000..18c9869
--- /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 0000000..a633f1b
--- /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 fa5805e..62bc7b5 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";