@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
at upstream/main 1160 lines 36 kB view raw
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}