diff options
| author | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2022-07-15 11:47:35 +0000 |
|---|---|---|
| committer | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2022-07-15 11:47:35 +0000 |
| commit | 55852ef4819e5eafce9ae53dc4de2d84cdad1778 (patch) | |
| tree | 9aedcdf89f416a7936f7df80da339a537082b5d5 /app/controllers/api | |
| parent | a9585dad3547a4ebbadd00f44065f95017d18684 (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.php | 101 | ||||
| -rw-r--r-- | app/controllers/api/oauth2/authorize.php | 175 | ||||
| -rw-r--r-- | app/controllers/api/oauth2/clients.php | 166 | ||||
| -rw-r--r-- | app/controllers/api/oauth2/oauth2_controller.php | 52 | ||||
| -rw-r--r-- | app/controllers/api/oauth2/token.php | 29 |
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); + } +} |
