1<?php
2/**
3 * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
4 *
5 * @author Bjoern Schiessle <bjoern@schiessle.org>
6 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
7 * @author Daniel Kesselberg <mail@danielkesselberg.de>
8 * @author Joas Schilling <coding@schilljs.com>
9 * @author Lukas Reschke <lukas@statuscode.ch>
10 * @author Mario Danic <mario@lovelyhq.com>
11 * @author Morris Jobke <hey@morrisjobke.de>
12 * @author Roeland Jago Douma <roeland@famdouma.nl>
13 * @author RussellAult <RussellAult@users.noreply.github.com>
14 * @author Sergej Nikolaev <kinolaev@gmail.com>
15 *
16 * @license GNU AGPL version 3 or any later version
17 *
18 * This program is free software: you can redistribute it and/or modify
19 * it under the terms of the GNU Affero General Public License as
20 * published by the Free Software Foundation, either version 3 of the
21 * License, or (at your option) any later version.
22 *
23 * This program is distributed in the hope that it will be useful,
24 * but WITHOUT ANY WARRANTY; without even the implied warranty of
25 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
26 * GNU Affero General Public License for more details.
27 *
28 * You should have received a copy of the GNU Affero General Public License
29 * along with this program. If not, see <http://www.gnu.org/licenses/>.
30 *
31 */
32namespace OC\Core\Controller;
33
34use OC\Authentication\Events\AppPasswordCreatedEvent;
35use OC\Authentication\Exceptions\InvalidTokenException;
36use OC\Authentication\Exceptions\PasswordlessTokenException;
37use OC\Authentication\Token\IProvider;
38use OC\Authentication\Token\IToken;
39use OCA\OAuth2\Db\AccessToken;
40use OCA\OAuth2\Db\AccessTokenMapper;
41use OCA\OAuth2\Db\ClientMapper;
42use OCP\AppFramework\Controller;
43use OCP\AppFramework\Http;
44use OCP\AppFramework\Http\Response;
45use OCP\AppFramework\Http\StandaloneTemplateResponse;
46use OCP\Defaults;
47use OCP\EventDispatcher\IEventDispatcher;
48use OCP\IL10N;
49use OCP\IRequest;
50use OCP\ISession;
51use OCP\IURLGenerator;
52use OCP\IUserSession;
53use OCP\Security\ICrypto;
54use OCP\Security\ISecureRandom;
55use OCP\Session\Exceptions\SessionNotAvailableException;
56
57class ClientFlowLoginController extends Controller {
58	/** @var IUserSession */
59	private $userSession;
60	/** @var IL10N */
61	private $l10n;
62	/** @var Defaults */
63	private $defaults;
64	/** @var ISession */
65	private $session;
66	/** @var IProvider */
67	private $tokenProvider;
68	/** @var ISecureRandom */
69	private $random;
70	/** @var IURLGenerator */
71	private $urlGenerator;
72	/** @var ClientMapper */
73	private $clientMapper;
74	/** @var AccessTokenMapper */
75	private $accessTokenMapper;
76	/** @var ICrypto */
77	private $crypto;
78	/** @var IEventDispatcher */
79	private $eventDispatcher;
80
81	public const STATE_NAME = 'client.flow.state.token';
82
83	/**
84	 * @param string $appName
85	 * @param IRequest $request
86	 * @param IUserSession $userSession
87	 * @param IL10N $l10n
88	 * @param Defaults $defaults
89	 * @param ISession $session
90	 * @param IProvider $tokenProvider
91	 * @param ISecureRandom $random
92	 * @param IURLGenerator $urlGenerator
93	 * @param ClientMapper $clientMapper
94	 * @param AccessTokenMapper $accessTokenMapper
95	 * @param ICrypto $crypto
96	 * @param IEventDispatcher $eventDispatcher
97	 */
98	public function __construct($appName,
99								IRequest $request,
100								IUserSession $userSession,
101								IL10N $l10n,
102								Defaults $defaults,
103								ISession $session,
104								IProvider $tokenProvider,
105								ISecureRandom $random,
106								IURLGenerator $urlGenerator,
107								ClientMapper $clientMapper,
108								AccessTokenMapper $accessTokenMapper,
109								ICrypto $crypto,
110								IEventDispatcher $eventDispatcher) {
111		parent::__construct($appName, $request);
112		$this->userSession = $userSession;
113		$this->l10n = $l10n;
114		$this->defaults = $defaults;
115		$this->session = $session;
116		$this->tokenProvider = $tokenProvider;
117		$this->random = $random;
118		$this->urlGenerator = $urlGenerator;
119		$this->clientMapper = $clientMapper;
120		$this->accessTokenMapper = $accessTokenMapper;
121		$this->crypto = $crypto;
122		$this->eventDispatcher = $eventDispatcher;
123	}
124
125	/**
126	 * @return string
127	 */
128	private function getClientName() {
129		$userAgent = $this->request->getHeader('USER_AGENT');
130		return $userAgent !== '' ? $userAgent : 'unknown';
131	}
132
133	/**
134	 * @param string $stateToken
135	 * @return bool
136	 */
137	private function isValidToken($stateToken) {
138		$currentToken = $this->session->get(self::STATE_NAME);
139		if (!is_string($stateToken) || !is_string($currentToken)) {
140			return false;
141		}
142		return hash_equals($currentToken, $stateToken);
143	}
144
145	/**
146	 * @return StandaloneTemplateResponse
147	 */
148	private function stateTokenForbiddenResponse() {
149		$response = new StandaloneTemplateResponse(
150			$this->appName,
151			'403',
152			[
153				'message' => $this->l10n->t('State token does not match'),
154			],
155			'guest'
156		);
157		$response->setStatus(Http::STATUS_FORBIDDEN);
158		return $response;
159	}
160
161	/**
162	 * @PublicPage
163	 * @NoCSRFRequired
164	 * @UseSession
165	 *
166	 * @param string $clientIdentifier
167	 *
168	 * @return StandaloneTemplateResponse
169	 */
170	public function showAuthPickerPage($clientIdentifier = '') {
171		$clientName = $this->getClientName();
172		$client = null;
173		if ($clientIdentifier !== '') {
174			$client = $this->clientMapper->getByIdentifier($clientIdentifier);
175			$clientName = $client->getName();
176		}
177
178		// No valid clientIdentifier given and no valid API Request (APIRequest header not set)
179		$clientRequest = $this->request->getHeader('OCS-APIREQUEST');
180		if ($clientRequest !== 'true' && $client === null) {
181			return new StandaloneTemplateResponse(
182				$this->appName,
183				'error',
184				[
185					'errors' =>
186					[
187						[
188							'error' => 'Access Forbidden',
189							'hint' => 'Invalid request',
190						],
191					],
192				],
193				'guest'
194			);
195		}
196
197		$stateToken = $this->random->generate(
198			64,
199			ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS
200		);
201		$this->session->set(self::STATE_NAME, $stateToken);
202
203		$csp = new Http\ContentSecurityPolicy();
204		if ($client) {
205			$csp->addAllowedFormActionDomain($client->getRedirectUri());
206		} else {
207			$csp->addAllowedFormActionDomain('nc://*');
208		}
209
210		$response = new StandaloneTemplateResponse(
211			$this->appName,
212			'loginflow/authpicker',
213			[
214				'client' => $clientName,
215				'clientIdentifier' => $clientIdentifier,
216				'instanceName' => $this->defaults->getName(),
217				'urlGenerator' => $this->urlGenerator,
218				'stateToken' => $stateToken,
219				'serverHost' => $this->getServerPath(),
220				'oauthState' => $this->session->get('oauth.state'),
221			],
222			'guest'
223		);
224
225		$response->setContentSecurityPolicy($csp);
226		return $response;
227	}
228
229	/**
230	 * @NoAdminRequired
231	 * @NoCSRFRequired
232	 * @NoSameSiteCookieRequired
233	 * @UseSession
234	 *
235	 * @param string $stateToken
236	 * @param string $clientIdentifier
237	 * @return StandaloneTemplateResponse
238	 */
239	public function grantPage($stateToken = '',
240								 $clientIdentifier = '') {
241		if (!$this->isValidToken($stateToken)) {
242			return $this->stateTokenForbiddenResponse();
243		}
244
245		$clientName = $this->getClientName();
246		$client = null;
247		if ($clientIdentifier !== '') {
248			$client = $this->clientMapper->getByIdentifier($clientIdentifier);
249			$clientName = $client->getName();
250		}
251
252		$csp = new Http\ContentSecurityPolicy();
253		if ($client) {
254			$csp->addAllowedFormActionDomain($client->getRedirectUri());
255		} else {
256			$csp->addAllowedFormActionDomain('nc://*');
257		}
258
259		$response = new StandaloneTemplateResponse(
260			$this->appName,
261			'loginflow/grant',
262			[
263				'client' => $clientName,
264				'clientIdentifier' => $clientIdentifier,
265				'instanceName' => $this->defaults->getName(),
266				'urlGenerator' => $this->urlGenerator,
267				'stateToken' => $stateToken,
268				'serverHost' => $this->getServerPath(),
269				'oauthState' => $this->session->get('oauth.state'),
270			],
271			'guest'
272		);
273
274		$response->setContentSecurityPolicy($csp);
275		return $response;
276	}
277
278	/**
279	 * @NoAdminRequired
280	 * @UseSession
281	 *
282	 * @param string $stateToken
283	 * @param string $clientIdentifier
284	 * @return Http\RedirectResponse|Response
285	 */
286	public function generateAppPassword($stateToken,
287										$clientIdentifier = '') {
288		if (!$this->isValidToken($stateToken)) {
289			$this->session->remove(self::STATE_NAME);
290			return $this->stateTokenForbiddenResponse();
291		}
292
293		$this->session->remove(self::STATE_NAME);
294
295		try {
296			$sessionId = $this->session->getId();
297		} catch (SessionNotAvailableException $ex) {
298			$response = new Response();
299			$response->setStatus(Http::STATUS_FORBIDDEN);
300			return $response;
301		}
302
303		try {
304			$sessionToken = $this->tokenProvider->getToken($sessionId);
305			$loginName = $sessionToken->getLoginName();
306			try {
307				$password = $this->tokenProvider->getPassword($sessionToken, $sessionId);
308			} catch (PasswordlessTokenException $ex) {
309				$password = null;
310			}
311		} catch (InvalidTokenException $ex) {
312			$response = new Response();
313			$response->setStatus(Http::STATUS_FORBIDDEN);
314			return $response;
315		}
316
317		$clientName = $this->getClientName();
318		$client = false;
319		if ($clientIdentifier !== '') {
320			$client = $this->clientMapper->getByIdentifier($clientIdentifier);
321			$clientName = $client->getName();
322		}
323
324		$token = $this->random->generate(72, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS);
325		$uid = $this->userSession->getUser()->getUID();
326		$generatedToken = $this->tokenProvider->generateToken(
327			$token,
328			$uid,
329			$loginName,
330			$password,
331			$clientName,
332			IToken::PERMANENT_TOKEN,
333			IToken::DO_NOT_REMEMBER
334		);
335
336		if ($client) {
337			$code = $this->random->generate(128, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS);
338			$accessToken = new AccessToken();
339			$accessToken->setClientId($client->getId());
340			$accessToken->setEncryptedToken($this->crypto->encrypt($token, $code));
341			$accessToken->setHashedCode(hash('sha512', $code));
342			$accessToken->setTokenId($generatedToken->getId());
343			$this->accessTokenMapper->insert($accessToken);
344
345			$redirectUri = $client->getRedirectUri();
346
347			if (parse_url($redirectUri, PHP_URL_QUERY)) {
348				$redirectUri .= '&';
349			} else {
350				$redirectUri .= '?';
351			}
352
353			$redirectUri .= sprintf(
354				'state=%s&code=%s',
355				urlencode($this->session->get('oauth.state')),
356				urlencode($code)
357			);
358			$this->session->remove('oauth.state');
359		} else {
360			$redirectUri = 'nc://login/server:' . $this->getServerPath() . '&user:' . urlencode($loginName) . '&password:' . urlencode($token);
361
362			// Clear the token from the login here
363			$this->tokenProvider->invalidateToken($sessionId);
364		}
365
366		$this->eventDispatcher->dispatchTyped(
367			new AppPasswordCreatedEvent($generatedToken)
368		);
369
370		return new Http\RedirectResponse($redirectUri);
371	}
372
373	/**
374	 * @PublicPage
375	 */
376	public function apptokenRedirect(string $stateToken, string $user, string $password) {
377		if (!$this->isValidToken($stateToken)) {
378			return $this->stateTokenForbiddenResponse();
379		}
380
381		try {
382			$token = $this->tokenProvider->getToken($password);
383			if ($token->getLoginName() !== $user) {
384				throw new InvalidTokenException('login name does not match');
385			}
386		} catch (InvalidTokenException $e) {
387			$response = new StandaloneTemplateResponse(
388				$this->appName,
389				'403',
390				[
391					'message' => $this->l10n->t('Invalid app password'),
392				],
393				'guest'
394			);
395			$response->setStatus(Http::STATUS_FORBIDDEN);
396			return $response;
397		}
398
399		$redirectUri = 'nc://login/server:' . $this->getServerPath() . '&user:' . urlencode($user) . '&password:' . urlencode($password);
400		return new Http\RedirectResponse($redirectUri);
401	}
402
403	private function getServerPath(): string {
404		$serverPostfix = '';
405
406		if (strpos($this->request->getRequestUri(), '/index.php') !== false) {
407			$serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/index.php'));
408		} elseif (strpos($this->request->getRequestUri(), '/login/flow') !== false) {
409			$serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/login/flow'));
410		}
411
412		$protocol = $this->request->getServerProtocol();
413
414		if ($protocol !== "https") {
415			$xForwardedProto = $this->request->getHeader('X-Forwarded-Proto');
416			$xForwardedSSL = $this->request->getHeader('X-Forwarded-Ssl');
417			if ($xForwardedProto === 'https' || $xForwardedSSL === 'on') {
418				$protocol = 'https';
419			}
420		}
421
422		return $protocol . "://" . $this->request->getServerHost() . $serverPostfix;
423	}
424}
425