aboutsummaryrefslogtreecommitdiff
path: root/app/controllers/api
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 /app/controllers/api
parenta9585dad3547a4ebbadd00f44065f95017d18684 (diff)
StEP-366: Add OAuth2 support to Stud.IP
Closes #1035 and #1198 Merge request studip/studip!635
Diffstat (limited to 'app/controllers/api')
-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
5 files changed, 523 insertions, 0 deletions
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);
+ }
+}