diff --git a/resources/sql/autopatches/20181214.auth.01.workflowkey.sql b/resources/sql/autopatches/20181214.auth.01.workflowkey.sql new file mode 100644 index 0000000000..538778e218 --- /dev/null +++ b/resources/sql/autopatches/20181214.auth.01.workflowkey.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_challenge + ADD workflowKey VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT}; diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php index acd16f690f..f3814b949d 100644 --- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php +++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php @@ -1,1016 +1,1040 @@ workflowKey = $workflow_key; + return $this; + } + + public function getWorkflowKey() { + + // TODO: A workflow key should become required in order to issue an MFA + // challenge, but allow things to keep working for now until we can update + // callsites. + if ($this->workflowKey === null) { + return 'legacy'; + } + + return $this->workflowKey; + } + + /** * Get the session kind (e.g., anonymous, user, external account) from a * session token. Returns a `KIND_` constant. * * @param string Session token. * @return const Session kind constant. */ public static function getSessionKindFromToken($session_token) { if (strpos($session_token, '/') === false) { // Old-style session, these are all user sessions. return self::KIND_USER; } list($kind, $key) = explode('/', $session_token, 2); switch ($kind) { case self::KIND_ANONYMOUS: case self::KIND_USER: case self::KIND_EXTERNAL: return $kind; default: return self::KIND_UNKNOWN; } } /** * Load the user identity associated with a session of a given type, * identified by token. * * When the user presents a session token to an API, this method verifies * it is of the correct type and loads the corresponding identity if the * session exists and is valid. * * NOTE: `$session_type` is the type of session that is required by the * loading context. This prevents use of a Conduit sesssion as a Web * session, for example. * * @param const The type of session to load. * @param string The session token. * @return PhabricatorUser|null * @task use */ public function loadUserForSession($session_type, $session_token) { $session_kind = self::getSessionKindFromToken($session_token); switch ($session_kind) { case self::KIND_ANONYMOUS: // Don't bother trying to load a user for an anonymous session, since // neither the session nor the user exist. return null; case self::KIND_UNKNOWN: // If we don't know what kind of session this is, don't go looking for // it. return null; case self::KIND_USER: break; case self::KIND_EXTERNAL: // TODO: Implement these (T4310). return null; } $session_table = new PhabricatorAuthSession(); $user_table = new PhabricatorUser(); $conn = $session_table->establishConnection('r'); // TODO: See T13225. We're moving sessions to a more modern digest // algorithm, but still accept older cookies for compatibility. $session_key = PhabricatorAuthSession::newSessionDigest( new PhutilOpaqueEnvelope($session_token)); $weak_key = PhabricatorHash::weakDigest($session_token); $cache_parts = $this->getUserCacheQueryParts($conn); list($cache_selects, $cache_joins, $cache_map, $types_map) = $cache_parts; $info = queryfx_one( $conn, 'SELECT s.id AS s_id, s.phid AS s_phid, s.sessionExpires AS s_sessionExpires, s.sessionStart AS s_sessionStart, s.highSecurityUntil AS s_highSecurityUntil, s.isPartial AS s_isPartial, s.signedLegalpadDocuments as s_signedLegalpadDocuments, IF(s.sessionKey = %P, 1, 0) as s_weak, u.* %Q FROM %R u JOIN %R s ON u.phid = s.userPHID AND s.type = %s AND s.sessionKey IN (%P, %P) %Q', new PhutilOpaqueEnvelope($weak_key), $cache_selects, $user_table, $session_table, $session_type, new PhutilOpaqueEnvelope($session_key), new PhutilOpaqueEnvelope($weak_key), $cache_joins); if (!$info) { return null; } // TODO: Remove this, see T13225. $is_weak = (bool)$info['s_weak']; unset($info['s_weak']); $session_dict = array( 'userPHID' => $info['phid'], 'sessionKey' => $session_key, 'type' => $session_type, ); $cache_raw = array_fill_keys($cache_map, null); foreach ($info as $key => $value) { if (strncmp($key, 's_', 2) === 0) { unset($info[$key]); $session_dict[substr($key, 2)] = $value; continue; } if (isset($cache_map[$key])) { unset($info[$key]); $cache_raw[$cache_map[$key]] = $value; continue; } } $user = $user_table->loadFromArray($info); $cache_raw = $this->filterRawCacheData($user, $types_map, $cache_raw); $user->attachRawCacheData($cache_raw); switch ($session_type) { case PhabricatorAuthSession::TYPE_WEB: // Explicitly prevent bots and mailing lists from establishing web // sessions. It's normally impossible to attach authentication to these // accounts, and likewise impossible to generate sessions, but it's // technically possible that a session could exist in the database. If // one does somehow, refuse to load it. if (!$user->canEstablishWebSessions()) { return null; } break; } $session = id(new PhabricatorAuthSession())->loadFromArray($session_dict); $ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type); // If more than 20% of the time on this session has been used, refresh the // TTL back up to the full duration. The idea here is that sessions are // good forever if used regularly, but get GC'd when they fall out of use. // NOTE: If we begin rotating session keys when extending sessions, the // CSRF code needs to be updated so CSRF tokens survive session rotation. if (time() + (0.80 * $ttl) > $session->getSessionExpires()) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $conn_w = $session_table->establishConnection('w'); queryfx( $conn_w, 'UPDATE %T SET sessionExpires = UNIX_TIMESTAMP() + %d WHERE id = %d', $session->getTableName(), $ttl, $session->getID()); unset($unguarded); } // TODO: Remove this, see T13225. if ($is_weak) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $conn_w = $session_table->establishConnection('w'); queryfx( $conn_w, 'UPDATE %T SET sessionKey = %P WHERE id = %d', $session->getTableName(), new PhutilOpaqueEnvelope($session_key), $session->getID()); unset($unguarded); } $user->attachSession($session); return $user; } /** * Issue a new session key for a given identity. Phabricator supports * different types of sessions (like "web" and "conduit") and each session * type may have multiple concurrent sessions (this allows a user to be * logged in on multiple browsers at the same time, for instance). * * Note that this method is transport-agnostic and does not set cookies or * issue other types of tokens, it ONLY generates a new session key. * * You can configure the maximum number of concurrent sessions for various * session types in the Phabricator configuration. * * @param const Session type constant (see * @{class:PhabricatorAuthSession}). * @param phid|null Identity to establish a session for, usually a user * PHID. With `null`, generates an anonymous session. * @param bool True to issue a partial session. * @return string Newly generated session key. */ public function establishSession($session_type, $identity_phid, $partial) { // Consume entropy to generate a new session key, forestalling the eventual // heat death of the universe. $session_key = Filesystem::readRandomCharacters(40); if ($identity_phid === null) { return self::KIND_ANONYMOUS.'/'.$session_key; } $session_table = new PhabricatorAuthSession(); $conn_w = $session_table->establishConnection('w'); // This has a side effect of validating the session type. $session_ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type); $digest_key = PhabricatorAuthSession::newSessionDigest( new PhutilOpaqueEnvelope($session_key)); // Logging-in users don't have CSRF stuff yet, so we have to unguard this // write. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); id(new PhabricatorAuthSession()) ->setUserPHID($identity_phid) ->setType($session_type) ->setSessionKey($digest_key) ->setSessionStart(time()) ->setSessionExpires(time() + $session_ttl) ->setIsPartial($partial ? 1 : 0) ->setSignedLegalpadDocuments(0) ->save(); $log = PhabricatorUserLog::initializeNewLog( null, $identity_phid, ($partial ? PhabricatorUserLog::ACTION_LOGIN_PARTIAL : PhabricatorUserLog::ACTION_LOGIN)); $log->setDetails( array( 'session_type' => $session_type, )); $log->setSession($digest_key); $log->save(); unset($unguarded); $info = id(new PhabricatorAuthSessionInfo()) ->setSessionType($session_type) ->setIdentityPHID($identity_phid) ->setIsPartial($partial); $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions(); foreach ($extensions as $extension) { $extension->didEstablishSession($info); } return $session_key; } /** * Terminate all of a user's login sessions. * * This is used when users change passwords, linked accounts, or add * multifactor authentication. * * @param PhabricatorUser User whose sessions should be terminated. * @param string|null Optionally, one session to keep. Normally, the current * login session. * * @return void */ public function terminateLoginSessions( PhabricatorUser $user, PhutilOpaqueEnvelope $except_session = null) { $sessions = id(new PhabricatorAuthSessionQuery()) ->setViewer($user) ->withIdentityPHIDs(array($user->getPHID())) ->execute(); if ($except_session !== null) { $except_session = PhabricatorAuthSession::newSessionDigest( $except_session); } foreach ($sessions as $key => $session) { if ($except_session !== null) { $is_except = phutil_hashes_are_identical( $session->getSessionKey(), $except_session); if ($is_except) { continue; } } $session->delete(); } } public function logoutSession( PhabricatorUser $user, PhabricatorAuthSession $session) { $log = PhabricatorUserLog::initializeNewLog( $user, $user->getPHID(), PhabricatorUserLog::ACTION_LOGOUT); $log->save(); $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions(); foreach ($extensions as $extension) { $extension->didLogout($user, array($session)); } $session->delete(); } /* -( High Security )------------------------------------------------------ */ /** * Require the user respond to a high security (MFA) check. * * This method differs from @{method:requireHighSecuritySession} in that it * does not upgrade the user's session as a side effect. This method is * appropriate for one-time checks. * * @param PhabricatorUser User whose session needs to be in high security. * @param AphrontReqeust Current request. * @param string URI to return the user to if they cancel. * @return PhabricatorAuthHighSecurityToken Security token. * @task hisec */ public function requireHighSecurityToken( PhabricatorUser $viewer, AphrontRequest $request, $cancel_uri) { return $this->newHighSecurityToken( $viewer, $request, $cancel_uri, false, false); } /** * Require high security, or prompt the user to enter high security. * * If the user's session is in high security, this method will return a * token. Otherwise, it will throw an exception which will eventually * be converted into a multi-factor authentication workflow. * * This method upgrades the user's session to high security for a short * period of time, and is appropriate if you anticipate they may need to * take multiple high security actions. To perform a one-time check instead, * use @{method:requireHighSecurityToken}. * * @param PhabricatorUser User whose session needs to be in high security. * @param AphrontReqeust Current request. * @param string URI to return the user to if they cancel. * @param bool True to jump partial sessions directly into high * security instead of just upgrading them to full * sessions. * @return PhabricatorAuthHighSecurityToken Security token. * @task hisec */ public function requireHighSecuritySession( PhabricatorUser $viewer, AphrontRequest $request, $cancel_uri, $jump_into_hisec = false) { return $this->newHighSecurityToken( $viewer, $request, $cancel_uri, false, true); } private function newHighSecurityToken( PhabricatorUser $viewer, AphrontRequest $request, $cancel_uri, $jump_into_hisec, $upgrade_session) { if (!$viewer->hasSession()) { throw new Exception( pht('Requiring a high-security session from a user with no session!')); } // TODO: If a user answers a "requireHighSecurityToken()" prompt and hits // a "requireHighSecuritySession()" prompt a short time later, the one-shot // token should be good enough to upgrade the session. $session = $viewer->getSession(); // Check if the session is already in high security mode. $token = $this->issueHighSecurityToken($session); if ($token) { return $token; } // Load the multi-factor auth sources attached to this account. $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $viewer->getPHID()); // If the account has no associated multi-factor auth, just issue a token // without putting the session into high security mode. This is generally // easier for users. A minor but desirable side effect is that when a user // adds an auth factor, existing sessions won't get a free pass into hisec, // since they never actually got marked as hisec. if (!$factors) { return $this->issueHighSecurityToken($session, true); } + foreach ($factors as $factor) { + $factor->setSessionEngine($this); + } + // Check for a rate limit without awarding points, so the user doesn't // get partway through the workflow only to get blocked. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthTryFactorAction(), 0); $now = PhabricatorTime::getNow(); // We need to do challenge validation first, since this happens whether you // submitted responses or not. You can't get a "bad response" error before // you actually submit a response, but you can get a "wait, we can't // issue a challenge yet" response. Load all issued challenges which are // currently valid. $challenges = id(new PhabricatorAuthChallengeQuery()) ->setViewer($viewer) ->withFactorPHIDs(mpull($factors, 'getPHID')) ->withUserPHIDs(array($viewer->getPHID())) ->withChallengeTTLBetween($now, null) ->execute(); $challenge_map = mgroup($challenges, 'getFactorPHID'); $validation_results = array(); $ok = true; // Validate each factor against issued challenges. For example, this // prevents you from receiving or responding to a TOTP challenge if another // challenge was recently issued to a different session. foreach ($factors as $factor) { $factor_phid = $factor->getPHID(); $issued_challenges = idx($challenge_map, $factor_phid, array()); $impl = $factor->requireImplementation(); $new_challenges = $impl->getNewIssuedChallenges( $factor, $viewer, $issued_challenges); foreach ($new_challenges as $new_challenge) { $issued_challenges[] = $new_challenge; } $challenge_map[$factor_phid] = $issued_challenges; if (!$issued_challenges) { continue; } $result = $impl->getResultFromIssuedChallenges( $factor, $viewer, $issued_challenges); if (!$result) { continue; } $ok = false; $validation_results[$factor_phid] = $result; } if ($request->isHTTPPost()) { $request->validateCSRF(); if ($request->getExists(AphrontRequest::TYPE_HISEC)) { // Limit factor verification rates to prevent brute force attacks. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthTryFactorAction(), 1); foreach ($factors as $factor) { $factor_phid = $factor->getPHID(); // If we already have a validation result from previously issued // challenges, skip validating this factor. if (isset($validation_results[$factor_phid])) { continue; } $impl = $factor->requireImplementation(); $validation_result = $impl->getResultFromChallengeResponse( $factor, $viewer, $request, $issued_challenges); if (!$validation_result->getIsValid()) { $ok = false; } $validation_results[$factor_phid] = $validation_result; } if ($ok) { // Give the user a credit back for a successful factor verification. PhabricatorSystemActionEngine::willTakeAction( array($viewer->getPHID()), new PhabricatorAuthTryFactorAction(), -1); if ($session->getIsPartial() && !$jump_into_hisec) { // If we have a partial session and are not jumping directly into // hisec, just issue a token without putting it in high security // mode. return $this->issueHighSecurityToken($session, true); } // If we aren't upgrading the session itself, just issue a token. if (!$upgrade_session) { return $this->issueHighSecurityToken($session, true); } $until = time() + phutil_units('15 minutes in seconds'); $session->setHighSecurityUntil($until); queryfx( $session->establishConnection('w'), 'UPDATE %T SET highSecurityUntil = %d WHERE id = %d', $session->getTableName(), $until, $session->getID()); $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_ENTER_HISEC); $log->save(); } else { $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_FAIL_HISEC); $log->save(); } } } $token = $this->issueHighSecurityToken($session); if ($token) { return $token; } // If we don't have a validation result for some factors yet, fill them // in with an empty result so form rendering doesn't have to care if the // results exist or not. This happens when you first load the form and have // not submitted any responses yet. foreach ($factors as $factor) { $factor_phid = $factor->getPHID(); if (isset($validation_results[$factor_phid])) { continue; } $validation_results[$factor_phid] = new PhabricatorAuthFactorResult(); } throw id(new PhabricatorAuthHighSecurityRequiredException()) ->setCancelURI($cancel_uri) ->setFactors($factors) ->setFactorValidationResults($validation_results); } /** * Issue a high security token for a session, if authorized. * * @param PhabricatorAuthSession Session to issue a token for. * @param bool Force token issue. * @return PhabricatorAuthHighSecurityToken|null Token, if authorized. * @task hisec */ private function issueHighSecurityToken( PhabricatorAuthSession $session, $force = false) { if ($session->isHighSecuritySession() || $force) { return new PhabricatorAuthHighSecurityToken(); } return null; } /** * Render a form for providing relevant multi-factor credentials. * * @param PhabricatorUser Viewing user. * @param AphrontRequest Current request. * @return AphrontFormView Renderable form. * @task hisec */ public function renderHighSecurityForm( array $factors, array $validation_results, PhabricatorUser $viewer, AphrontRequest $request) { assert_instances_of($validation_results, 'PhabricatorAuthFactorResult'); $form = id(new AphrontFormView()) ->setUser($viewer) ->appendRemarkupInstructions(''); foreach ($factors as $factor) { $result = $validation_results[$factor->getPHID()]; $factor->requireImplementation()->renderValidateFactorForm( $factor, $form, $viewer, $result); } $form->appendRemarkupInstructions(''); return $form; } /** * Strip the high security flag from a session. * * Kicks a session out of high security and logs the exit. * * @param PhabricatorUser Acting user. * @param PhabricatorAuthSession Session to return to normal security. * @return void * @task hisec */ public function exitHighSecurity( PhabricatorUser $viewer, PhabricatorAuthSession $session) { if (!$session->getHighSecurityUntil()) { return; } queryfx( $session->establishConnection('w'), 'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d', $session->getTableName(), $session->getID()); $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_EXIT_HISEC); $log->save(); } /* -( Partial Sessions )--------------------------------------------------- */ /** * Upgrade a partial session to a full session. * * @param PhabricatorAuthSession Session to upgrade. * @return void * @task partial */ public function upgradePartialSession(PhabricatorUser $viewer) { if (!$viewer->hasSession()) { throw new Exception( pht('Upgrading partial session of user with no session!')); } $session = $viewer->getSession(); if (!$session->getIsPartial()) { throw new Exception(pht('Session is not partial!')); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $session->setIsPartial(0); queryfx( $session->establishConnection('w'), 'UPDATE %T SET isPartial = %d WHERE id = %d', $session->getTableName(), 0, $session->getID()); $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_LOGIN_FULL); $log->save(); unset($unguarded); } /* -( Legalpad Documents )-------------------------------------------------- */ /** * Upgrade a session to have all legalpad documents signed. * * @param PhabricatorUser User whose session should upgrade. * @param array LegalpadDocument objects * @return void * @task partial */ public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) { if (!$viewer->hasSession()) { throw new Exception( pht('Signing session legalpad documents of user with no session!')); } $session = $viewer->getSession(); if ($session->getSignedLegalpadDocuments()) { throw new Exception(pht( 'Session has already signed required legalpad documents!')); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $session->setSignedLegalpadDocuments(1); queryfx( $session->establishConnection('w'), 'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d', $session->getTableName(), 1, $session->getID()); if (!empty($docs)) { $log = PhabricatorUserLog::initializeNewLog( $viewer, $viewer->getPHID(), PhabricatorUserLog::ACTION_LOGIN_LEGALPAD); $log->save(); } unset($unguarded); } /* -( One Time Login URIs )------------------------------------------------ */ /** * Retrieve a temporary, one-time URI which can log in to an account. * * These URIs are used for password recovery and to regain access to accounts * which users have been locked out of. * * @param PhabricatorUser User to generate a URI for. * @param PhabricatorUserEmail Optionally, email to verify when * link is used. * @param string Optional context string for the URI. This is purely cosmetic * and used only to customize workflow and error messages. * @return string Login URI. * @task onetime */ public function getOneTimeLoginURI( PhabricatorUser $user, PhabricatorUserEmail $email = null, $type = self::ONETIME_RESET) { $key = Filesystem::readRandomCharacters(32); $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key); $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE; $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); id(new PhabricatorAuthTemporaryToken()) ->setTokenResource($user->getPHID()) ->setTokenType($onetime_type) ->setTokenExpires(time() + phutil_units('1 day in seconds')) ->setTokenCode($key_hash) ->save(); unset($unguarded); $uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/'; if ($email) { $uri = $uri.$email->getID().'/'; } try { $uri = PhabricatorEnv::getProductionURI($uri); } catch (Exception $ex) { // If a user runs `bin/auth recover` before configuring the base URI, // just show the path. We don't have any way to figure out the domain. // See T4132. } return $uri; } /** * Load the temporary token associated with a given one-time login key. * * @param PhabricatorUser User to load the token for. * @param PhabricatorUserEmail Optionally, email to verify when * link is used. * @param string Key user is presenting as a valid one-time login key. * @return PhabricatorAuthTemporaryToken|null Token, if one exists. * @task onetime */ public function loadOneTimeLoginKey( PhabricatorUser $user, PhabricatorUserEmail $email = null, $key = null) { $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key); $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE; return id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($onetime_type)) ->withTokenCodes(array($key_hash)) ->withExpired(false) ->executeOne(); } /** * Hash a one-time login key for storage as a temporary token. * * @param PhabricatorUser User this key is for. * @param PhabricatorUserEmail Optionally, email to verify when * link is used. * @param string The one time login key. * @return string Hash of the key. * task onetime */ private function getOneTimeLoginKeyHash( PhabricatorUser $user, PhabricatorUserEmail $email = null, $key = null) { $parts = array( $key, $user->getAccountSecret(), ); if ($email) { $parts[] = $email->getVerificationCode(); } return PhabricatorHash::weakDigest(implode(':', $parts)); } /* -( User Cache )--------------------------------------------------------- */ /** * @task cache */ private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) { $cache_selects = array(); $cache_joins = array(); $cache_map = array(); $keys = array(); $types_map = array(); $cache_types = PhabricatorUserCacheType::getAllCacheTypes(); foreach ($cache_types as $cache_type) { foreach ($cache_type->getAutoloadKeys() as $autoload_key) { $keys[] = $autoload_key; $types_map[$autoload_key] = $cache_type; } } $cache_table = id(new PhabricatorUserCache())->getTableName(); $cache_idx = 1; foreach ($keys as $key) { $join_as = 'ucache_'.$cache_idx; $select_as = 'ucache_'.$cache_idx.'_v'; $cache_selects[] = qsprintf( $conn, '%T.cacheData %T', $join_as, $select_as); $cache_joins[] = qsprintf( $conn, 'LEFT JOIN %T AS %T ON u.phid = %T.userPHID AND %T.cacheIndex = %s', $cache_table, $join_as, $join_as, $join_as, PhabricatorHash::digestForIndex($key)); $cache_map[$select_as] = $key; $cache_idx++; } if ($cache_selects) { $cache_selects = qsprintf($conn, ', %LQ', $cache_selects); } else { $cache_selects = qsprintf($conn, ''); } if ($cache_joins) { $cache_joins = qsprintf($conn, '%LJ', $cache_joins); } else { $cache_joins = qsprintf($conn, ''); } return array($cache_selects, $cache_joins, $cache_map, $types_map); } private function filterRawCacheData( PhabricatorUser $user, array $types_map, array $cache_raw) { foreach ($cache_raw as $cache_key => $cache_data) { $type = $types_map[$cache_key]; if ($type->shouldValidateRawCacheData()) { if (!$type->isRawCacheDataValid($user, $cache_key, $cache_data)) { unset($cache_raw[$cache_key]); } } } return $cache_raw; } public function willServeRequestForUser(PhabricatorUser $user) { // We allow the login user to generate any missing cache data inline. $user->setAllowInlineCacheGeneration(true); // Switch to the user's translation. PhabricatorEnv::setLocaleCode($user->getTranslation()); $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions(); foreach ($extensions as $extension) { $extension->willServeRequestForUser($user); } } } diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index 2b8ec486e2..be99df9c79 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -1,165 +1,168 @@ getID().'.'.$name; } public static function getAllFactors() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getFactorKey') ->execute(); } protected function newConfigForUser(PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfig()) ->setUserPHID($user->getPHID()) ->setFactorKey($this->getFactorKey()); } protected function newResult() { return new PhabricatorAuthFactorResult(); } protected function newChallenge( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer) { + $engine = $config->getSessionEngine(); + return id(new PhabricatorAuthChallenge()) ->setUserPHID($viewer->getPHID()) ->setSessionPHID($viewer->getSession()->getPHID()) - ->setFactorPHID($config->getPHID()); + ->setFactorPHID($config->getPHID()) + ->setWorkflowKey($engine->getWorkflowKey()); } final public function getNewIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $now = PhabricatorTime::getNow(); $new_challenges = $this->newIssuedChallenges( $config, $viewer, $challenges); assert_instances_of($new_challenges, 'PhabricatorAuthChallenge'); foreach ($new_challenges as $new_challenge) { $ttl = $new_challenge->getChallengeTTL(); if (!$ttl) { throw new Exception( pht('Newly issued MFA challenges must have a valid TTL!')); } if ($ttl < $now) { throw new Exception( pht( 'Newly issued MFA challenges must have a future TTL. This '. 'factor issued a bad TTL ("%s"). (Did you use a relative '. 'time instead of an epoch?)', $ttl)); } } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); foreach ($new_challenges as $challenge) { $challenge->save(); } unset($unguarded); return $new_challenges; } abstract protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromIssuedChallenges( $config, $viewer, $challenges); if ($result === null) { return $result; } if (!($result instanceof PhabricatorAuthFactorResult)) { throw new Exception( pht( 'Expected "newResultFromIssuedChallenges()" to return null or '. 'an object of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } $result->setIssuedChallenges($challenges); return $result; } abstract protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromChallengeResponse( $config, $viewer, $request, $challenges); if (!($result instanceof PhabricatorAuthFactorResult)) { throw new Exception( pht( 'Expected "newResultFromChallengeResponse()" to return an object '. 'of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } $result->setIssuedChallenges($challenges); return $result; } abstract protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges); } diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php index 7f426d0138..373745e244 100644 --- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php @@ -1,417 +1,431 @@ getStr('totpkey'); if (strlen($key)) { // If the user is providing a key, make sure it's a key we generated. // This raises the barrier to theoretical attacks where an attacker might // provide a known key (such attacks are already prevented by CSRF, but // this is a second barrier to overcome). // (We store and verify the hash of the key, not the key itself, to limit // how useful the data in the table is to an attacker.) $token_code = PhabricatorHash::digestWithNamedKey( $key, self::DIGEST_TEMPORARY_KEY); $temporary_token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($totp_token_type)) ->withExpired(false) ->withTokenCodes(array($token_code)) ->executeOne(); if (!$temporary_token) { // If we don't have a matching token, regenerate the key below. $key = null; } } if (!strlen($key)) { $key = self::generateNewTOTPKey(); // Mark this key as one we generated, so the user is allowed to submit // a response for it. $token_code = PhabricatorHash::digestWithNamedKey( $key, self::DIGEST_TEMPORARY_KEY); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); id(new PhabricatorAuthTemporaryToken()) ->setTokenResource($user->getPHID()) ->setTokenType($totp_token_type) ->setTokenExpires(time() + phutil_units('1 hour in seconds')) ->setTokenCode($token_code) ->save(); unset($unguarded); } $code = $request->getStr('totpcode'); $e_code = true; if ($request->getExists('totp')) { $okay = $this->verifyTOTPCode( $user, new PhutilOpaqueEnvelope($key), $code); if ($okay) { $config = $this->newConfigForUser($user) ->setFactorName(pht('Mobile App (TOTP)')) ->setFactorSecret($key); return $config; } else { if (!strlen($code)) { $e_code = pht('Required'); } else { $e_code = pht('Invalid'); } } } $form->addHiddenInput('totp', true); $form->addHiddenInput('totpkey', $key); $form->appendRemarkupInstructions( pht( 'First, download an authenticator application on your phone. Two '. 'applications which work well are **Authy** and **Google '. 'Authenticator**, but any other TOTP application should also work.')); $form->appendInstructions( pht( 'Launch the application on your phone, and add a new entry for '. 'this Phabricator install. When prompted, scan the QR code or '. 'manually enter the key shown below into the application.')); $prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/')); $issuer = $prod_uri->getDomain(); $uri = urisprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s', $issuer, $user->getUsername(), $key, $issuer); $qrcode = $this->renderQRCode($uri); $form->appendChild($qrcode); $form->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('Key')) ->setValue(phutil_tag('strong', array(), $key))); $form->appendInstructions( pht( '(If given an option, select that this key is "Time Based", not '. '"Counter Based".)')); $form->appendInstructions( pht( 'After entering the key, the application should display a numeric '. 'code. Enter that code below to confirm that you have configured '. 'the authenticator correctly:')); $form->appendChild( id(new PHUIFormNumberControl()) ->setLabel(pht('TOTP Code')) ->setName('totpcode') ->setValue($code) ->setError($e_code)); } protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $now = $this->getCurrentTimestep(); // If we already issued a valid challenge, don't issue a new one. if ($challenges) { return array(); } // Otherwise, generate a new challenge for the current timestep. It TTLs // after it would fall off the bottom of the window. $timesteps = $this->getAllowedTimesteps(); $min_step = min($timesteps); $step_duration = $this->getTimestepDuration(); $ttl_steps = ($now - $min_step) + 1; $ttl_seconds = ($ttl_steps * $step_duration); return array( $this->newChallenge($config, $viewer) ->setChallengeKey($now) ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), ); } public function renderValidateFactorForm( PhabricatorAuthFactorConfig $config, AphrontFormView $form, PhabricatorUser $viewer, PhabricatorAuthFactorResult $result) { $value = $result->getValue(); $error = $result->getErrorMessage(); $is_wait = $result->getIsWait(); if ($is_wait) { $control = id(new AphrontFormMarkupControl()) ->setValue($error) ->setError(pht('Wait')); } else { $control = id(new PHUIFormNumberControl()) ->setName($this->getParameterName($config, 'totpcode')) ->setDisableAutocomplete(true) ->setValue($value) ->setError($error); } $control ->setLabel(pht('App Code')) ->setCaption(pht('Factor Name: %s', $config->getFactorName())); $form->appendChild($control); } protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { // If we've already issued a challenge at the current timestep or any // nearby timestep, require that it was issued to the current session. // This is defusing attacks where you (broadly) look at someone's phone // and type the code in more quickly than they do. $step_duration = $this->getTimestepDuration(); $now = $this->getCurrentTimestep(); $timesteps = $this->getAllowedTimesteps(); $timesteps = array_fuse($timesteps); $min_step = min($timesteps); $session_phid = $viewer->getSession()->getPHID(); + $engine = $config->getSessionEngine(); + $workflow_key = $engine->getWorkflowKey(); + foreach ($challenges as $challenge) { $challenge_timestep = (int)$challenge->getChallengeKey(); // This challenge isn't for one of the timesteps you'd be able to respond // to if you submitted the form right now, so we're good to keep going. if (!isset($timesteps[$challenge_timestep])) { continue; } // This is the number of timesteps you need to wait for the problem // timestep to leave the window, rounded up. $wait_steps = ($challenge_timestep - $min_step) + 1; $wait_duration = ($wait_steps * $step_duration); if ($challenge->getSessionPHID() !== $session_phid) { return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'This factor recently issued a challenge to a different login '. 'session. Wait %s seconds for the code to cycle, then try '. 'again.', new PhutilNumber($wait_duration))); } + + if ($challenge->getWorkflowKey() !== $workflow_key) { + return $this->newResult() + ->setIsWait(true) + ->setErrorMessage( + pht( + 'This factor recently issued a challenge for a different '. + 'workflow. Wait %s seconds for the code to cycle, then try '. + 'again.', + new PhutilNumber($wait_duration))); + } } return null; } protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { $code = $request->getStr($this->getParameterName($config, 'totpcode')); $key = new PhutilOpaqueEnvelope($config->getFactorSecret()); $result = $this->newResult() ->setValue($code); if ($this->verifyTOTPCode($viewer, $key, (string)$code)) { $result->setIsValid(true); } else { if (strlen($code)) { $error_message = pht('Invalid'); } else { $error_message = pht('Required'); } $result->setErrorMessage($error_message); } return $result; } public static function generateNewTOTPKey() { return strtoupper(Filesystem::readRandomCharacters(32)); } private function verifyTOTPCode( PhabricatorUser $user, PhutilOpaqueEnvelope $key, $code) { $now = (int)(time() / 30); // Allow the user to enter a code a few minutes away on either side, in // case the server or client has some clock skew. for ($offset = -2; $offset <= 2; $offset++) { $real = self::getTOTPCode($key, $now + $offset); if (phutil_hashes_are_identical($real, $code)) { return true; } } // TODO: After validating a code, this should mark it as used and prevent // it from being reused. return false; } public static function base32Decode($buf) { $buf = strtoupper($buf); $map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $map = str_split($map); $map = array_flip($map); $out = ''; $len = strlen($buf); $acc = 0; $bits = 0; for ($ii = 0; $ii < $len; $ii++) { $chr = $buf[$ii]; $val = $map[$chr]; $acc = $acc << 5; $acc = $acc + $val; $bits += 5; if ($bits >= 8) { $bits = $bits - 8; $out .= chr(($acc & (0xFF << $bits)) >> $bits); } } return $out; } public static function getTOTPCode(PhutilOpaqueEnvelope $key, $timestamp) { $binary_timestamp = pack('N*', 0).pack('N*', $timestamp); $binary_key = self::base32Decode($key->openEnvelope()); $hash = hash_hmac('sha1', $binary_timestamp, $binary_key, true); // See RFC 4226. $offset = ord($hash[19]) & 0x0F; $code = ((ord($hash[$offset + 0]) & 0x7F) << 24) | ((ord($hash[$offset + 1]) & 0xFF) << 16) | ((ord($hash[$offset + 2]) & 0xFF) << 8) | ((ord($hash[$offset + 3]) ) ); $code = ($code % 1000000); $code = str_pad($code, 6, '0', STR_PAD_LEFT); return $code; } /** * @phutil-external-symbol class QRcode */ private function renderQRCode($uri) { $root = dirname(phutil_get_library_root('phabricator')); require_once $root.'/externals/phpqrcode/phpqrcode.php'; $lines = QRcode::text($uri); $total_width = 240; $cell_size = floor($total_width / count($lines)); $rows = array(); foreach ($lines as $line) { $cells = array(); for ($ii = 0; $ii < strlen($line); $ii++) { if ($line[$ii] == '1') { $color = '#000'; } else { $color = '#fff'; } $cells[] = phutil_tag( 'td', array( 'width' => $cell_size, 'height' => $cell_size, 'style' => 'background: '.$color, ), ''); } $rows[] = phutil_tag('tr', array(), $cells); } return phutil_tag( 'table', array( 'style' => 'margin: 24px auto;', ), $rows); } private function getTimestepDuration() { return 30; } private function getCurrentTimestep() { $duration = $this->getTimestepDuration(); return (int)(PhabricatorTime::getNow() / $duration); } private function getAllowedTimesteps() { $now = $this->getCurrentTimestep(); return range($now - 2, $now + 2); } } diff --git a/src/applications/auth/storage/PhabricatorAuthChallenge.php b/src/applications/auth/storage/PhabricatorAuthChallenge.php index 4ef2a7054f..63d2092e49 100644 --- a/src/applications/auth/storage/PhabricatorAuthChallenge.php +++ b/src/applications/auth/storage/PhabricatorAuthChallenge.php @@ -1,57 +1,59 @@ array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'challengeKey' => 'text255', 'challengeTTL' => 'epoch', + 'workflowKey' => 'text255', ), self::CONFIG_KEY_SCHEMA => array( 'key_issued' => array( 'columns' => array('userPHID', 'challengeTTL'), ), 'key_collection' => array( 'columns' => array('challengeTTL'), ), ), ) + parent::getConfiguration(); } public function getPHIDType() { return PhabricatorAuthChallengePHIDType::TYPECONST; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return PhabricatorPolicies::POLICY_NOONE; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return ($viewer->getPHID() === $this->getUserPHID()); } } diff --git a/src/applications/auth/storage/PhabricatorAuthFactorConfig.php b/src/applications/auth/storage/PhabricatorAuthFactorConfig.php index 8420ea9ba7..2bed939402 100644 --- a/src/applications/auth/storage/PhabricatorAuthFactorConfig.php +++ b/src/applications/auth/storage/PhabricatorAuthFactorConfig.php @@ -1,52 +1,67 @@ array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'factorKey' => 'text64', 'factorName' => 'text', 'factorSecret' => 'text', ), self::CONFIG_KEY_SCHEMA => array( 'key_user' => array( 'columns' => array('userPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorAuthAuthFactorPHIDType::TYPECONST); } public function getImplementation() { return idx(PhabricatorAuthFactor::getAllFactors(), $this->getFactorKey()); } public function requireImplementation() { $impl = $this->getImplementation(); if (!$impl) { throw new Exception( pht( 'Attempting to operate on multi-factor auth which has no '. 'corresponding implementation (factor key is "%s").', $this->getFactorKey())); } return $impl; } + public function setSessionEngine(PhabricatorAuthSessionEngine $engine) { + $this->sessionEngine = $engine; + return $this; + } + + public function getSessionEngine() { + if (!$this->sessionEngine) { + throw new PhutilInvalidStateException('setSessionEngine'); + } + + return $this->sessionEngine; + } + } diff --git a/src/applications/legalpad/controller/LegalpadDocumentSignController.php b/src/applications/legalpad/controller/LegalpadDocumentSignController.php index 8ef35e3493..27769432c2 100644 --- a/src/applications/legalpad/controller/LegalpadDocumentSignController.php +++ b/src/applications/legalpad/controller/LegalpadDocumentSignController.php @@ -1,702 +1,707 @@ getUser(); $document = id(new LegalpadDocumentQuery()) ->setViewer($viewer) ->withIDs(array($request->getURIData('id'))) ->needDocumentBodies(true) ->executeOne(); if (!$document) { return new Aphront404Response(); } $information = $this->readSignerInformation( $document, $request); if ($information instanceof AphrontResponse) { return $information; } list($signer_phid, $signature_data) = $information; $signature = null; $type_individual = LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL; $is_individual = ($document->getSignatureType() == $type_individual); switch ($document->getSignatureType()) { case LegalpadDocument::SIGNATURE_TYPE_NONE: // nothing to sign means this should be true $has_signed = true; // this is a status UI element $signed_status = null; break; case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: if ($signer_phid) { // TODO: This is odd and should probably be adjusted after // grey/external accounts work better, but use the omnipotent // viewer to check for a signature so we can pick up // anonymous/grey signatures. $signature = id(new LegalpadDocumentSignatureQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withDocumentPHIDs(array($document->getPHID())) ->withSignerPHIDs(array($signer_phid)) ->executeOne(); if ($signature && !$viewer->isLoggedIn()) { return $this->newDialog() ->setTitle(pht('Already Signed')) ->appendParagraph(pht('You have already signed this document!')) ->addCancelButton('/'.$document->getMonogram(), pht('Okay')); } } $signed_status = null; if (!$signature) { $has_signed = false; $signature = id(new LegalpadDocumentSignature()) ->setSignerPHID($signer_phid) ->setDocumentPHID($document->getPHID()) ->setDocumentVersion($document->getVersions()); // If the user is logged in, show a notice that they haven't signed. // If they aren't logged in, we can't be as sure, so don't show // anything. if ($viewer->isLoggedIn()) { $signed_status = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht('You have not signed this document yet.'), )); } } else { $has_signed = true; $signature_data = $signature->getSignatureData(); // In this case, we know they've signed. $signed_at = $signature->getDateCreated(); if ($signature->getIsExemption()) { $exemption_phid = $signature->getExemptionPHID(); $handles = $this->loadViewerHandles(array($exemption_phid)); $exemption_handle = $handles[$exemption_phid]; $signed_text = pht( 'You do not need to sign this document. '. '%s added a signature exemption for you on %s.', $exemption_handle->renderLink(), phabricator_datetime($signed_at, $viewer)); } else { $signed_text = pht( 'You signed this document on %s.', phabricator_datetime($signed_at, $viewer)); } $signed_status = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) ->setErrors(array($signed_text)); } $field_errors = array( 'name' => true, 'email' => true, 'agree' => true, ); $signature->setSignatureData($signature_data); break; case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $signature = id(new LegalpadDocumentSignature()) ->setDocumentPHID($document->getPHID()) ->setDocumentVersion($document->getVersions()); if ($viewer->isLoggedIn()) { $has_signed = false; $signed_status = null; } else { // This just hides the form. $has_signed = true; $login_text = pht( 'This document requires a corporate signatory. You must log in to '. 'accept this document on behalf of a company you represent.'); $signed_status = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors(array($login_text)); } $field_errors = array( 'name' => true, 'address' => true, 'contact.name' => true, 'email' => true, ); $signature->setSignatureData($signature_data); break; } $errors = array(); $hisec_token = null; if ($request->isFormOrHisecPost() && !$has_signed) { // Require two-factor auth to sign legal documents. if ($viewer->isLoggedIn()) { + $workflow_key = sprintf( + 'legalpad.sign(%s)', + $document->getPHID()); + $hisec_token = id(new PhabricatorAuthSessionEngine()) + ->setWorkflowKey($workflow_key) ->requireHighSecurityToken( $viewer, $request, $document->getURI()); } list($form_data, $errors, $field_errors) = $this->readSignatureForm( $document, $request); $signature_data = $form_data + $signature_data; $signature->setSignatureData($signature_data); $signature->setSignatureType($document->getSignatureType()); $signature->setSignerName((string)idx($signature_data, 'name')); $signature->setSignerEmail((string)idx($signature_data, 'email')); $agree = $request->getExists('agree'); if (!$agree) { $errors[] = pht( 'You must check "I agree to the terms laid forth above."'); $field_errors['agree'] = pht('Required'); } if ($viewer->isLoggedIn() && $is_individual) { $verified = LegalpadDocumentSignature::VERIFIED; } else { $verified = LegalpadDocumentSignature::UNVERIFIED; } $signature->setVerified($verified); if (!$errors) { $signature->save(); // If the viewer is logged in, signing for themselves, send them to // the document page, which will show that they have signed the // document. Unless of course they were required to sign the // document to use Phabricator; in that case try really hard to // re-direct them to where they wanted to go. // // Otherwise, send them to a completion page. if ($viewer->isLoggedIn() && $is_individual) { $next_uri = '/'.$document->getMonogram(); if ($document->getRequireSignature()) { $request_uri = $request->getRequestURI(); $next_uri = (string)$request_uri; } } else { $this->sendVerifySignatureEmail( $document, $signature); $next_uri = $this->getApplicationURI('done/'); } return id(new AphrontRedirectResponse())->setURI($next_uri); } } $document_body = $document->getDocumentBody(); $engine = id(new PhabricatorMarkupEngine()) ->setViewer($viewer); $engine->addObject( $document_body, LegalpadDocumentBody::MARKUP_FIELD_TEXT); $engine->process(); $document_markup = $engine->getOutput( $document_body, LegalpadDocumentBody::MARKUP_FIELD_TEXT); $title = $document_body->getTitle(); $manage_uri = $this->getApplicationURI('view/'.$document->getID().'/'); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $document, PhabricatorPolicyCapability::CAN_EDIT); // Use the last content update as the modified date. We don't want to // show that a document like a TOS was "updated" by an incidental change // to a field like the preamble or privacy settings which does not actually // affect the content of the agreement. $content_updated = $document_body->getDateCreated(); // NOTE: We're avoiding `setPolicyObject()` here so we don't pick up // extra UI elements that are unnecessary and clutter the signature page. // These details are available on the "Manage" page. $header = id(new PHUIHeaderView()) ->setHeader($title) ->setUser($viewer) ->setEpoch($content_updated) ->addActionLink( id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-pencil') ->setText(pht('Manage')) ->setHref($manage_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $preamble_box = null; if (strlen($document->getPreamble())) { $preamble_text = new PHUIRemarkupView($viewer, $document->getPreamble()); // NOTE: We're avoiding `setObject()` here so we don't pick up extra UI // elements like "Subscribers". This information is available on the // "Manage" page, but just clutters up the "Signature" page. $preamble = id(new PHUIPropertyListView()) ->setUser($viewer) ->addSectionHeader(pht('Preamble')) ->addTextContent($preamble_text); $preamble_box = new PHUIPropertyGroupView(); $preamble_box->addPropertyList($preamble); } $content = id(new PHUIDocumentView()) ->addClass('legalpad') ->setHeader($header) ->appendChild( array( $signed_status, $preamble_box, $document_markup, )); $signature_box = null; if (!$has_signed) { $error_view = null; if ($errors) { $error_view = id(new PHUIInfoView()) ->setErrors($errors); } $signature_form = $this->buildSignatureForm( $document, $signature, $field_errors); switch ($document->getSignatureType()) { default: break; case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $box = id(new PHUIObjectBoxView()) ->addClass('document-sign-box') ->setHeaderText(pht('Agree and Sign Document')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setForm($signature_form); if ($error_view) { $box->setInfoView($error_view); } $signature_box = phutil_tag_div( 'phui-document-view-pro-box plt', $box); break; } } $crumbs = $this->buildApplicationCrumbs(); $crumbs->setBorder(true); $crumbs->addTextCrumb($document->getMonogram()); $box = id(new PHUITwoColumnView()) ->setFooter($signature_box); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->setPageObjectPHIDs(array($document->getPHID())) ->appendChild(array( $content, $box, )); } private function readSignerInformation( LegalpadDocument $document, AphrontRequest $request) { $viewer = $request->getUser(); $signer_phid = null; $signature_data = array(); switch ($document->getSignatureType()) { case LegalpadDocument::SIGNATURE_TYPE_NONE: break; case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: if ($viewer->isLoggedIn()) { $signer_phid = $viewer->getPHID(); $signature_data = array( 'name' => $viewer->getRealName(), 'email' => $viewer->loadPrimaryEmailAddress(), ); } else if ($request->isFormPost()) { $email = new PhutilEmailAddress($request->getStr('email')); if (strlen($email->getDomainName())) { $email_obj = id(new PhabricatorUserEmail()) ->loadOneWhere('address = %s', $email->getAddress()); if ($email_obj) { return $this->signInResponse(); } $external_account = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withAccountTypes(array('email')) ->withAccountDomains(array($email->getDomainName())) ->withAccountIDs(array($email->getAddress())) ->loadOneOrCreate(); if ($external_account->getUserPHID()) { return $this->signInResponse(); } $signer_phid = $external_account->getPHID(); } } break; case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $signer_phid = $viewer->getPHID(); if ($signer_phid) { $signature_data = array( 'contact.name' => $viewer->getRealName(), 'email' => $viewer->loadPrimaryEmailAddress(), 'actorPHID' => $viewer->getPHID(), ); } break; } return array($signer_phid, $signature_data); } private function buildSignatureForm( LegalpadDocument $document, LegalpadDocumentSignature $signature, array $errors) { $viewer = $this->getRequest()->getUser(); $data = $signature->getSignatureData(); $form = id(new AphrontFormView()) ->setUser($viewer); $signature_type = $document->getSignatureType(); switch ($signature_type) { case LegalpadDocument::SIGNATURE_TYPE_NONE: // bail out of here quick return; case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: $this->buildIndividualSignatureForm( $form, $document, $signature, $errors); break; case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $this->buildCorporateSignatureForm( $form, $document, $signature, $errors); break; default: throw new Exception( pht( 'This document has an unknown signature type ("%s").', $signature_type)); } $form ->appendChild( id(new AphrontFormCheckboxControl()) ->setError(idx($errors, 'agree', null)) ->addCheckbox( 'agree', 'agree', pht('I agree to the terms laid forth above.'), false)); if ($document->getRequireSignature()) { $cancel_uri = '/logout/'; $cancel_text = pht('Log Out'); } else { $cancel_uri = $this->getApplicationURI(); $cancel_text = pht('Cancel'); } $form ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Sign Document')) ->addCancelButton($cancel_uri, $cancel_text)); return $form; } private function buildIndividualSignatureForm( AphrontFormView $form, LegalpadDocument $document, LegalpadDocumentSignature $signature, array $errors) { $data = $signature->getSignatureData(); $form ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Name')) ->setValue(idx($data, 'name', '')) ->setName('name') ->setError(idx($errors, 'name', null))); $viewer = $this->getRequest()->getUser(); if (!$viewer->isLoggedIn()) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Email')) ->setValue(idx($data, 'email', '')) ->setName('email') ->setError(idx($errors, 'email', null))); } return $form; } private function buildCorporateSignatureForm( AphrontFormView $form, LegalpadDocument $document, LegalpadDocumentSignature $signature, array $errors) { $data = $signature->getSignatureData(); $form ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Company Name')) ->setValue(idx($data, 'name', '')) ->setName('name') ->setError(idx($errors, 'name', null))) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel(pht('Company Address')) ->setValue(idx($data, 'address', '')) ->setName('address') ->setError(idx($errors, 'address', null))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Contact Name')) ->setValue(idx($data, 'contact.name', '')) ->setName('contact.name') ->setError(idx($errors, 'contact.name', null))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Contact Email')) ->setValue(idx($data, 'email', '')) ->setName('email') ->setError(idx($errors, 'email', null))); return $form; } private function readSignatureForm( LegalpadDocument $document, AphrontRequest $request) { $signature_type = $document->getSignatureType(); switch ($signature_type) { case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL: $result = $this->readIndividualSignatureForm( $document, $request); break; case LegalpadDocument::SIGNATURE_TYPE_CORPORATION: $result = $this->readCorporateSignatureForm( $document, $request); break; default: throw new Exception( pht( 'This document has an unknown signature type ("%s").', $signature_type)); } return $result; } private function readIndividualSignatureForm( LegalpadDocument $document, AphrontRequest $request) { $signature_data = array(); $errors = array(); $field_errors = array(); $name = $request->getStr('name'); if (!strlen($name)) { $field_errors['name'] = pht('Required'); $errors[] = pht('Name field is required.'); } else { $field_errors['name'] = null; } $signature_data['name'] = $name; $viewer = $request->getUser(); if ($viewer->isLoggedIn()) { $email = $viewer->loadPrimaryEmailAddress(); } else { $email = $request->getStr('email'); $addr_obj = null; if (!strlen($email)) { $field_errors['email'] = pht('Required'); $errors[] = pht('Email field is required.'); } else { $addr_obj = new PhutilEmailAddress($email); $domain = $addr_obj->getDomainName(); if (!$domain) { $field_errors['email'] = pht('Invalid'); $errors[] = pht('A valid email is required.'); } else { $field_errors['email'] = null; } } } $signature_data['email'] = $email; return array($signature_data, $errors, $field_errors); } private function readCorporateSignatureForm( LegalpadDocument $document, AphrontRequest $request) { $viewer = $request->getUser(); if (!$viewer->isLoggedIn()) { throw new Exception( pht( 'You can not sign a document on behalf of a corporation unless '. 'you are logged in.')); } $signature_data = array(); $errors = array(); $field_errors = array(); $name = $request->getStr('name'); if (!strlen($name)) { $field_errors['name'] = pht('Required'); $errors[] = pht('Company name is required.'); } else { $field_errors['name'] = null; } $signature_data['name'] = $name; $address = $request->getStr('address'); if (!strlen($address)) { $field_errors['address'] = pht('Required'); $errors[] = pht('Company address is required.'); } else { $field_errors['address'] = null; } $signature_data['address'] = $address; $contact_name = $request->getStr('contact.name'); if (!strlen($contact_name)) { $field_errors['contact.name'] = pht('Required'); $errors[] = pht('Contact name is required.'); } else { $field_errors['contact.name'] = null; } $signature_data['contact.name'] = $contact_name; $email = $request->getStr('email'); $addr_obj = null; if (!strlen($email)) { $field_errors['email'] = pht('Required'); $errors[] = pht('Contact email is required.'); } else { $addr_obj = new PhutilEmailAddress($email); $domain = $addr_obj->getDomainName(); if (!$domain) { $field_errors['email'] = pht('Invalid'); $errors[] = pht('A valid email is required.'); } else { $field_errors['email'] = null; } } $signature_data['email'] = $email; return array($signature_data, $errors, $field_errors); } private function sendVerifySignatureEmail( LegalpadDocument $doc, LegalpadDocumentSignature $signature) { $signature_data = $signature->getSignatureData(); $email = new PhutilEmailAddress($signature_data['email']); $doc_name = $doc->getTitle(); $doc_link = PhabricatorEnv::getProductionURI('/'.$doc->getMonogram()); $path = $this->getApplicationURI(sprintf( '/verify/%s/', $signature->getSecretKey())); $link = PhabricatorEnv::getProductionURI($path); $name = idx($signature_data, 'name'); $body = pht( "%s:\n\n". "This email address was used to sign a Legalpad document ". "in Phabricator:\n\n". " %s\n\n". "Please verify you own this email address and accept the ". "agreement by clicking this link:\n\n". " %s\n\n". "Your signature is not valid until you complete this ". "verification step.\n\nYou can review the document here:\n\n". " %s\n", $name, $doc_name, $link, $doc_link); id(new PhabricatorMetaMTAMail()) ->addRawTos(array($email->getAddress())) ->setSubject(pht('[Legalpad] Signature Verification')) ->setForceDelivery(true) ->setBody($body) ->setRelatedPHID($signature->getDocumentPHID()) ->saveAndSend(); } private function signInResponse() { return id(new Aphront403Response()) ->setForbiddenText( pht( 'The email address specified is associated with an account. '. 'Please login to that account and sign this document again.')); } }