SecurityMiddleware.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  6. * SPDX-License-Identifier: AGPL-3.0-only
  7. */
  8. namespace OC\AppFramework\Middleware\Security;
  9. use OC\AppFramework\Middleware\Security\Exceptions\AdminIpNotAllowedException;
  10. use OC\AppFramework\Middleware\Security\Exceptions\AppNotEnabledException;
  11. use OC\AppFramework\Middleware\Security\Exceptions\CrossSiteRequestForgeryException;
  12. use OC\AppFramework\Middleware\Security\Exceptions\ExAppRequiredException;
  13. use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
  14. use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException;
  15. use OC\AppFramework\Middleware\Security\Exceptions\SecurityException;
  16. use OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException;
  17. use OC\AppFramework\Utility\ControllerMethodReflector;
  18. use OC\Settings\AuthorizedGroupMapper;
  19. use OC\User\Session;
  20. use OCP\App\AppPathNotFoundException;
  21. use OCP\App\IAppManager;
  22. use OCP\AppFramework\Controller;
  23. use OCP\AppFramework\Http\Attribute\AppApiAdminAccessWithoutUser;
  24. use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
  25. use OCP\AppFramework\Http\Attribute\ExAppRequired;
  26. use OCP\AppFramework\Http\Attribute\NoAdminRequired;
  27. use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
  28. use OCP\AppFramework\Http\Attribute\PublicPage;
  29. use OCP\AppFramework\Http\Attribute\StrictCookiesRequired;
  30. use OCP\AppFramework\Http\Attribute\SubAdminRequired;
  31. use OCP\AppFramework\Http\JSONResponse;
  32. use OCP\AppFramework\Http\RedirectResponse;
  33. use OCP\AppFramework\Http\Response;
  34. use OCP\AppFramework\Http\TemplateResponse;
  35. use OCP\AppFramework\Middleware;
  36. use OCP\AppFramework\OCSController;
  37. use OCP\Group\ISubAdmin;
  38. use OCP\IGroupManager;
  39. use OCP\IL10N;
  40. use OCP\INavigationManager;
  41. use OCP\IRequest;
  42. use OCP\IURLGenerator;
  43. use OCP\IUserSession;
  44. use OCP\Security\Ip\IRemoteAddress;
  45. use OCP\Util;
  46. use Psr\Log\LoggerInterface;
  47. use ReflectionMethod;
  48. /**
  49. * Used to do all the authentication and checking stuff for a controller method
  50. * It reads out the annotations of a controller method and checks which if
  51. * security things should be checked and also handles errors in case a security
  52. * check fails
  53. */
  54. class SecurityMiddleware extends Middleware {
  55. private ?bool $isAdminUser = null;
  56. private ?bool $isSubAdmin = null;
  57. public function __construct(
  58. private IRequest $request,
  59. private ControllerMethodReflector $reflector,
  60. private INavigationManager $navigationManager,
  61. private IURLGenerator $urlGenerator,
  62. private LoggerInterface $logger,
  63. private string $appName,
  64. private bool $isLoggedIn,
  65. private IGroupManager $groupManager,
  66. private ISubAdmin $subAdminManager,
  67. private IAppManager $appManager,
  68. private IL10N $l10n,
  69. private AuthorizedGroupMapper $groupAuthorizationMapper,
  70. private IUserSession $userSession,
  71. private IRemoteAddress $remoteAddress,
  72. ) {
  73. }
  74. private function isAdminUser(): bool {
  75. if ($this->isAdminUser === null) {
  76. $user = $this->userSession->getUser();
  77. $this->isAdminUser = $user && $this->groupManager->isAdmin($user->getUID());
  78. }
  79. return $this->isAdminUser;
  80. }
  81. private function isSubAdmin(): bool {
  82. if ($this->isSubAdmin === null) {
  83. $user = $this->userSession->getUser();
  84. $this->isSubAdmin = $user && $this->subAdminManager->isSubAdmin($user);
  85. }
  86. return $this->isSubAdmin;
  87. }
  88. /**
  89. * This runs all the security checks before a method call. The
  90. * security checks are determined by inspecting the controller method
  91. * annotations
  92. *
  93. * @param Controller $controller the controller
  94. * @param string $methodName the name of the method
  95. * @throws SecurityException when a security check fails
  96. *
  97. * @suppress PhanUndeclaredClassConstant
  98. */
  99. public function beforeController($controller, $methodName) {
  100. // this will set the current navigation entry of the app, use this only
  101. // for normal HTML requests and not for AJAX requests
  102. $this->navigationManager->setActiveEntry($this->appName);
  103. if (get_class($controller) === \OCA\Talk\Controller\PageController::class && $methodName === 'showCall') {
  104. $this->navigationManager->setActiveEntry('spreed');
  105. }
  106. $reflectionMethod = new ReflectionMethod($controller, $methodName);
  107. // security checks
  108. $isPublicPage = $this->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class);
  109. if ($this->hasAnnotationOrAttribute($reflectionMethod, 'ExAppRequired', ExAppRequired::class)) {
  110. if (!$this->userSession instanceof Session || $this->userSession->getSession()->get('app_api') !== true) {
  111. throw new ExAppRequiredException();
  112. }
  113. } elseif (!$isPublicPage) {
  114. $authorized = false;
  115. if ($this->hasAnnotationOrAttribute($reflectionMethod, null, AppApiAdminAccessWithoutUser::class)) {
  116. // this attribute allows ExApp to access admin endpoints only if "userId" is "null"
  117. if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) {
  118. $authorized = true;
  119. }
  120. }
  121. if (!$authorized && !$this->isLoggedIn) {
  122. throw new NotLoggedInException();
  123. }
  124. if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'AuthorizedAdminSetting', AuthorizedAdminSetting::class)) {
  125. $authorized = $this->isAdminUser();
  126. if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)) {
  127. $authorized = $this->isSubAdmin();
  128. }
  129. if (!$authorized) {
  130. $settingClasses = $this->getAuthorizedAdminSettingClasses($reflectionMethod);
  131. $authorizedClasses = $this->groupAuthorizationMapper->findAllClassesForUser($this->userSession->getUser());
  132. foreach ($settingClasses as $settingClass) {
  133. $authorized = in_array($settingClass, $authorizedClasses, true);
  134. if ($authorized) {
  135. break;
  136. }
  137. }
  138. }
  139. if (!$authorized) {
  140. throw new NotAdminException($this->l10n->t('Logged in account must be an admin, a sub admin or gotten special right to access this setting'));
  141. }
  142. if (!$this->remoteAddress->allowsAdminActions()) {
  143. throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn’t allow you to perform admin actions'));
  144. }
  145. }
  146. if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
  147. && !$this->isSubAdmin()
  148. && !$this->isAdminUser()
  149. && !$authorized) {
  150. throw new NotAdminException($this->l10n->t('Logged in account must be an admin or sub admin'));
  151. }
  152. if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
  153. && !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class)
  154. && !$this->isAdminUser()
  155. && !$authorized) {
  156. throw new NotAdminException($this->l10n->t('Logged in account must be an admin'));
  157. }
  158. if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
  159. && !$this->remoteAddress->allowsAdminActions()) {
  160. throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn’t allow you to perform admin actions'));
  161. }
  162. if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)
  163. && !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class)
  164. && !$this->remoteAddress->allowsAdminActions()) {
  165. throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn’t allow you to perform admin actions'));
  166. }
  167. }
  168. // Check for strict cookie requirement
  169. if ($this->hasAnnotationOrAttribute($reflectionMethod, 'StrictCookieRequired', StrictCookiesRequired::class) ||
  170. !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) {
  171. if (!$this->request->passesStrictCookieCheck()) {
  172. throw new StrictCookieMissingException();
  173. }
  174. }
  175. // CSRF check - also registers the CSRF token since the session may be closed later
  176. Util::callRegister();
  177. if ($this->isInvalidCSRFRequired($reflectionMethod)) {
  178. /*
  179. * Only allow the CSRF check to fail on OCS Requests. This kind of
  180. * hacks around that we have no full token auth in place yet and we
  181. * do want to offer CSRF checks for web requests.
  182. *
  183. * Additionally we allow Bearer authenticated requests to pass on OCS routes.
  184. * This allows oauth apps (e.g. moodle) to use the OCS endpoints
  185. */
  186. if (!$controller instanceof OCSController || !$this->isValidOCSRequest()) {
  187. throw new CrossSiteRequestForgeryException();
  188. }
  189. }
  190. /**
  191. * Checks if app is enabled (also includes a check whether user is allowed to access the resource)
  192. * The getAppPath() check is here since components such as settings also use the AppFramework and
  193. * therefore won't pass this check.
  194. * If page is public, app does not need to be enabled for current user/visitor
  195. */
  196. try {
  197. $appPath = $this->appManager->getAppPath($this->appName);
  198. } catch (AppPathNotFoundException $e) {
  199. $appPath = false;
  200. }
  201. if ($appPath !== false && !$isPublicPage && !$this->appManager->isEnabledForUser($this->appName)) {
  202. throw new AppNotEnabledException();
  203. }
  204. }
  205. private function isInvalidCSRFRequired(ReflectionMethod $reflectionMethod): bool {
  206. if ($this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) {
  207. return false;
  208. }
  209. return !$this->request->passesCSRFCheck();
  210. }
  211. private function isValidOCSRequest(): bool {
  212. return $this->request->getHeader('OCS-APIREQUEST') === 'true'
  213. || str_starts_with($this->request->getHeader('Authorization'), 'Bearer ');
  214. }
  215. /**
  216. * @template T
  217. *
  218. * @param ReflectionMethod $reflectionMethod
  219. * @param ?string $annotationName
  220. * @param class-string<T> $attributeClass
  221. * @return boolean
  222. */
  223. protected function hasAnnotationOrAttribute(ReflectionMethod $reflectionMethod, ?string $annotationName, string $attributeClass): bool {
  224. if (!empty($reflectionMethod->getAttributes($attributeClass))) {
  225. return true;
  226. }
  227. if ($annotationName && $this->reflector->hasAnnotation($annotationName)) {
  228. $this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . $annotationName . ' annotation and should use the #[' . $attributeClass . '] attribute instead');
  229. return true;
  230. }
  231. return false;
  232. }
  233. /**
  234. * @param ReflectionMethod $reflectionMethod
  235. * @return string[]
  236. */
  237. protected function getAuthorizedAdminSettingClasses(ReflectionMethod $reflectionMethod): array {
  238. $classes = [];
  239. if ($this->reflector->hasAnnotation('AuthorizedAdminSetting')) {
  240. $classes = explode(';', $this->reflector->getAnnotationParameter('AuthorizedAdminSetting', 'settings'));
  241. }
  242. $attributes = $reflectionMethod->getAttributes(AuthorizedAdminSetting::class);
  243. if (!empty($attributes)) {
  244. foreach ($attributes as $attribute) {
  245. /** @var AuthorizedAdminSetting $setting */
  246. $setting = $attribute->newInstance();
  247. $classes[] = $setting->getSettings();
  248. }
  249. }
  250. return $classes;
  251. }
  252. /**
  253. * If an SecurityException is being caught, ajax requests return a JSON error
  254. * response and non ajax requests redirect to the index
  255. *
  256. * @param Controller $controller the controller that is being called
  257. * @param string $methodName the name of the method that will be called on
  258. * the controller
  259. * @param \Exception $exception the thrown exception
  260. * @return Response a Response object or null in case that the exception could not be handled
  261. * @throws \Exception the passed in exception if it can't handle it
  262. */
  263. public function afterException($controller, $methodName, \Exception $exception): Response {
  264. if ($exception instanceof SecurityException) {
  265. if ($exception instanceof StrictCookieMissingException) {
  266. return new RedirectResponse(\OC::$WEBROOT . '/');
  267. }
  268. if (stripos($this->request->getHeader('Accept'), 'html') === false) {
  269. $response = new JSONResponse(
  270. ['message' => $exception->getMessage()],
  271. $exception->getCode()
  272. );
  273. } else {
  274. if ($exception instanceof NotLoggedInException) {
  275. $params = [];
  276. if (isset($this->request->server['REQUEST_URI'])) {
  277. $params['redirect_url'] = $this->request->server['REQUEST_URI'];
  278. }
  279. $usernamePrefill = $this->request->getParam('user', '');
  280. if ($usernamePrefill !== '') {
  281. $params['user'] = $usernamePrefill;
  282. }
  283. if ($this->request->getParam('direct')) {
  284. $params['direct'] = 1;
  285. }
  286. $url = $this->urlGenerator->linkToRoute('core.login.showLoginForm', $params);
  287. $response = new RedirectResponse($url);
  288. } else {
  289. $response = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest');
  290. $response->setStatus($exception->getCode());
  291. }
  292. }
  293. $this->logger->debug($exception->getMessage(), [
  294. 'exception' => $exception,
  295. ]);
  296. return $response;
  297. }
  298. throw $exception;
  299. }
  300. }