Path: blob/master/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
12256 views
<?php12/**3*4* @task use Using Sessions5* @task new Creating Sessions6* @task hisec High Security7* @task partial Partial Sessions8* @task onetime One Time Login URIs9* @task cache User Cache10*/11final class PhabricatorAuthSessionEngine extends Phobject {1213/**14* Session issued to normal users after they login through a standard channel.15* Associates the client with a standard user identity.16*/17const KIND_USER = 'U';181920/**21* Session issued to users who login with some sort of credentials but do not22* have full accounts. These are sometimes called "grey users".23*24* TODO: We do not currently issue these sessions, see T4310.25*/26const KIND_EXTERNAL = 'X';272829/**30* Session issued to logged-out users which has no real identity information.31* Its purpose is to protect logged-out users from CSRF.32*/33const KIND_ANONYMOUS = 'A';343536/**37* Session kind isn't known.38*/39const KIND_UNKNOWN = '?';404142const ONETIME_RECOVER = 'recover';43const ONETIME_RESET = 'reset';44const ONETIME_WELCOME = 'welcome';45const ONETIME_USERNAME = 'rename';464748private $workflowKey;49private $request;5051public function setWorkflowKey($workflow_key) {52$this->workflowKey = $workflow_key;53return $this;54}5556public function getWorkflowKey() {5758// TODO: A workflow key should become required in order to issue an MFA59// challenge, but allow things to keep working for now until we can update60// callsites.61if ($this->workflowKey === null) {62return 'legacy';63}6465return $this->workflowKey;66}6768public function getRequest() {69return $this->request;70}717273/**74* Get the session kind (e.g., anonymous, user, external account) from a75* session token. Returns a `KIND_` constant.76*77* @param string Session token.78* @return const Session kind constant.79*/80public static function getSessionKindFromToken($session_token) {81if (strpos($session_token, '/') === false) {82// Old-style session, these are all user sessions.83return self::KIND_USER;84}8586list($kind, $key) = explode('/', $session_token, 2);8788switch ($kind) {89case self::KIND_ANONYMOUS:90case self::KIND_USER:91case self::KIND_EXTERNAL:92return $kind;93default:94return self::KIND_UNKNOWN;95}96}979899/**100* Load the user identity associated with a session of a given type,101* identified by token.102*103* When the user presents a session token to an API, this method verifies104* it is of the correct type and loads the corresponding identity if the105* session exists and is valid.106*107* NOTE: `$session_type` is the type of session that is required by the108* loading context. This prevents use of a Conduit sesssion as a Web109* session, for example.110*111* @param const The type of session to load.112* @param string The session token.113* @return PhabricatorUser|null114* @task use115*/116public function loadUserForSession($session_type, $session_token) {117$session_kind = self::getSessionKindFromToken($session_token);118switch ($session_kind) {119case self::KIND_ANONYMOUS:120// Don't bother trying to load a user for an anonymous session, since121// neither the session nor the user exist.122return null;123case self::KIND_UNKNOWN:124// If we don't know what kind of session this is, don't go looking for125// it.126return null;127case self::KIND_USER:128break;129case self::KIND_EXTERNAL:130// TODO: Implement these (T4310).131return null;132}133134$session_table = new PhabricatorAuthSession();135$user_table = new PhabricatorUser();136$conn = $session_table->establishConnection('r');137138// TODO: See T13225. We're moving sessions to a more modern digest139// algorithm, but still accept older cookies for compatibility.140$session_key = PhabricatorAuthSession::newSessionDigest(141new PhutilOpaqueEnvelope($session_token));142$weak_key = PhabricatorHash::weakDigest($session_token);143144$cache_parts = $this->getUserCacheQueryParts($conn);145list($cache_selects, $cache_joins, $cache_map, $types_map) = $cache_parts;146147$info = queryfx_one(148$conn,149'SELECT150s.id AS s_id,151s.phid AS s_phid,152s.sessionExpires AS s_sessionExpires,153s.sessionStart AS s_sessionStart,154s.highSecurityUntil AS s_highSecurityUntil,155s.isPartial AS s_isPartial,156s.signedLegalpadDocuments as s_signedLegalpadDocuments,157IF(s.sessionKey = %P, 1, 0) as s_weak,158u.*159%Q160FROM %R u JOIN %R s ON u.phid = s.userPHID161AND s.type = %s AND s.sessionKey IN (%P, %P) %Q',162new PhutilOpaqueEnvelope($weak_key),163$cache_selects,164$user_table,165$session_table,166$session_type,167new PhutilOpaqueEnvelope($session_key),168new PhutilOpaqueEnvelope($weak_key),169$cache_joins);170171if (!$info) {172return null;173}174175// TODO: Remove this, see T13225.176$is_weak = (bool)$info['s_weak'];177unset($info['s_weak']);178179$session_dict = array(180'userPHID' => $info['phid'],181'sessionKey' => $session_key,182'type' => $session_type,183);184185$cache_raw = array_fill_keys($cache_map, null);186foreach ($info as $key => $value) {187if (strncmp($key, 's_', 2) === 0) {188unset($info[$key]);189$session_dict[substr($key, 2)] = $value;190continue;191}192193if (isset($cache_map[$key])) {194unset($info[$key]);195$cache_raw[$cache_map[$key]] = $value;196continue;197}198}199200$user = $user_table->loadFromArray($info);201202$cache_raw = $this->filterRawCacheData($user, $types_map, $cache_raw);203$user->attachRawCacheData($cache_raw);204205switch ($session_type) {206case PhabricatorAuthSession::TYPE_WEB:207// Explicitly prevent bots and mailing lists from establishing web208// sessions. It's normally impossible to attach authentication to these209// accounts, and likewise impossible to generate sessions, but it's210// technically possible that a session could exist in the database. If211// one does somehow, refuse to load it.212if (!$user->canEstablishWebSessions()) {213return null;214}215break;216}217218$session = id(new PhabricatorAuthSession())->loadFromArray($session_dict);219220$this->extendSession($session);221222// TODO: Remove this, see T13225.223if ($is_weak) {224$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();225$conn_w = $session_table->establishConnection('w');226queryfx(227$conn_w,228'UPDATE %T SET sessionKey = %P WHERE id = %d',229$session->getTableName(),230new PhutilOpaqueEnvelope($session_key),231$session->getID());232unset($unguarded);233}234235$user->attachSession($session);236return $user;237}238239240/**241* Issue a new session key for a given identity. Phabricator supports242* different types of sessions (like "web" and "conduit") and each session243* type may have multiple concurrent sessions (this allows a user to be244* logged in on multiple browsers at the same time, for instance).245*246* Note that this method is transport-agnostic and does not set cookies or247* issue other types of tokens, it ONLY generates a new session key.248*249* You can configure the maximum number of concurrent sessions for various250* session types in the Phabricator configuration.251*252* @param const Session type constant (see253* @{class:PhabricatorAuthSession}).254* @param phid|null Identity to establish a session for, usually a user255* PHID. With `null`, generates an anonymous session.256* @param bool True to issue a partial session.257* @return string Newly generated session key.258*/259public function establishSession($session_type, $identity_phid, $partial) {260// Consume entropy to generate a new session key, forestalling the eventual261// heat death of the universe.262$session_key = Filesystem::readRandomCharacters(40);263264if ($identity_phid === null) {265return self::KIND_ANONYMOUS.'/'.$session_key;266}267268$session_table = new PhabricatorAuthSession();269$conn_w = $session_table->establishConnection('w');270271// This has a side effect of validating the session type.272$session_ttl = PhabricatorAuthSession::getSessionTypeTTL(273$session_type,274$partial);275276$digest_key = PhabricatorAuthSession::newSessionDigest(277new PhutilOpaqueEnvelope($session_key));278279// Logging-in users don't have CSRF stuff yet, so we have to unguard this280// write.281$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();282id(new PhabricatorAuthSession())283->setUserPHID($identity_phid)284->setType($session_type)285->setSessionKey($digest_key)286->setSessionStart(time())287->setSessionExpires(time() + $session_ttl)288->setIsPartial($partial ? 1 : 0)289->setSignedLegalpadDocuments(0)290->save();291292$log = PhabricatorUserLog::initializeNewLog(293null,294$identity_phid,295($partial296? PhabricatorPartialLoginUserLogType::LOGTYPE297: PhabricatorLoginUserLogType::LOGTYPE));298299$log->setDetails(300array(301'session_type' => $session_type,302));303$log->setSession($digest_key);304$log->save();305unset($unguarded);306307$info = id(new PhabricatorAuthSessionInfo())308->setSessionType($session_type)309->setIdentityPHID($identity_phid)310->setIsPartial($partial);311312$extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();313foreach ($extensions as $extension) {314$extension->didEstablishSession($info);315}316317return $session_key;318}319320321/**322* Terminate all of a user's login sessions.323*324* This is used when users change passwords, linked accounts, or add325* multifactor authentication.326*327* @param PhabricatorUser User whose sessions should be terminated.328* @param string|null Optionally, one session to keep. Normally, the current329* login session.330*331* @return void332*/333public function terminateLoginSessions(334PhabricatorUser $user,335PhutilOpaqueEnvelope $except_session = null) {336337$sessions = id(new PhabricatorAuthSessionQuery())338->setViewer($user)339->withIdentityPHIDs(array($user->getPHID()))340->execute();341342if ($except_session !== null) {343$except_session = PhabricatorAuthSession::newSessionDigest(344$except_session);345}346347foreach ($sessions as $key => $session) {348if ($except_session !== null) {349$is_except = phutil_hashes_are_identical(350$session->getSessionKey(),351$except_session);352if ($is_except) {353continue;354}355}356357$session->delete();358}359}360361public function logoutSession(362PhabricatorUser $user,363PhabricatorAuthSession $session) {364365$log = PhabricatorUserLog::initializeNewLog(366$user,367$user->getPHID(),368PhabricatorLogoutUserLogType::LOGTYPE);369$log->save();370371$extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();372foreach ($extensions as $extension) {373$extension->didLogout($user, array($session));374}375376$session->delete();377}378379380/* -( High Security )------------------------------------------------------ */381382383/**384* Require the user respond to a high security (MFA) check.385*386* This method differs from @{method:requireHighSecuritySession} in that it387* does not upgrade the user's session as a side effect. This method is388* appropriate for one-time checks.389*390* @param PhabricatorUser User whose session needs to be in high security.391* @param AphrontRequest Current request.392* @param string URI to return the user to if they cancel.393* @return PhabricatorAuthHighSecurityToken Security token.394* @task hisec395*/396public function requireHighSecurityToken(397PhabricatorUser $viewer,398AphrontRequest $request,399$cancel_uri) {400401return $this->newHighSecurityToken(402$viewer,403$request,404$cancel_uri,405false,406false);407}408409410/**411* Require high security, or prompt the user to enter high security.412*413* If the user's session is in high security, this method will return a414* token. Otherwise, it will throw an exception which will eventually415* be converted into a multi-factor authentication workflow.416*417* This method upgrades the user's session to high security for a short418* period of time, and is appropriate if you anticipate they may need to419* take multiple high security actions. To perform a one-time check instead,420* use @{method:requireHighSecurityToken}.421*422* @param PhabricatorUser User whose session needs to be in high security.423* @param AphrontRequest Current request.424* @param string URI to return the user to if they cancel.425* @param bool True to jump partial sessions directly into high426* security instead of just upgrading them to full427* sessions.428* @return PhabricatorAuthHighSecurityToken Security token.429* @task hisec430*/431public function requireHighSecuritySession(432PhabricatorUser $viewer,433AphrontRequest $request,434$cancel_uri,435$jump_into_hisec = false) {436437return $this->newHighSecurityToken(438$viewer,439$request,440$cancel_uri,441$jump_into_hisec,442true);443}444445private function newHighSecurityToken(446PhabricatorUser $viewer,447AphrontRequest $request,448$cancel_uri,449$jump_into_hisec,450$upgrade_session) {451452if (!$viewer->hasSession()) {453throw new Exception(454pht('Requiring a high-security session from a user with no session!'));455}456457// TODO: If a user answers a "requireHighSecurityToken()" prompt and hits458// a "requireHighSecuritySession()" prompt a short time later, the one-shot459// token should be good enough to upgrade the session.460461$session = $viewer->getSession();462463// Check if the session is already in high security mode.464$token = $this->issueHighSecurityToken($session);465if ($token) {466return $token;467}468469// Load the multi-factor auth sources attached to this account. Note that470// we order factors from oldest to newest, which is not the default query471// ordering but makes the greatest sense in context.472$factors = id(new PhabricatorAuthFactorConfigQuery())473->setViewer($viewer)474->withUserPHIDs(array($viewer->getPHID()))475->withFactorProviderStatuses(476array(477PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,478PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,479))480->execute();481482// Sort factors in the same order that they appear in on the Settings483// panel. This means that administrators changing provider statuses may484// change the order of prompts for users, but the alternative is that the485// Settings panel order disagrees with the prompt order, which seems more486// disruptive.487$factors = msortv($factors, 'newSortVector');488489// If the account has no associated multi-factor auth, just issue a token490// without putting the session into high security mode. This is generally491// easier for users. A minor but desirable side effect is that when a user492// adds an auth factor, existing sessions won't get a free pass into hisec,493// since they never actually got marked as hisec.494if (!$factors) {495return $this->issueHighSecurityToken($session, true)496->setIsUnchallengedToken(true);497}498499$this->request = $request;500foreach ($factors as $factor) {501$factor->setSessionEngine($this);502}503504// Check for a rate limit without awarding points, so the user doesn't505// get partway through the workflow only to get blocked.506PhabricatorSystemActionEngine::willTakeAction(507array($viewer->getPHID()),508new PhabricatorAuthTryFactorAction(),5090);510511$now = PhabricatorTime::getNow();512513// We need to do challenge validation first, since this happens whether you514// submitted responses or not. You can't get a "bad response" error before515// you actually submit a response, but you can get a "wait, we can't516// issue a challenge yet" response. Load all issued challenges which are517// currently valid.518$challenges = id(new PhabricatorAuthChallengeQuery())519->setViewer($viewer)520->withFactorPHIDs(mpull($factors, 'getPHID'))521->withUserPHIDs(array($viewer->getPHID()))522->withChallengeTTLBetween($now, null)523->execute();524525PhabricatorAuthChallenge::newChallengeResponsesFromRequest(526$challenges,527$request);528529$challenge_map = mgroup($challenges, 'getFactorPHID');530531$validation_results = array();532$ok = true;533534// Validate each factor against issued challenges. For example, this535// prevents you from receiving or responding to a TOTP challenge if another536// challenge was recently issued to a different session.537foreach ($factors as $factor) {538$factor_phid = $factor->getPHID();539$issued_challenges = idx($challenge_map, $factor_phid, array());540$provider = $factor->getFactorProvider();541$impl = $provider->getFactor();542543$new_challenges = $impl->getNewIssuedChallenges(544$factor,545$viewer,546$issued_challenges);547548// NOTE: We may get a list of challenges back, or may just get an early549// result. For example, this can happen on an SMS factor if all SMS550// mailers have been disabled.551if ($new_challenges instanceof PhabricatorAuthFactorResult) {552$result = $new_challenges;553554if (!$result->getIsValid()) {555$ok = false;556}557558$validation_results[$factor_phid] = $result;559$challenge_map[$factor_phid] = $issued_challenges;560continue;561}562563foreach ($new_challenges as $new_challenge) {564$issued_challenges[] = $new_challenge;565}566$challenge_map[$factor_phid] = $issued_challenges;567568if (!$issued_challenges) {569continue;570}571572$result = $impl->getResultFromIssuedChallenges(573$factor,574$viewer,575$issued_challenges);576577if (!$result) {578continue;579}580581if (!$result->getIsValid()) {582$ok = false;583}584585$validation_results[$factor_phid] = $result;586}587588if ($request->isHTTPPost()) {589$request->validateCSRF();590if ($request->getExists(AphrontRequest::TYPE_HISEC)) {591592// Limit factor verification rates to prevent brute force attacks.593$any_attempt = false;594foreach ($factors as $factor) {595$factor_phid = $factor->getPHID();596597$provider = $factor->getFactorProvider();598$impl = $provider->getFactor();599600// If we already have a result (normally "wait..."), we won't try601// to validate whatever the user submitted, so this doesn't count as602// an attempt for rate limiting purposes.603if (isset($validation_results[$factor_phid])) {604continue;605}606607if ($impl->getRequestHasChallengeResponse($factor, $request)) {608$any_attempt = true;609break;610}611}612613if ($any_attempt) {614PhabricatorSystemActionEngine::willTakeAction(615array($viewer->getPHID()),616new PhabricatorAuthTryFactorAction(),6171);618}619620foreach ($factors as $factor) {621$factor_phid = $factor->getPHID();622623// If we already have a validation result from previously issued624// challenges, skip validating this factor.625if (isset($validation_results[$factor_phid])) {626continue;627}628629$issued_challenges = idx($challenge_map, $factor_phid, array());630631$provider = $factor->getFactorProvider();632$impl = $provider->getFactor();633634$validation_result = $impl->getResultFromChallengeResponse(635$factor,636$viewer,637$request,638$issued_challenges);639640if (!$validation_result->getIsValid()) {641$ok = false;642}643644$validation_results[$factor_phid] = $validation_result;645}646647if ($ok) {648// We're letting you through, so mark all the challenges you649// responded to as completed. These challenges can never be used650// again, even by the same session and workflow: you can't use the651// same response to take two different actions, even if those actions652// are of the same type.653foreach ($validation_results as $validation_result) {654$challenge = $validation_result->getAnsweredChallenge()655->markChallengeAsCompleted();656}657658// Give the user a credit back for a successful factor verification.659if ($any_attempt) {660PhabricatorSystemActionEngine::willTakeAction(661array($viewer->getPHID()),662new PhabricatorAuthTryFactorAction(),663-1);664}665666if ($session->getIsPartial() && !$jump_into_hisec) {667// If we have a partial session and are not jumping directly into668// hisec, just issue a token without putting it in high security669// mode.670return $this->issueHighSecurityToken($session, true);671}672673// If we aren't upgrading the session itself, just issue a token.674if (!$upgrade_session) {675return $this->issueHighSecurityToken($session, true);676}677678$until = time() + phutil_units('15 minutes in seconds');679$session->setHighSecurityUntil($until);680681queryfx(682$session->establishConnection('w'),683'UPDATE %T SET highSecurityUntil = %d WHERE id = %d',684$session->getTableName(),685$until,686$session->getID());687688$log = PhabricatorUserLog::initializeNewLog(689$viewer,690$viewer->getPHID(),691PhabricatorEnterHisecUserLogType::LOGTYPE);692$log->save();693} else {694$log = PhabricatorUserLog::initializeNewLog(695$viewer,696$viewer->getPHID(),697PhabricatorFailHisecUserLogType::LOGTYPE);698$log->save();699}700}701}702703$token = $this->issueHighSecurityToken($session);704if ($token) {705return $token;706}707708// If we don't have a validation result for some factors yet, fill them709// in with an empty result so form rendering doesn't have to care if the710// results exist or not. This happens when you first load the form and have711// not submitted any responses yet.712foreach ($factors as $factor) {713$factor_phid = $factor->getPHID();714if (isset($validation_results[$factor_phid])) {715continue;716}717718$issued_challenges = idx($challenge_map, $factor_phid, array());719720$validation_results[$factor_phid] = $impl->getResultForPrompt(721$factor,722$viewer,723$request,724$issued_challenges);725}726727throw id(new PhabricatorAuthHighSecurityRequiredException())728->setCancelURI($cancel_uri)729->setIsSessionUpgrade($upgrade_session)730->setFactors($factors)731->setFactorValidationResults($validation_results);732}733734735/**736* Issue a high security token for a session, if authorized.737*738* @param PhabricatorAuthSession Session to issue a token for.739* @param bool Force token issue.740* @return PhabricatorAuthHighSecurityToken|null Token, if authorized.741* @task hisec742*/743private function issueHighSecurityToken(744PhabricatorAuthSession $session,745$force = false) {746747if ($session->isHighSecuritySession() || $force) {748return new PhabricatorAuthHighSecurityToken();749}750751return null;752}753754755/**756* Render a form for providing relevant multi-factor credentials.757*758* @param PhabricatorUser Viewing user.759* @param AphrontRequest Current request.760* @return AphrontFormView Renderable form.761* @task hisec762*/763public function renderHighSecurityForm(764array $factors,765array $validation_results,766PhabricatorUser $viewer,767AphrontRequest $request) {768assert_instances_of($validation_results, 'PhabricatorAuthFactorResult');769770$form = id(new AphrontFormView())771->setUser($viewer)772->appendRemarkupInstructions('');773774$answered = array();775foreach ($factors as $factor) {776$result = $validation_results[$factor->getPHID()];777778$provider = $factor->getFactorProvider();779$impl = $provider->getFactor();780781$impl->renderValidateFactorForm(782$factor,783$form,784$viewer,785$result);786787$answered_challenge = $result->getAnsweredChallenge();788if ($answered_challenge) {789$answered[] = $answered_challenge;790}791}792793$form->appendRemarkupInstructions('');794795if ($answered) {796$http_params = PhabricatorAuthChallenge::newHTTPParametersFromChallenges(797$answered);798foreach ($http_params as $key => $value) {799$form->addHiddenInput($key, $value);800}801}802803return $form;804}805806807/**808* Strip the high security flag from a session.809*810* Kicks a session out of high security and logs the exit.811*812* @param PhabricatorUser Acting user.813* @param PhabricatorAuthSession Session to return to normal security.814* @return void815* @task hisec816*/817public function exitHighSecurity(818PhabricatorUser $viewer,819PhabricatorAuthSession $session) {820821if (!$session->getHighSecurityUntil()) {822return;823}824825queryfx(826$session->establishConnection('w'),827'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d',828$session->getTableName(),829$session->getID());830831$log = PhabricatorUserLog::initializeNewLog(832$viewer,833$viewer->getPHID(),834PhabricatorExitHisecUserLogType::LOGTYPE);835$log->save();836}837838839/* -( Partial Sessions )--------------------------------------------------- */840841842/**843* Upgrade a partial session to a full session.844*845* @param PhabricatorAuthSession Session to upgrade.846* @return void847* @task partial848*/849public function upgradePartialSession(PhabricatorUser $viewer) {850851if (!$viewer->hasSession()) {852throw new Exception(853pht('Upgrading partial session of user with no session!'));854}855856$session = $viewer->getSession();857858if (!$session->getIsPartial()) {859throw new Exception(pht('Session is not partial!'));860}861862$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();863$session->setIsPartial(0);864865queryfx(866$session->establishConnection('w'),867'UPDATE %T SET isPartial = %d WHERE id = %d',868$session->getTableName(),8690,870$session->getID());871872$log = PhabricatorUserLog::initializeNewLog(873$viewer,874$viewer->getPHID(),875PhabricatorFullLoginUserLogType::LOGTYPE);876$log->save();877unset($unguarded);878}879880881/* -( Legalpad Documents )-------------------------------------------------- */882883884/**885* Upgrade a session to have all legalpad documents signed.886*887* @param PhabricatorUser User whose session should upgrade.888* @param array LegalpadDocument objects889* @return void890* @task partial891*/892public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) {893894if (!$viewer->hasSession()) {895throw new Exception(896pht('Signing session legalpad documents of user with no session!'));897}898899$session = $viewer->getSession();900901if ($session->getSignedLegalpadDocuments()) {902throw new Exception(pht(903'Session has already signed required legalpad documents!'));904}905906$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();907$session->setSignedLegalpadDocuments(1);908909queryfx(910$session->establishConnection('w'),911'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d',912$session->getTableName(),9131,914$session->getID());915916if (!empty($docs)) {917$log = PhabricatorUserLog::initializeNewLog(918$viewer,919$viewer->getPHID(),920PhabricatorSignDocumentsUserLogType::LOGTYPE);921$log->save();922}923unset($unguarded);924}925926927/* -( One Time Login URIs )------------------------------------------------ */928929930/**931* Retrieve a temporary, one-time URI which can log in to an account.932*933* These URIs are used for password recovery and to regain access to accounts934* which users have been locked out of.935*936* @param PhabricatorUser User to generate a URI for.937* @param PhabricatorUserEmail Optionally, email to verify when938* link is used.939* @param string Optional context string for the URI. This is purely cosmetic940* and used only to customize workflow and error messages.941* @param bool True to generate a URI which forces an immediate upgrade to942* a full session, bypassing MFA and other login checks.943* @return string Login URI.944* @task onetime945*/946public function getOneTimeLoginURI(947PhabricatorUser $user,948PhabricatorUserEmail $email = null,949$type = self::ONETIME_RESET,950$force_full_session = false) {951952$key = Filesystem::readRandomCharacters(32);953$key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);954$onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;955956$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();957$token = id(new PhabricatorAuthTemporaryToken())958->setTokenResource($user->getPHID())959->setTokenType($onetime_type)960->setTokenExpires(time() + phutil_units('1 day in seconds'))961->setTokenCode($key_hash)962->setShouldForceFullSession($force_full_session)963->save();964unset($unguarded);965966$uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/';967if ($email) {968$uri = $uri.$email->getID().'/';969}970971try {972$uri = PhabricatorEnv::getProductionURI($uri);973} catch (Exception $ex) {974// If a user runs `bin/auth recover` before configuring the base URI,975// just show the path. We don't have any way to figure out the domain.976// See T4132.977}978979return $uri;980}981982983/**984* Load the temporary token associated with a given one-time login key.985*986* @param PhabricatorUser User to load the token for.987* @param PhabricatorUserEmail Optionally, email to verify when988* link is used.989* @param string Key user is presenting as a valid one-time login key.990* @return PhabricatorAuthTemporaryToken|null Token, if one exists.991* @task onetime992*/993public function loadOneTimeLoginKey(994PhabricatorUser $user,995PhabricatorUserEmail $email = null,996$key = null) {997998$key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);999$onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;10001001return id(new PhabricatorAuthTemporaryTokenQuery())1002->setViewer($user)1003->withTokenResources(array($user->getPHID()))1004->withTokenTypes(array($onetime_type))1005->withTokenCodes(array($key_hash))1006->withExpired(false)1007->executeOne();1008}100910101011/**1012* Hash a one-time login key for storage as a temporary token.1013*1014* @param PhabricatorUser User this key is for.1015* @param PhabricatorUserEmail Optionally, email to verify when1016* link is used.1017* @param string The one time login key.1018* @return string Hash of the key.1019* task onetime1020*/1021private function getOneTimeLoginKeyHash(1022PhabricatorUser $user,1023PhabricatorUserEmail $email = null,1024$key = null) {10251026$parts = array(1027$key,1028$user->getAccountSecret(),1029);10301031if ($email) {1032$parts[] = $email->getVerificationCode();1033}10341035return PhabricatorHash::weakDigest(implode(':', $parts));1036}103710381039/* -( User Cache )--------------------------------------------------------- */104010411042/**1043* @task cache1044*/1045private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) {1046$cache_selects = array();1047$cache_joins = array();1048$cache_map = array();10491050$keys = array();1051$types_map = array();10521053$cache_types = PhabricatorUserCacheType::getAllCacheTypes();1054foreach ($cache_types as $cache_type) {1055foreach ($cache_type->getAutoloadKeys() as $autoload_key) {1056$keys[] = $autoload_key;1057$types_map[$autoload_key] = $cache_type;1058}1059}10601061$cache_table = id(new PhabricatorUserCache())->getTableName();10621063$cache_idx = 1;1064foreach ($keys as $key) {1065$join_as = 'ucache_'.$cache_idx;1066$select_as = 'ucache_'.$cache_idx.'_v';10671068$cache_selects[] = qsprintf(1069$conn,1070'%T.cacheData %T',1071$join_as,1072$select_as);10731074$cache_joins[] = qsprintf(1075$conn,1076'LEFT JOIN %T AS %T ON u.phid = %T.userPHID1077AND %T.cacheIndex = %s',1078$cache_table,1079$join_as,1080$join_as,1081$join_as,1082PhabricatorHash::digestForIndex($key));10831084$cache_map[$select_as] = $key;10851086$cache_idx++;1087}10881089if ($cache_selects) {1090$cache_selects = qsprintf($conn, ', %LQ', $cache_selects);1091} else {1092$cache_selects = qsprintf($conn, '');1093}10941095if ($cache_joins) {1096$cache_joins = qsprintf($conn, '%LJ', $cache_joins);1097} else {1098$cache_joins = qsprintf($conn, '');1099}11001101return array($cache_selects, $cache_joins, $cache_map, $types_map);1102}11031104private function filterRawCacheData(1105PhabricatorUser $user,1106array $types_map,1107array $cache_raw) {11081109foreach ($cache_raw as $cache_key => $cache_data) {1110$type = $types_map[$cache_key];1111if ($type->shouldValidateRawCacheData()) {1112if (!$type->isRawCacheDataValid($user, $cache_key, $cache_data)) {1113unset($cache_raw[$cache_key]);1114}1115}1116}11171118return $cache_raw;1119}11201121public function willServeRequestForUser(PhabricatorUser $user) {1122// We allow the login user to generate any missing cache data inline.1123$user->setAllowInlineCacheGeneration(true);11241125// Switch to the user's translation.1126PhabricatorEnv::setLocaleCode($user->getTranslation());11271128$extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();1129foreach ($extensions as $extension) {1130$extension->willServeRequestForUser($user);1131}1132}11331134private function extendSession(PhabricatorAuthSession $session) {1135$is_partial = $session->getIsPartial();11361137// Don't extend partial sessions. You have a relatively short window to1138// upgrade into a full session, and your session expires otherwise.1139if ($is_partial) {1140return;1141}11421143$session_type = $session->getType();11441145$ttl = PhabricatorAuthSession::getSessionTypeTTL(1146$session_type,1147$session->getIsPartial());11481149// If more than 20% of the time on this session has been used, refresh the1150// TTL back up to the full duration. The idea here is that sessions are1151// good forever if used regularly, but get GC'd when they fall out of use.11521153$now = PhabricatorTime::getNow();1154if ($now + (0.80 * $ttl) <= $session->getSessionExpires()) {1155return;1156}11571158$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();1159queryfx(1160$session->establishConnection('w'),1161'UPDATE %R SET sessionExpires = UNIX_TIMESTAMP() + %d1162WHERE id = %d',1163$session,1164$ttl,1165$session->getID());1166unset($unguarded);1167}116811691170}117111721173