mirror of
https://github.com/nextcloud/server.git
synced 2024-11-14 20:36:50 +00:00
8b60df1600
Signed-off-by: Robin Appelman <robin@icewind.nl>
333 lines
13 KiB
PHP
333 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
/**
|
||
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
|
||
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
|
||
* SPDX-License-Identifier: AGPL-3.0-only
|
||
*/
|
||
namespace OC\AppFramework\Middleware\Security;
|
||
|
||
use OC\AppFramework\Middleware\Security\Exceptions\AdminIpNotAllowedException;
|
||
use OC\AppFramework\Middleware\Security\Exceptions\AppNotEnabledException;
|
||
use OC\AppFramework\Middleware\Security\Exceptions\CrossSiteRequestForgeryException;
|
||
use OC\AppFramework\Middleware\Security\Exceptions\ExAppRequiredException;
|
||
use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
|
||
use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException;
|
||
use OC\AppFramework\Middleware\Security\Exceptions\SecurityException;
|
||
use OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException;
|
||
use OC\AppFramework\Utility\ControllerMethodReflector;
|
||
use OC\Settings\AuthorizedGroupMapper;
|
||
use OC\User\Session;
|
||
use OCP\App\AppPathNotFoundException;
|
||
use OCP\App\IAppManager;
|
||
use OCP\AppFramework\Controller;
|
||
use OCP\AppFramework\Http\Attribute\AppApiAdminAccessWithoutUser;
|
||
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
|
||
use OCP\AppFramework\Http\Attribute\ExAppRequired;
|
||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||
use OCP\AppFramework\Http\Attribute\PublicPage;
|
||
use OCP\AppFramework\Http\Attribute\StrictCookiesRequired;
|
||
use OCP\AppFramework\Http\Attribute\SubAdminRequired;
|
||
use OCP\AppFramework\Http\JSONResponse;
|
||
use OCP\AppFramework\Http\RedirectResponse;
|
||
use OCP\AppFramework\Http\Response;
|
||
use OCP\AppFramework\Http\TemplateResponse;
|
||
use OCP\AppFramework\Middleware;
|
||
use OCP\AppFramework\OCSController;
|
||
use OCP\Group\ISubAdmin;
|
||
use OCP\IGroupManager;
|
||
use OCP\IL10N;
|
||
use OCP\INavigationManager;
|
||
use OCP\IRequest;
|
||
use OCP\IURLGenerator;
|
||
use OCP\IUserSession;
|
||
use OCP\Security\Ip\IRemoteAddress;
|
||
use OCP\Util;
|
||
use Psr\Log\LoggerInterface;
|
||
use ReflectionMethod;
|
||
|
||
/**
|
||
* Used to do all the authentication and checking stuff for a controller method
|
||
* It reads out the annotations of a controller method and checks which if
|
||
* security things should be checked and also handles errors in case a security
|
||
* check fails
|
||
*/
|
||
class SecurityMiddleware extends Middleware {
|
||
private ?bool $isAdminUser = null;
|
||
private ?bool $isSubAdmin = null;
|
||
|
||
public function __construct(
|
||
private IRequest $request,
|
||
private ControllerMethodReflector $reflector,
|
||
private INavigationManager $navigationManager,
|
||
private IURLGenerator $urlGenerator,
|
||
private LoggerInterface $logger,
|
||
private string $appName,
|
||
private bool $isLoggedIn,
|
||
private IGroupManager $groupManager,
|
||
private ISubAdmin $subAdminManager,
|
||
private IAppManager $appManager,
|
||
private IL10N $l10n,
|
||
private AuthorizedGroupMapper $groupAuthorizationMapper,
|
||
private IUserSession $userSession,
|
||
private IRemoteAddress $remoteAddress,
|
||
) {
|
||
}
|
||
|
||
private function isAdminUser(): bool {
|
||
if ($this->isAdminUser === null) {
|
||
$user = $this->userSession->getUser();
|
||
$this->isAdminUser = $user && $this->groupManager->isAdmin($user->getUID());
|
||
}
|
||
return $this->isAdminUser;
|
||
}
|
||
|
||
private function isSubAdmin(): bool {
|
||
if ($this->isSubAdmin === null) {
|
||
$user = $this->userSession->getUser();
|
||
$this->isSubAdmin = $user && $this->subAdminManager->isSubAdmin($user);
|
||
}
|
||
return $this->isSubAdmin;
|
||
}
|
||
|
||
/**
|
||
* This runs all the security checks before a method call. The
|
||
* security checks are determined by inspecting the controller method
|
||
* annotations
|
||
*
|
||
* @param Controller $controller the controller
|
||
* @param string $methodName the name of the method
|
||
* @throws SecurityException when a security check fails
|
||
*
|
||
* @suppress PhanUndeclaredClassConstant
|
||
*/
|
||
public function beforeController($controller, $methodName) {
|
||
// this will set the current navigation entry of the app, use this only
|
||
// for normal HTML requests and not for AJAX requests
|
||
$this->navigationManager->setActiveEntry($this->appName);
|
||
|
||
if (get_class($controller) === \OCA\Talk\Controller\PageController::class && $methodName === 'showCall') {
|
||
$this->navigationManager->setActiveEntry('spreed');
|
||
}
|
||
|
||
$reflectionMethod = new ReflectionMethod($controller, $methodName);
|
||
|
||
// security checks
|
||
$isPublicPage = $this->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class);
|
||
|
||
if ($this->hasAnnotationOrAttribute($reflectionMethod, 'ExAppRequired', ExAppRequired::class)) {
|
||
if (!$this->userSession instanceof Session || $this->userSession->getSession()->get('app_api') !== true) {
|
||
throw new ExAppRequiredException();
|
||
}
|
||
} elseif (!$isPublicPage) {
|
||
$authorized = false;
|
||
if ($this->hasAnnotationOrAttribute($reflectionMethod, null, AppApiAdminAccessWithoutUser::class)) {
|
||
// this attribute allows ExApp to access admin endpoints only if "userId" is "null"
|
||
if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) {
|
||
$authorized = true;
|
||
}
|
||
}
|
||
|
||
if (!$authorized && !$this->isLoggedIn) {
|
||
throw new NotLoggedInException();
|
||
}
|
||
|
||
if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'AuthorizedAdminSetting', AuthorizedAdminSetting::class)) {
|
||
$authorized = $this->isAdminUser();
|
||
|
||
if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)) {
|
||
$authorized = $this->isSubAdmin();
|
||
}
|
||
|
||
if (!$authorized) {
|
||
$settingClasses = $this->getAuthorizedAdminSettingClasses($reflectionMethod);
|
||
$authorizedClasses = $this->groupAuthorizationMapper->findAllClassesForUser($this->userSession->getUser());
|
||
foreach ($settingClasses as $settingClass) {
|
||
$authorized = in_array($settingClass, $authorizedClasses, true);
|
||
|
||
if ($authorized) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!$authorized) {
|
||
throw new NotAdminException($this->l10n->t('Logged in account must be an admin, a sub admin or gotten special right to access this setting'));
|
||
}
|
||
if (!$this->remoteAddress->allowsAdminActions()) {
|
||
throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn’t allow you to perform admin actions'));
|
||
}
|
||
}
|
||
if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
|
||
&& !$this->isSubAdmin()
|
||
&& !$this->isAdminUser()
|
||
&& !$authorized) {
|
||
throw new NotAdminException($this->l10n->t('Logged in account must be an admin or sub admin'));
|
||
}
|
||
if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
|
||
&& !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class)
|
||
&& !$this->isAdminUser()
|
||
&& !$authorized) {
|
||
throw new NotAdminException($this->l10n->t('Logged in account must be an admin'));
|
||
}
|
||
if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
|
||
&& !$this->remoteAddress->allowsAdminActions()) {
|
||
throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn’t allow you to perform admin actions'));
|
||
}
|
||
if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
|
||
&& !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class)
|
||
&& !$this->remoteAddress->allowsAdminActions()) {
|
||
throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn’t allow you to perform admin actions'));
|
||
}
|
||
|
||
}
|
||
|
||
// Check for strict cookie requirement
|
||
if ($this->hasAnnotationOrAttribute($reflectionMethod, 'StrictCookieRequired', StrictCookiesRequired::class) ||
|
||
!$this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) {
|
||
if (!$this->request->passesStrictCookieCheck()) {
|
||
throw new StrictCookieMissingException();
|
||
}
|
||
}
|
||
// CSRF check - also registers the CSRF token since the session may be closed later
|
||
Util::callRegister();
|
||
if ($this->isInvalidCSRFRequired($reflectionMethod)) {
|
||
/*
|
||
* Only allow the CSRF check to fail on OCS Requests. This kind of
|
||
* hacks around that we have no full token auth in place yet and we
|
||
* do want to offer CSRF checks for web requests.
|
||
*
|
||
* Additionally we allow Bearer authenticated requests to pass on OCS routes.
|
||
* This allows oauth apps (e.g. moodle) to use the OCS endpoints
|
||
*/
|
||
if (!$controller instanceof OCSController || !$this->isValidOCSRequest()) {
|
||
throw new CrossSiteRequestForgeryException();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Checks if app is enabled (also includes a check whether user is allowed to access the resource)
|
||
* The getAppPath() check is here since components such as settings also use the AppFramework and
|
||
* therefore won't pass this check.
|
||
* If page is public, app does not need to be enabled for current user/visitor
|
||
*/
|
||
try {
|
||
$appPath = $this->appManager->getAppPath($this->appName);
|
||
} catch (AppPathNotFoundException $e) {
|
||
$appPath = false;
|
||
}
|
||
|
||
if ($appPath !== false && !$isPublicPage && !$this->appManager->isEnabledForUser($this->appName)) {
|
||
throw new AppNotEnabledException();
|
||
}
|
||
}
|
||
|
||
private function isInvalidCSRFRequired(ReflectionMethod $reflectionMethod): bool {
|
||
if ($this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) {
|
||
return false;
|
||
}
|
||
|
||
return !$this->request->passesCSRFCheck();
|
||
}
|
||
|
||
private function isValidOCSRequest(): bool {
|
||
return $this->request->getHeader('OCS-APIREQUEST') === 'true'
|
||
|| str_starts_with($this->request->getHeader('Authorization'), 'Bearer ');
|
||
}
|
||
|
||
/**
|
||
* @template T
|
||
*
|
||
* @param ReflectionMethod $reflectionMethod
|
||
* @param ?string $annotationName
|
||
* @param class-string<T> $attributeClass
|
||
* @return boolean
|
||
*/
|
||
protected function hasAnnotationOrAttribute(ReflectionMethod $reflectionMethod, ?string $annotationName, string $attributeClass): bool {
|
||
if (!empty($reflectionMethod->getAttributes($attributeClass))) {
|
||
return true;
|
||
}
|
||
|
||
if ($annotationName && $this->reflector->hasAnnotation($annotationName)) {
|
||
$this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . $annotationName . ' annotation and should use the #[' . $attributeClass . '] attribute instead');
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param ReflectionMethod $reflectionMethod
|
||
* @return string[]
|
||
*/
|
||
protected function getAuthorizedAdminSettingClasses(ReflectionMethod $reflectionMethod): array {
|
||
$classes = [];
|
||
if ($this->reflector->hasAnnotation('AuthorizedAdminSetting')) {
|
||
$classes = explode(';', $this->reflector->getAnnotationParameter('AuthorizedAdminSetting', 'settings'));
|
||
}
|
||
|
||
$attributes = $reflectionMethod->getAttributes(AuthorizedAdminSetting::class);
|
||
if (!empty($attributes)) {
|
||
foreach ($attributes as $attribute) {
|
||
/** @var AuthorizedAdminSetting $setting */
|
||
$setting = $attribute->newInstance();
|
||
$classes[] = $setting->getSettings();
|
||
}
|
||
}
|
||
|
||
return $classes;
|
||
}
|
||
|
||
/**
|
||
* If an SecurityException is being caught, ajax requests return a JSON error
|
||
* response and non ajax requests redirect to the index
|
||
*
|
||
* @param Controller $controller the controller that is being called
|
||
* @param string $methodName the name of the method that will be called on
|
||
* the controller
|
||
* @param \Exception $exception the thrown exception
|
||
* @return Response a Response object or null in case that the exception could not be handled
|
||
* @throws \Exception the passed in exception if it can't handle it
|
||
*/
|
||
public function afterException($controller, $methodName, \Exception $exception): Response {
|
||
if ($exception instanceof SecurityException) {
|
||
if ($exception instanceof StrictCookieMissingException) {
|
||
return new RedirectResponse(\OC::$WEBROOT . '/');
|
||
}
|
||
if (stripos($this->request->getHeader('Accept'), 'html') === false) {
|
||
$response = new JSONResponse(
|
||
['message' => $exception->getMessage()],
|
||
$exception->getCode()
|
||
);
|
||
} else {
|
||
if ($exception instanceof NotLoggedInException) {
|
||
$params = [];
|
||
if (isset($this->request->server['REQUEST_URI'])) {
|
||
$params['redirect_url'] = $this->request->server['REQUEST_URI'];
|
||
}
|
||
$usernamePrefill = $this->request->getParam('user', '');
|
||
if ($usernamePrefill !== '') {
|
||
$params['user'] = $usernamePrefill;
|
||
}
|
||
if ($this->request->getParam('direct')) {
|
||
$params['direct'] = 1;
|
||
}
|
||
$url = $this->urlGenerator->linkToRoute('core.login.showLoginForm', $params);
|
||
$response = new RedirectResponse($url);
|
||
} else {
|
||
$response = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest');
|
||
$response->setStatus($exception->getCode());
|
||
}
|
||
}
|
||
|
||
$this->logger->debug($exception->getMessage(), [
|
||
'exception' => $exception,
|
||
]);
|
||
return $response;
|
||
}
|
||
|
||
throw $exception;
|
||
}
|
||
}
|