From 55852ef4819e5eafce9ae53dc4de2d84cdad1778 Mon Sep 17 00:00:00 2001
From: Marcus Eibrink-Lunzenauer
Date: Fri, 15 Jul 2022 11:47:35 +0000
Subject: StEP-366: Add OAuth2 support to Stud.IP
Closes #1035 and #1198
Merge request studip/studip!635
---
.gitignore | 2 +
app/controllers/admin/oauth2.php | 46 ++++++
app/controllers/api/oauth2/applications.php | 101 ++++++++++++
app/controllers/api/oauth2/authorize.php | 175 +++++++++++++++++++++
app/controllers/api/oauth2/clients.php | 166 +++++++++++++++++++
app/controllers/api/oauth2/oauth2_controller.php | 52 ++++++
app/controllers/api/oauth2/token.php | 29 ++++
app/views/admin/oauth2/_clients.php | 79 ++++++++++
app/views/admin/oauth2/_notices.php | 10 ++
app/views/admin/oauth2/_setup.php | 17 ++
app/views/admin/oauth2/_setup_key.php | 28 ++++
app/views/admin/oauth2/index.php | 14 ++
app/views/api/oauth/authorize.php | 11 +-
app/views/api/oauth2/applications/details.php | 24 +++
app/views/api/oauth2/applications/index.php | 52 ++++++
app/views/api/oauth2/authorize.php | 60 +++++++
app/views/api/oauth2/clients/add.php | 86 ++++++++++
cli/Commands/OAuth2/Keys.php | 68 ++++++++
cli/Commands/OAuth2/Purge.php | 58 +++++++
cli/studip | 2 +
composer.json | 5 +-
composer.lock | 162 ++++++++++---------
config/oauth2/.gitkeep | 0
db/migrations/5.2.15_create_oauth2_tables.php | 83 ++++++++++
.../JsonApi/Middlewares/Auth/OAuth2Strategy.php | 103 ++++++++++++
lib/classes/JsonApi/Middlewares/Authentication.php | 1 +
lib/classes/OAuth2/Bridge/AccessTokenEntity.php | 34 ++++
.../OAuth2/Bridge/AccessTokenRepository.php | 74 +++++++++
lib/classes/OAuth2/Bridge/AuthCodeEntity.php | 15 ++
lib/classes/OAuth2/Bridge/AuthCodeRepository.php | 69 ++++++++
lib/classes/OAuth2/Bridge/ClientEntity.php | 27 ++++
lib/classes/OAuth2/Bridge/ClientRepository.php | 59 +++++++
lib/classes/OAuth2/Bridge/RefreshTokenEntity.php | 13 ++
.../OAuth2/Bridge/RefreshTokenRepository.php | 63 ++++++++
lib/classes/OAuth2/Bridge/ScopeEntity.php | 18 +++
lib/classes/OAuth2/Bridge/ScopeRepository.php | 60 +++++++
lib/classes/OAuth2/Bridge/ScopesHelper.php | 18 +++
lib/classes/OAuth2/Bridge/UserEntity.php | 16 ++
lib/classes/OAuth2/Container.php | 121 ++++++++++++++
.../Exceptions/InvalidAuthTokenException.php | 16 ++
lib/classes/OAuth2/Exceptions/SetupError.php | 15 ++
lib/classes/OAuth2/KeyInformation.php | 47 ++++++
lib/classes/OAuth2/Models/AccessToken.php | 50 ++++++
lib/classes/OAuth2/Models/AuthCode.php | 35 +++++
lib/classes/OAuth2/Models/Client.php | 122 ++++++++++++++
lib/classes/OAuth2/Models/RefreshToken.php | 40 +++++
lib/classes/OAuth2/Models/RevokedHelper.php | 25 +++
lib/classes/OAuth2/Models/Scope.php | 59 +++++++
lib/classes/OAuth2/NegotiatesWithPsr7.php | 44 ++++++
lib/classes/OAuth2/SetupInformation.php | 31 ++++
lib/classes/PageLayout.php | 3 +
lib/functions.php | 15 ++
lib/navigation/AdminNavigation.php | 2 +
lib/navigation/ProfileNavigation.php | 2 +
lib/phplib/Seminar_Auth.class.php | 2 +-
public/oauth2.php | 105 +++++++++++++
resources/assets/stylesheets/scss/oauth2.scss | 26 +++
resources/assets/stylesheets/studip.scss | 1 +
58 files changed, 2571 insertions(+), 90 deletions(-)
create mode 100644 app/controllers/admin/oauth2.php
create mode 100644 app/controllers/api/oauth2/applications.php
create mode 100644 app/controllers/api/oauth2/authorize.php
create mode 100644 app/controllers/api/oauth2/clients.php
create mode 100644 app/controllers/api/oauth2/oauth2_controller.php
create mode 100644 app/controllers/api/oauth2/token.php
create mode 100644 app/views/admin/oauth2/_clients.php
create mode 100644 app/views/admin/oauth2/_notices.php
create mode 100644 app/views/admin/oauth2/_setup.php
create mode 100644 app/views/admin/oauth2/_setup_key.php
create mode 100644 app/views/admin/oauth2/index.php
create mode 100644 app/views/api/oauth2/applications/details.php
create mode 100644 app/views/api/oauth2/applications/index.php
create mode 100644 app/views/api/oauth2/authorize.php
create mode 100644 app/views/api/oauth2/clients/add.php
create mode 100644 cli/Commands/OAuth2/Keys.php
create mode 100644 cli/Commands/OAuth2/Purge.php
create mode 100644 config/oauth2/.gitkeep
create mode 100644 db/migrations/5.2.15_create_oauth2_tables.php
create mode 100644 lib/classes/JsonApi/Middlewares/Auth/OAuth2Strategy.php
create mode 100644 lib/classes/OAuth2/Bridge/AccessTokenEntity.php
create mode 100644 lib/classes/OAuth2/Bridge/AccessTokenRepository.php
create mode 100644 lib/classes/OAuth2/Bridge/AuthCodeEntity.php
create mode 100644 lib/classes/OAuth2/Bridge/AuthCodeRepository.php
create mode 100644 lib/classes/OAuth2/Bridge/ClientEntity.php
create mode 100644 lib/classes/OAuth2/Bridge/ClientRepository.php
create mode 100644 lib/classes/OAuth2/Bridge/RefreshTokenEntity.php
create mode 100644 lib/classes/OAuth2/Bridge/RefreshTokenRepository.php
create mode 100644 lib/classes/OAuth2/Bridge/ScopeEntity.php
create mode 100644 lib/classes/OAuth2/Bridge/ScopeRepository.php
create mode 100644 lib/classes/OAuth2/Bridge/ScopesHelper.php
create mode 100644 lib/classes/OAuth2/Bridge/UserEntity.php
create mode 100644 lib/classes/OAuth2/Container.php
create mode 100644 lib/classes/OAuth2/Exceptions/InvalidAuthTokenException.php
create mode 100644 lib/classes/OAuth2/Exceptions/SetupError.php
create mode 100644 lib/classes/OAuth2/KeyInformation.php
create mode 100644 lib/classes/OAuth2/Models/AccessToken.php
create mode 100644 lib/classes/OAuth2/Models/AuthCode.php
create mode 100644 lib/classes/OAuth2/Models/Client.php
create mode 100644 lib/classes/OAuth2/Models/RefreshToken.php
create mode 100644 lib/classes/OAuth2/Models/RevokedHelper.php
create mode 100644 lib/classes/OAuth2/Models/Scope.php
create mode 100644 lib/classes/OAuth2/NegotiatesWithPsr7.php
create mode 100644 lib/classes/OAuth2/SetupInformation.php
create mode 100644 public/oauth2.php
create mode 100644 resources/assets/stylesheets/scss/oauth2.scss
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 @@
+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 @@
+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 @@
+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 @@
+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
+ */
+ 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 client_id lautet: %s '), $client['id']),
+ sprintf(_('Das client_secret lautet: %s '), $client->plainsecret),
+ _(
+ 'Notieren Sie sich bitte das client_secret . Es wird Ihnen nur dieses eine Mal angezeigt.'
+ ),
+ ]);
+ } else {
+ PageLayout::postSuccess(_('Der OAuth2-Client wurde erstellt.'), [
+ sprintf(_('Die client_id lautet: %s '), $client['id']),
+ ]);
+ }
+ }
+
+ /**
+ * Validate the request parameters when creating a new client.
+ *
+ * @return array{0: bool, 1: array, 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 @@
+ '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 @@
+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)) { ?>
+
+ = _('Registrierte OAuth2-Clients') ?>
+
+
+ foreach ($clients as $client) { ?>
+
+
+
+ = htmlReady($client['name']) ?>
+
+
+
+
+
+
+
+
+ = _('Beschreibung') ?>
+ = htmlReady($client['description']) ?>
+
+ = _('Entwickelt durch') ?>
+
+
+ = htmlReady($client['owner']) ?>
+
+
+
+ = _('client_id') ?>
+ = htmlReady($client['id']) ?>
+
+ = _('Redirect-URIs') ?>
+
+
+ foreach ($client->redirectUris() as $uri) { ?>
+ = htmlReady($uri) ?>
+ } ?>
+
+
+
+ = _('Kann kryptographische Geheimnisse bewahren?') ?>
+ = $client->confidential() ? _('Ja') : _('Nein') ?>
+
+ = _('Notizen (nur für Root-Accounts sichtbar)') ?>
+
+ = htmlReady($client['admin_notes']) ?>
+
+
+
+
+ } ?>
+ } ?>
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.') .
+ ' ' .
+ \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 @@
+
+
+ $privateKey = $setup->privateKey(); ?>
+ Private Key (= htmlReady($privateKey->filename()) ?>)
+ = $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $privateKey]) ?>
+
+
+ $publicKey = $setup->publicKey(); ?>
+ Public Key (= htmlReady($publicKey->filename()) ?>)
+ = $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $publicKey]) ?>
+
+
+ $encryptionKey = $setup->encryptionKey(); ?>
+ Encryption Key (= htmlReady($encryptionKey->filename()) ?>)
+ = $this->render_partial('admin/oauth2/_setup_key.php', ['key' => $encryptionKey]) ?>
+
+
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 @@
+
+
+
+ = $predicate($key->exists(), _('Datei existiert.'), _('Datei existiert nicht.')) ?>
+
+
+ = $predicate($key->isReadable(), _('Datei ist lesbar.'), _('Datei ist nicht lesbar.')) ?>
+
+ if ($key->isReadable()) { ?>
+
+ = $predicate(
+ $key->hasProperMode(),
+ sprintf(_('Korrekte Zugriffsberechtigung: %s'), $key->mode()),
+ sprintf(_('Falsche Zugriffsberechtigung: %s'), $key->mode())
+ ) ?>
+
+ } ?>
+
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') ?>
+
+
+
+ = $this->render_partial('admin/oauth2/_setup') ?>
+
+
+= $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)
) ?>
- = sprintf(
- _('Sind sie nicht %s , so melden Sie sich bitte ab und versuchen es erneut.'),
- htmlReady($GLOBALS['user']->getFullName()),
- URLHelper::getLink('logout.php')
- ) ?>
+
+ = sprintf(
+ _('Sind sie nicht %s , so melden Sie sich bitte ab und versuchen es erneut.'),
+ htmlReady($GLOBALS['user']->getFullName())
+ ) ?>
+
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 @@
+
+ = _('Name') ?>
+ = htmlReady($application['name']) ?>
+
+ = _('Beschreibung') ?>
+ = htmlReady($application['description']) ?>
+
+ = _('Von wem wird der OAuth2-Client entwickelt?') ?>
+
+
+ = htmlReady($application['owner']) ?>
+
+
+
+ = _('Berechtigungen') ?>
+
+
+ foreach ($application['scopes'] as $scope) { ?>
+ = htmlReady($scope->description) ?>
+ } ?>
+
+
+
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) { ?>
+
+
+
+
+
+
+ foreach ($application['scopes'] as $scope) { ?>
+ = htmlReady($scope->description) ?>
+ } ?>
+
+
+ } ?>
+ } 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 @@
+
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 @@
+
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 @@
+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 "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
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 @@
+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 @@
+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 @@
+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 @@
+ $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 @@
+ $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 @@
+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 @@
+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 @@
+ $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 @@
+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 @@
+ */
+ 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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+ 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 @@
+ 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 @@
+ \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 @@
+ 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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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";
--
cgit v1.0