@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
4 *
5 * @task use Using Sessions
6 * @task new Creating Sessions
7 * @task hisec High Security
8 * @task partial Partial Sessions
9 * @task onetime One Time Login URIs
10 * @task cache User Cache
11 */
12final class PhabricatorAuthSessionEngine extends Phobject {
13
14 /**
15 * Session issued to normal users after they login through a standard channel.
16 * Associates the client with a standard user identity.
17 */
18 const KIND_USER = 'U';
19
20
21 /**
22 * Session issued to users who login with some sort of credentials but do not
23 * have full accounts. These are sometimes called "grey users".
24 *
25 * TODO: We do not currently issue these sessions, see T4310.
26 */
27 const KIND_EXTERNAL = 'X';
28
29
30 /**
31 * Session issued to logged-out users which has no real identity information.
32 * Its purpose is to protect logged-out users from CSRF.
33 */
34 const KIND_ANONYMOUS = 'A';
35
36
37 /**
38 * Session kind isn't known.
39 */
40 const KIND_UNKNOWN = '?';
41
42
43 const ONETIME_RECOVER = 'recover';
44 const ONETIME_RESET = 'reset';
45 const ONETIME_WELCOME = 'welcome';
46 const ONETIME_USERNAME = 'rename';
47
48
49 private $workflowKey;
50 private $request;
51
52 public function setWorkflowKey($workflow_key) {
53 $this->workflowKey = $workflow_key;
54 return $this;
55 }
56
57 public function getWorkflowKey() {
58
59 // TODO: A workflow key should become required in order to issue an MFA
60 // challenge, but allow things to keep working for now until we can update
61 // callsites.
62 if ($this->workflowKey === null) {
63 return 'legacy';
64 }
65
66 return $this->workflowKey;
67 }
68
69 public function getRequest() {
70 return $this->request;
71 }
72
73
74 /**
75 * Get the session kind (e.g., anonymous, user, external account) from a
76 * session token. Returns a `KIND_` constant.
77 *
78 * @param string $session_token Session token.
79 * @return string Session kind constant.
80 */
81 public static function getSessionKindFromToken($session_token) {
82 if (strpos($session_token, '/') === false) {
83 // Old-style session, these are all user sessions.
84 return self::KIND_USER;
85 }
86
87 list($kind, $key) = explode('/', $session_token, 2);
88
89 switch ($kind) {
90 case self::KIND_ANONYMOUS:
91 case self::KIND_USER:
92 case self::KIND_EXTERNAL:
93 return $kind;
94 default:
95 return self::KIND_UNKNOWN;
96 }
97 }
98
99
100 /**
101 * Load the user identity associated with a session of a given type,
102 * identified by token.
103 *
104 * When the user presents a session token to an API, this method verifies
105 * it is of the correct type and loads the corresponding identity if the
106 * session exists and is valid.
107 *
108 * NOTE: `$session_type` is the type of session that is required by the
109 * loading context. This prevents use of a Conduit sesssion as a Web
110 * session, for example.
111 *
112 * @param string $session_type Constant of the type of session to load.
113 * @param string $session_token The session token.
114 * @return PhabricatorUser|null
115 * @task use
116 */
117 public function loadUserForSession($session_type, $session_token) {
118 $session_kind = self::getSessionKindFromToken($session_token);
119 switch ($session_kind) {
120 case self::KIND_ANONYMOUS:
121 // Don't bother trying to load a user for an anonymous session, since
122 // neither the session nor the user exist.
123 return null;
124 case self::KIND_UNKNOWN:
125 // If we don't know what kind of session this is, don't go looking for
126 // it.
127 return null;
128 case self::KIND_USER:
129 break;
130 case self::KIND_EXTERNAL:
131 // TODO: Implement these (T4310).
132 return null;
133 }
134
135 $session_table = new PhabricatorAuthSession();
136 $user_table = new PhabricatorUser();
137 $conn = $session_table->establishConnection('r');
138
139 $session_key = PhabricatorAuthSession::newSessionDigest(
140 new PhutilOpaqueEnvelope($session_token));
141
142 $cache_parts = $this->getUserCacheQueryParts($conn);
143 list($cache_selects, $cache_joins, $cache_map, $types_map) = $cache_parts;
144
145 $info = queryfx_one(
146 $conn,
147 'SELECT
148 s.id AS s_id,
149 s.phid AS s_phid,
150 s.sessionExpires AS s_sessionExpires,
151 s.sessionStart AS s_sessionStart,
152 s.highSecurityUntil AS s_highSecurityUntil,
153 s.isPartial AS s_isPartial,
154 s.signedLegalpadDocuments as s_signedLegalpadDocuments,
155 u.*
156 %Q
157 FROM %R u JOIN %R s ON u.phid = s.userPHID
158 AND s.type = %s AND s.sessionKey = %P %Q',
159 $cache_selects,
160 $user_table,
161 $session_table,
162 $session_type,
163 new PhutilOpaqueEnvelope($session_key),
164 $cache_joins);
165
166 if (!$info) {
167 return null;
168 }
169
170 $session_dict = array(
171 'userPHID' => $info['phid'],
172 'sessionKey' => $session_key,
173 'type' => $session_type,
174 );
175
176 $cache_raw = array_fill_keys($cache_map, null);
177 foreach ($info as $key => $value) {
178 if (strncmp($key, 's_', 2) === 0) {
179 unset($info[$key]);
180 $session_dict[substr($key, 2)] = $value;
181 continue;
182 }
183
184 if (isset($cache_map[$key])) {
185 unset($info[$key]);
186 $cache_raw[$cache_map[$key]] = $value;
187 continue;
188 }
189 }
190
191 $user = $user_table->loadFromArray($info);
192
193 $cache_raw = $this->filterRawCacheData($user, $types_map, $cache_raw);
194 $user->attachRawCacheData($cache_raw);
195
196 switch ($session_type) {
197 case PhabricatorAuthSession::TYPE_WEB:
198 // Explicitly prevent bots and mailing lists from establishing web
199 // sessions. It's normally impossible to attach authentication to these
200 // accounts, and likewise impossible to generate sessions, but it's
201 // technically possible that a session could exist in the database. If
202 // one does somehow, refuse to load it.
203 if (!$user->canEstablishWebSessions()) {
204 return null;
205 }
206 break;
207 }
208
209 $session = id(new PhabricatorAuthSession())->loadFromArray($session_dict);
210
211 $this->extendSession($session);
212
213 $user->attachSession($session);
214 return $user;
215 }
216
217
218 /**
219 * Issue a new session key for a given identity. Phabricator supports
220 * different types of sessions (like "web" and "conduit") and each session
221 * type may have multiple concurrent sessions (this allows a user to be
222 * logged in on multiple browsers at the same time, for instance).
223 *
224 * Note that this method is transport-agnostic and does not set cookies or
225 * issue other types of tokens, it ONLY generates a new session key.
226 *
227 * You can configure the maximum number of concurrent sessions for various
228 * session types in the Phabricator configuration.
229 *
230 * @param string $session_type Session type constant (see
231 * @{class:PhabricatorAuthSession}).
232 * @param string|null $identity_phid Identity to establish a session for,
233 * usually a user PHID. With `null`, generates an
234 * anonymous session.
235 * @param bool $partial True to issue a partial session.
236 * @return string Newly generated session key.
237 */
238 public function establishSession($session_type, $identity_phid, $partial) {
239 // Consume entropy to generate a new session key, forestalling the eventual
240 // heat death of the universe.
241 $session_key = Filesystem::readRandomCharacters(40);
242
243 if ($identity_phid === null) {
244 return self::KIND_ANONYMOUS.'/'.$session_key;
245 }
246
247 $session_table = new PhabricatorAuthSession();
248 $conn_w = $session_table->establishConnection('w');
249
250 // This has a side effect of validating the session type.
251 $session_ttl = PhabricatorAuthSession::getSessionTypeTTL(
252 $session_type,
253 $partial);
254
255 $digest_key = PhabricatorAuthSession::newSessionDigest(
256 new PhutilOpaqueEnvelope($session_key));
257
258 // Logging-in users don't have CSRF stuff yet, so we have to unguard this
259 // write.
260 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
261 id(new PhabricatorAuthSession())
262 ->setUserPHID($identity_phid)
263 ->setType($session_type)
264 ->setSessionKey($digest_key)
265 ->setSessionStart(time())
266 ->setSessionExpires(time() + $session_ttl)
267 ->setIsPartial($partial ? 1 : 0)
268 ->setSignedLegalpadDocuments(0)
269 ->save();
270
271 $log = PhabricatorUserLog::initializeNewLog(
272 null,
273 $identity_phid,
274 ($partial
275 ? PhabricatorPartialLoginUserLogType::LOGTYPE
276 : PhabricatorLoginUserLogType::LOGTYPE));
277
278 $log->setDetails(
279 array(
280 'session_type' => $session_type,
281 ));
282 $log->setSession($digest_key);
283 $log->save();
284 unset($unguarded);
285
286 $info = id(new PhabricatorAuthSessionInfo())
287 ->setSessionType($session_type)
288 ->setIdentityPHID($identity_phid)
289 ->setIsPartial($partial);
290
291 $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
292 foreach ($extensions as $extension) {
293 $extension->didEstablishSession($info);
294 }
295
296 return $session_key;
297 }
298
299
300 /**
301 * Terminate all of a user's login sessions.
302 *
303 * This is used when users change passwords, linked accounts, or add
304 * multifactor authentication.
305 *
306 * @param PhabricatorUser $user User whose sessions should be terminated.
307 * @param PhutilOpaqueEnvelope|null $except_session (optional) One session to
308 * keep. Normally, the current login session.
309 *
310 * @return void
311 */
312 public function terminateLoginSessions(
313 PhabricatorUser $user,
314 ?PhutilOpaqueEnvelope $except_session = null) {
315
316 $sessions = id(new PhabricatorAuthSessionQuery())
317 ->setViewer($user)
318 ->withIdentityPHIDs(array($user->getPHID()))
319 ->execute();
320
321 if ($except_session !== null) {
322 $except_session = PhabricatorAuthSession::newSessionDigest(
323 $except_session);
324 }
325
326 foreach ($sessions as $key => $session) {
327 if ($except_session !== null) {
328 $is_except = phutil_hashes_are_identical(
329 $session->getSessionKey(),
330 $except_session);
331 if ($is_except) {
332 continue;
333 }
334 }
335
336 $session->delete();
337 }
338 }
339
340 public function logoutSession(
341 PhabricatorUser $user,
342 PhabricatorAuthSession $session) {
343
344 $log = PhabricatorUserLog::initializeNewLog(
345 $user,
346 $user->getPHID(),
347 PhabricatorLogoutUserLogType::LOGTYPE);
348 $log->save();
349
350 $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
351 foreach ($extensions as $extension) {
352 $extension->didLogout($user, array($session));
353 }
354
355 $session->delete();
356 }
357
358
359/* -( High Security )------------------------------------------------------ */
360
361
362 /**
363 * Require the user respond to a high security (MFA) check.
364 *
365 * This method differs from @{method:requireHighSecuritySession} in that it
366 * does not upgrade the user's session as a side effect. This method is
367 * appropriate for one-time checks.
368 *
369 * @param PhabricatorUser $viewer User whose session needs to be in high
370 * security.
371 * @param AphrontRequest $request Current request.
372 * @param string $cancel_uri URI to return the user to if they
373 * cancel.
374 * @return PhabricatorAuthHighSecurityToken Security token.
375 * @task hisec
376 */
377 public function requireHighSecurityToken(
378 PhabricatorUser $viewer,
379 AphrontRequest $request,
380 $cancel_uri) {
381
382 return $this->newHighSecurityToken(
383 $viewer,
384 $request,
385 $cancel_uri,
386 false,
387 false);
388 }
389
390
391 /**
392 * Require high security, or prompt the user to enter high security.
393 *
394 * If the user's session is in high security, this method will return a
395 * token. Otherwise, it will throw an exception which will eventually
396 * be converted into a multi-factor authentication workflow.
397 *
398 * This method upgrades the user's session to high security for a short
399 * period of time, and is appropriate if you anticipate they may need to
400 * take multiple high security actions. To perform a one-time check instead,
401 * use @{method:requireHighSecurityToken}.
402 *
403 * @param PhabricatorUser $viewer User whose session needs to be in high
404 * security.
405 * @param AphrontRequest $request Current request.
406 * @param string $cancel_uri URI to return the user to if they
407 * cancel.
408 * @param bool $jump_into_hisec (optional) True to jump partial
409 * sessions directly into high security instead of
410 * just upgrading them to full sessions.
411 * @return PhabricatorAuthHighSecurityToken Security token.
412 * @task hisec
413 */
414 public function requireHighSecuritySession(
415 PhabricatorUser $viewer,
416 AphrontRequest $request,
417 $cancel_uri,
418 $jump_into_hisec = false) {
419
420 return $this->newHighSecurityToken(
421 $viewer,
422 $request,
423 $cancel_uri,
424 $jump_into_hisec,
425 true);
426 }
427
428 private function newHighSecurityToken(
429 PhabricatorUser $viewer,
430 AphrontRequest $request,
431 $cancel_uri,
432 $jump_into_hisec,
433 $upgrade_session) {
434
435 if (!$viewer->hasSession()) {
436 throw new Exception(
437 pht('Requiring a high-security session from a user with no session!'));
438 }
439
440 // TODO: If a user answers a "requireHighSecurityToken()" prompt and hits
441 // a "requireHighSecuritySession()" prompt a short time later, the one-shot
442 // token should be good enough to upgrade the session.
443
444 $session = $viewer->getSession();
445
446 // Check if the session is already in high security mode.
447 $token = $this->issueHighSecurityToken($session);
448 if ($token) {
449 return $token;
450 }
451
452 // Load the multi-factor auth sources attached to this account. Note that
453 // we order factors from oldest to newest, which is not the default query
454 // ordering but makes the greatest sense in context.
455 $factors = id(new PhabricatorAuthFactorConfigQuery())
456 ->setViewer($viewer)
457 ->withUserPHIDs(array($viewer->getPHID()))
458 ->withFactorProviderStatuses(
459 array(
460 PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
461 PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
462 ))
463 ->execute();
464
465 // Sort factors in the same order that they appear in on the Settings
466 // panel. This means that administrators changing provider statuses may
467 // change the order of prompts for users, but the alternative is that the
468 // Settings panel order disagrees with the prompt order, which seems more
469 // disruptive.
470 $factors = msortv($factors, 'newSortVector');
471
472 // If the account has no associated multi-factor auth, just issue a token
473 // without putting the session into high security mode. This is generally
474 // easier for users. A minor but desirable side effect is that when a user
475 // adds an auth factor, existing sessions won't get a free pass into hisec,
476 // since they never actually got marked as hisec.
477 if (!$factors) {
478 return $this->issueHighSecurityToken($session, true)
479 ->setIsUnchallengedToken(true);
480 }
481
482 $this->request = $request;
483 foreach ($factors as $factor) {
484 $factor->setSessionEngine($this);
485 }
486
487 // Check for a rate limit without awarding points, so the user doesn't
488 // get partway through the workflow only to get blocked.
489 PhabricatorSystemActionEngine::willTakeAction(
490 array($viewer->getPHID()),
491 new PhabricatorAuthTryFactorAction(),
492 0);
493
494 $now = PhabricatorTime::getNow();
495
496 // We need to do challenge validation first, since this happens whether you
497 // submitted responses or not. You can't get a "bad response" error before
498 // you actually submit a response, but you can get a "wait, we can't
499 // issue a challenge yet" response. Load all issued challenges which are
500 // currently valid.
501 $challenges = id(new PhabricatorAuthChallengeQuery())
502 ->setViewer($viewer)
503 ->withFactorPHIDs(mpull($factors, 'getPHID'))
504 ->withUserPHIDs(array($viewer->getPHID()))
505 ->withChallengeTTLBetween($now, null)
506 ->execute();
507
508 PhabricatorAuthChallenge::newChallengeResponsesFromRequest(
509 $challenges,
510 $request);
511
512 $challenge_map = mgroup($challenges, 'getFactorPHID');
513
514 $validation_results = array();
515 $ok = true;
516
517 // Validate each factor against issued challenges. For example, this
518 // prevents you from receiving or responding to a TOTP challenge if another
519 // challenge was recently issued to a different session.
520 foreach ($factors as $factor) {
521 $factor_phid = $factor->getPHID();
522 $issued_challenges = idx($challenge_map, $factor_phid, array());
523 $provider = $factor->getFactorProvider();
524 $impl = $provider->getFactor();
525
526 $new_challenges = $impl->getNewIssuedChallenges(
527 $factor,
528 $viewer,
529 $issued_challenges);
530
531 // NOTE: We may get a list of challenges back, or may just get an early
532 // result. For example, this can happen on an SMS factor if all SMS
533 // mailers have been disabled.
534 if ($new_challenges instanceof PhabricatorAuthFactorResult) {
535 $result = $new_challenges;
536
537 if (!$result->getIsValid()) {
538 $ok = false;
539 }
540
541 $validation_results[$factor_phid] = $result;
542 $challenge_map[$factor_phid] = $issued_challenges;
543 continue;
544 }
545
546 foreach ($new_challenges as $new_challenge) {
547 $issued_challenges[] = $new_challenge;
548 }
549 $challenge_map[$factor_phid] = $issued_challenges;
550
551 if (!$issued_challenges) {
552 continue;
553 }
554
555 $result = $impl->getResultFromIssuedChallenges(
556 $factor,
557 $viewer,
558 $issued_challenges);
559
560 if (!$result) {
561 continue;
562 }
563
564 if (!$result->getIsValid()) {
565 $ok = false;
566 }
567
568 $validation_results[$factor_phid] = $result;
569 }
570
571 if ($request->isHTTPPost()) {
572 $request->validateCSRF();
573 if ($request->getExists(AphrontRequest::TYPE_HISEC)) {
574
575 // Limit factor verification rates to prevent brute force attacks.
576 $any_attempt = false;
577 foreach ($factors as $factor) {
578 $factor_phid = $factor->getPHID();
579
580 $provider = $factor->getFactorProvider();
581 $impl = $provider->getFactor();
582
583 // If we already have a result (normally "wait..."), we won't try
584 // to validate whatever the user submitted, so this doesn't count as
585 // an attempt for rate limiting purposes.
586 if (isset($validation_results[$factor_phid])) {
587 continue;
588 }
589
590 if ($impl->getRequestHasChallengeResponse($factor, $request)) {
591 $any_attempt = true;
592 break;
593 }
594 }
595
596 if ($any_attempt) {
597 PhabricatorSystemActionEngine::willTakeAction(
598 array($viewer->getPHID()),
599 new PhabricatorAuthTryFactorAction(),
600 1);
601 }
602
603 foreach ($factors as $factor) {
604 $factor_phid = $factor->getPHID();
605
606 // If we already have a validation result from previously issued
607 // challenges, skip validating this factor.
608 if (isset($validation_results[$factor_phid])) {
609 continue;
610 }
611
612 $issued_challenges = idx($challenge_map, $factor_phid, array());
613
614 $provider = $factor->getFactorProvider();
615 $impl = $provider->getFactor();
616
617 $validation_result = $impl->getResultFromChallengeResponse(
618 $factor,
619 $viewer,
620 $request,
621 $issued_challenges);
622
623 if (!$validation_result->getIsValid()) {
624 $ok = false;
625 }
626
627 $validation_results[$factor_phid] = $validation_result;
628 }
629
630 if ($ok) {
631 // We're letting you through, so mark all the challenges you
632 // responded to as completed. These challenges can never be used
633 // again, even by the same session and workflow: you can't use the
634 // same response to take two different actions, even if those actions
635 // are of the same type.
636 foreach ($validation_results as $validation_result) {
637 $challenge = $validation_result->getAnsweredChallenge()
638 ->markChallengeAsCompleted();
639 }
640
641 // Give the user a credit back for a successful factor verification.
642 if ($any_attempt) {
643 PhabricatorSystemActionEngine::willTakeAction(
644 array($viewer->getPHID()),
645 new PhabricatorAuthTryFactorAction(),
646 -1);
647 }
648
649 if ($session->getIsPartial() && !$jump_into_hisec) {
650 // If we have a partial session and are not jumping directly into
651 // hisec, just issue a token without putting it in high security
652 // mode.
653 return $this->issueHighSecurityToken($session, true);
654 }
655
656 // If we aren't upgrading the session itself, just issue a token.
657 if (!$upgrade_session) {
658 return $this->issueHighSecurityToken($session, true);
659 }
660
661 $until = time() + phutil_units('15 minutes in seconds');
662 $session->setHighSecurityUntil($until);
663
664 queryfx(
665 $session->establishConnection('w'),
666 'UPDATE %T SET highSecurityUntil = %d WHERE id = %d',
667 $session->getTableName(),
668 $until,
669 $session->getID());
670
671 $log = PhabricatorUserLog::initializeNewLog(
672 $viewer,
673 $viewer->getPHID(),
674 PhabricatorEnterHisecUserLogType::LOGTYPE);
675 $log->save();
676 } else {
677 $log = PhabricatorUserLog::initializeNewLog(
678 $viewer,
679 $viewer->getPHID(),
680 PhabricatorFailHisecUserLogType::LOGTYPE);
681 $log->save();
682 }
683 }
684 }
685
686 $token = $this->issueHighSecurityToken($session);
687 if ($token) {
688 return $token;
689 }
690
691 // If we don't have a validation result for some factors yet, fill them
692 // in with an empty result so form rendering doesn't have to care if the
693 // results exist or not. This happens when you first load the form and have
694 // not submitted any responses yet.
695 foreach ($factors as $factor) {
696 $factor_phid = $factor->getPHID();
697 if (isset($validation_results[$factor_phid])) {
698 continue;
699 }
700
701 $issued_challenges = idx($challenge_map, $factor_phid, array());
702
703 $validation_results[$factor_phid] = $impl->getResultForPrompt(
704 $factor,
705 $viewer,
706 $request,
707 $issued_challenges);
708 }
709
710 throw id(new PhabricatorAuthHighSecurityRequiredException())
711 ->setCancelURI($cancel_uri)
712 ->setIsSessionUpgrade($upgrade_session)
713 ->setFactors($factors)
714 ->setFactorValidationResults($validation_results);
715 }
716
717
718 /**
719 * Issue a high security token for a session, if authorized.
720 *
721 * @param PhabricatorAuthSession $session Session to issue a token for.
722 * @param bool $force (optional) Force token issue.
723 * @return PhabricatorAuthHighSecurityToken|null Token, if authorized.
724 * @task hisec
725 */
726 private function issueHighSecurityToken(
727 PhabricatorAuthSession $session,
728 $force = false) {
729
730 if ($session->isHighSecuritySession() || $force) {
731 return new PhabricatorAuthHighSecurityToken();
732 }
733
734 return null;
735 }
736
737
738 /**
739 * Render a form for providing relevant multi-factor credentials.
740 *
741 * @param array $factors
742 * @param array<PhabricatorAuthFactorResult> $validation_results
743 * @param PhabricatorUser $viewer Viewing user.
744 * @param AphrontRequest $request Current request.
745 * @return AphrontFormView Renderable form.
746 * @task hisec
747 */
748 public function renderHighSecurityForm(
749 array $factors,
750 array $validation_results,
751 PhabricatorUser $viewer,
752 AphrontRequest $request) {
753 assert_instances_of(
754 $validation_results,
755 PhabricatorAuthFactorResult::class);
756
757 $form = id(new AphrontFormView())
758 ->setUser($viewer)
759 ->appendRemarkupInstructions('');
760
761 $answered = array();
762 foreach ($factors as $factor) {
763 $result = $validation_results[$factor->getPHID()];
764
765 $provider = $factor->getFactorProvider();
766 $impl = $provider->getFactor();
767
768 $impl->renderValidateFactorForm(
769 $factor,
770 $form,
771 $viewer,
772 $result);
773
774 $answered_challenge = $result->getAnsweredChallenge();
775 if ($answered_challenge) {
776 $answered[] = $answered_challenge;
777 }
778 }
779
780 $form->appendRemarkupInstructions('');
781
782 if ($answered) {
783 $http_params = PhabricatorAuthChallenge::newHTTPParametersFromChallenges(
784 $answered);
785 foreach ($http_params as $key => $value) {
786 $form->addHiddenInput($key, $value);
787 }
788 }
789
790 return $form;
791 }
792
793
794 /**
795 * Strip the high security flag from a session.
796 *
797 * Kicks a session out of high security and logs the exit.
798 *
799 * @param PhabricatorUser $viewer Acting user.
800 * @param PhabricatorAuthSession $session Session to return to normal
801 * security.
802 * @return void
803 * @task hisec
804 */
805 public function exitHighSecurity(
806 PhabricatorUser $viewer,
807 PhabricatorAuthSession $session) {
808
809 if (!$session->getHighSecurityUntil()) {
810 return;
811 }
812
813 queryfx(
814 $session->establishConnection('w'),
815 'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d',
816 $session->getTableName(),
817 $session->getID());
818
819 $log = PhabricatorUserLog::initializeNewLog(
820 $viewer,
821 $viewer->getPHID(),
822 PhabricatorExitHisecUserLogType::LOGTYPE);
823 $log->save();
824 }
825
826
827/* -( Partial Sessions )--------------------------------------------------- */
828
829
830 /**
831 * Upgrade a partial session to a full session.
832 *
833 * @param PhabricatorUser $viewer Viewer whose session should upgrade.
834 * @return void
835 * @task partial
836 */
837 public function upgradePartialSession(PhabricatorUser $viewer) {
838
839 if (!$viewer->hasSession()) {
840 throw new Exception(
841 pht('Upgrading partial session of user with no session!'));
842 }
843
844 $session = $viewer->getSession();
845
846 if (!$session->getIsPartial()) {
847 throw new Exception(pht('Session is not partial!'));
848 }
849
850 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
851 $session->setIsPartial(0);
852
853 queryfx(
854 $session->establishConnection('w'),
855 'UPDATE %T SET isPartial = %d WHERE id = %d',
856 $session->getTableName(),
857 0,
858 $session->getID());
859
860 $log = PhabricatorUserLog::initializeNewLog(
861 $viewer,
862 $viewer->getPHID(),
863 PhabricatorFullLoginUserLogType::LOGTYPE);
864 $log->save();
865 unset($unguarded);
866 }
867
868
869/* -( Legalpad Documents )-------------------------------------------------- */
870
871
872 /**
873 * Upgrade a session to have all legalpad documents signed.
874 *
875 * @param PhabricatorUser $viewer User whose session should upgrade.
876 * @param array $docs LegalpadDocument objects
877 * @return void
878 * @task partial
879 */
880 public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) {
881
882 if (!$viewer->hasSession()) {
883 throw new Exception(
884 pht('Signing session legalpad documents of user with no session!'));
885 }
886
887 $session = $viewer->getSession();
888
889 if ($session->getSignedLegalpadDocuments()) {
890 throw new Exception(pht(
891 'Session has already signed required legalpad documents!'));
892 }
893
894 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
895 $session->setSignedLegalpadDocuments(1);
896
897 queryfx(
898 $session->establishConnection('w'),
899 'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d',
900 $session->getTableName(),
901 1,
902 $session->getID());
903
904 if (!empty($docs)) {
905 $log = PhabricatorUserLog::initializeNewLog(
906 $viewer,
907 $viewer->getPHID(),
908 PhabricatorSignDocumentsUserLogType::LOGTYPE);
909 $log->save();
910 }
911 unset($unguarded);
912 }
913
914
915/* -( One Time Login URIs )------------------------------------------------ */
916
917
918 /**
919 * Retrieve a temporary, one-time URI which can log in to an account.
920 *
921 * These URIs are used for password recovery and to regain access to accounts
922 * which users have been locked out of.
923 *
924 * @param PhabricatorUser $user User to generate a URI for.
925 * @param PhabricatorUserEmail|null $email Optionally, email to verify when
926 * link is used.
927 * @param string $type (optional) Context string for the URI. This is purely
928 * cosmetic and used only to customize workflow and error messages.
929 * @param bool $force_full_session (optional) True to generate a URI which
930 * forces an immediate upgrade to a full session, bypassing MFA and other
931 * login checks.
932 * @return string Login URI.
933 * @task onetime
934 */
935 public function getOneTimeLoginURI(
936 PhabricatorUser $user,
937 ?PhabricatorUserEmail $email = null,
938 $type = self::ONETIME_RESET,
939 $force_full_session = false) {
940
941 $key = Filesystem::readRandomCharacters(32);
942 $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
943 $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
944
945 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
946 $token = id(new PhabricatorAuthTemporaryToken())
947 ->setTokenResource($user->getPHID())
948 ->setTokenType($onetime_type)
949 ->setTokenExpires(time() + phutil_units('1 day in seconds'))
950 ->setTokenCode($key_hash)
951 ->setShouldForceFullSession($force_full_session)
952 ->save();
953 unset($unguarded);
954
955 $uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/';
956 if ($email) {
957 $uri = $uri.$email->getID().'/';
958 }
959
960 try {
961 $uri = PhabricatorEnv::getProductionURI($uri);
962 } catch (Exception $ex) {
963 // If a user runs `bin/auth recover` before configuring the base URI,
964 // just show the path. We don't have any way to figure out the domain.
965 // See T4132.
966 }
967
968 return $uri;
969 }
970
971
972 /**
973 * Load the temporary token associated with a given one-time login key.
974 *
975 * @param PhabricatorUser $user User to load the token for.
976 * @param PhabricatorUserEmail $email (optional) Email to verify when link is
977 * used.
978 * @param string $key (optional) Key user is presenting as a valid one-time
979 * login key.
980 * @return PhabricatorAuthTemporaryToken|null Token, if one exists.
981 * @task onetime
982 */
983 public function loadOneTimeLoginKey(
984 PhabricatorUser $user,
985 ?PhabricatorUserEmail $email = null,
986 $key = null) {
987
988 $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
989 $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
990
991 return id(new PhabricatorAuthTemporaryTokenQuery())
992 ->setViewer($user)
993 ->withTokenResources(array($user->getPHID()))
994 ->withTokenTypes(array($onetime_type))
995 ->withTokenCodes(array($key_hash))
996 ->withExpired(false)
997 ->executeOne();
998 }
999
1000
1001 /**
1002 * Hash a one-time login key for storage as a temporary token.
1003 *
1004 * @param PhabricatorUser $user User this key is for.
1005 * @param PhabricatorUserEmail $email (optional) Email to verify when link is
1006 * used.
1007 * @param string $key (optional) The one time login key.
1008 * @return string Hash of the key.
1009 * task onetime
1010 */
1011 private function getOneTimeLoginKeyHash(
1012 PhabricatorUser $user,
1013 ?PhabricatorUserEmail $email = null,
1014 $key = null) {
1015
1016 $parts = array(
1017 $key,
1018 $user->getAccountSecret(),
1019 );
1020
1021 if ($email) {
1022 $parts[] = $email->getVerificationCode();
1023 }
1024
1025 return PhabricatorHash::weakDigest(implode(':', $parts));
1026 }
1027
1028
1029/* -( User Cache )--------------------------------------------------------- */
1030
1031
1032 /**
1033 * @task cache
1034 */
1035 private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) {
1036 $cache_selects = array();
1037 $cache_joins = array();
1038 $cache_map = array();
1039
1040 $keys = array();
1041 $types_map = array();
1042
1043 $cache_types = PhabricatorUserCacheType::getAllCacheTypes();
1044 foreach ($cache_types as $cache_type) {
1045 foreach ($cache_type->getAutoloadKeys() as $autoload_key) {
1046 $keys[] = $autoload_key;
1047 $types_map[$autoload_key] = $cache_type;
1048 }
1049 }
1050
1051 $cache_table = id(new PhabricatorUserCache())->getTableName();
1052
1053 $cache_idx = 1;
1054 foreach ($keys as $key) {
1055 $join_as = 'ucache_'.$cache_idx;
1056 $select_as = 'ucache_'.$cache_idx.'_v';
1057
1058 $cache_selects[] = qsprintf(
1059 $conn,
1060 '%T.cacheData %T',
1061 $join_as,
1062 $select_as);
1063
1064 $cache_joins[] = qsprintf(
1065 $conn,
1066 'LEFT JOIN %T AS %T ON u.phid = %T.userPHID
1067 AND %T.cacheIndex = %s',
1068 $cache_table,
1069 $join_as,
1070 $join_as,
1071 $join_as,
1072 PhabricatorHash::digestForIndex($key));
1073
1074 $cache_map[$select_as] = $key;
1075
1076 $cache_idx++;
1077 }
1078
1079 if ($cache_selects) {
1080 $cache_selects = qsprintf($conn, ', %LQ', $cache_selects);
1081 } else {
1082 $cache_selects = qsprintf($conn, '');
1083 }
1084
1085 if ($cache_joins) {
1086 $cache_joins = qsprintf($conn, '%LJ', $cache_joins);
1087 } else {
1088 $cache_joins = qsprintf($conn, '');
1089 }
1090
1091 return array($cache_selects, $cache_joins, $cache_map, $types_map);
1092 }
1093
1094 private function filterRawCacheData(
1095 PhabricatorUser $user,
1096 array $types_map,
1097 array $cache_raw) {
1098
1099 foreach ($cache_raw as $cache_key => $cache_data) {
1100 $type = $types_map[$cache_key];
1101 if ($type->shouldValidateRawCacheData()) {
1102 if (!$type->isRawCacheDataValid($user, $cache_key, $cache_data)) {
1103 unset($cache_raw[$cache_key]);
1104 }
1105 }
1106 }
1107
1108 return $cache_raw;
1109 }
1110
1111 public function willServeRequestForUser(PhabricatorUser $user) {
1112 // We allow the login user to generate any missing cache data inline.
1113 $user->setAllowInlineCacheGeneration(true);
1114
1115 // Switch to the user's translation.
1116 PhabricatorEnv::setLocaleCode($user->getTranslation());
1117
1118 $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
1119 foreach ($extensions as $extension) {
1120 $extension->willServeRequestForUser($user);
1121 }
1122 }
1123
1124 private function extendSession(PhabricatorAuthSession $session) {
1125 $is_partial = $session->getIsPartial();
1126
1127 // Don't extend partial sessions. You have a relatively short window to
1128 // upgrade into a full session, and your session expires otherwise.
1129 if ($is_partial) {
1130 return;
1131 }
1132
1133 $session_type = $session->getType();
1134
1135 $ttl = PhabricatorAuthSession::getSessionTypeTTL(
1136 $session_type,
1137 $session->getIsPartial());
1138
1139 // If more than 20% of the time on this session has been used, refresh the
1140 // TTL back up to the full duration. The idea here is that sessions are
1141 // good forever if used regularly, but get GC'd when they fall out of use.
1142
1143 $now = PhabricatorTime::getNow();
1144 if ($now + (0.80 * $ttl) <= $session->getSessionExpires()) {
1145 return;
1146 }
1147
1148 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
1149 queryfx(
1150 $session->establishConnection('w'),
1151 'UPDATE %R SET sessionExpires = UNIX_TIMESTAMP() + %d
1152 WHERE id = %d',
1153 $session,
1154 $ttl,
1155 $session->getID());
1156 unset($unguarded);
1157 }
1158
1159
1160}