Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2983636
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Advanced/Developer...
View Handle
View Hovercard
Size
62 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/resources/sql/autopatches/20181217.auth.01.digest.sql b/resources/sql/autopatches/20181217.auth.01.digest.sql
new file mode 100644
index 0000000000..8e30143e8f
--- /dev/null
+++ b/resources/sql/autopatches/20181217.auth.01.digest.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_challenge
+ ADD responseDigest VARCHAR(255) COLLATE {$COLLATE_TEXT};
diff --git a/resources/sql/autopatches/20181217.auth.02.ttl.sql b/resources/sql/autopatches/20181217.auth.02.ttl.sql
new file mode 100644
index 0000000000..c8e883dbea
--- /dev/null
+++ b/resources/sql/autopatches/20181217.auth.02.ttl.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_challenge
+ ADD responseTTL INT UNSIGNED;
diff --git a/resources/sql/autopatches/20181217.auth.03.completed.sql b/resources/sql/autopatches/20181217.auth.03.completed.sql
new file mode 100644
index 0000000000..22ca6e21ff
--- /dev/null
+++ b/resources/sql/autopatches/20181217.auth.03.completed.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_challenge
+ ADD isCompleted BOOL NOT NULL;
diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
index f3814b949d..74183f4ffa 100644
--- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
+++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
@@ -1,1040 +1,1052 @@
<?php
/**
*
* @task use Using Sessions
* @task new Creating Sessions
* @task hisec High Security
* @task partial Partial Sessions
* @task onetime One Time Login URIs
* @task cache User Cache
*/
final class PhabricatorAuthSessionEngine extends Phobject {
/**
* Session issued to normal users after they login through a standard channel.
* Associates the client with a standard user identity.
*/
const KIND_USER = 'U';
/**
* Session issued to users who login with some sort of credentials but do not
* have full accounts. These are sometimes called "grey users".
*
* TODO: We do not currently issue these sessions, see T4310.
*/
const KIND_EXTERNAL = 'X';
/**
* Session issued to logged-out users which has no real identity information.
* Its purpose is to protect logged-out users from CSRF.
*/
const KIND_ANONYMOUS = 'A';
/**
* Session kind isn't known.
*/
const KIND_UNKNOWN = '?';
const ONETIME_RECOVER = 'recover';
const ONETIME_RESET = 'reset';
const ONETIME_WELCOME = 'welcome';
const ONETIME_USERNAME = 'rename';
private $workflowKey;
public function setWorkflowKey($workflow_key) {
$this->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;
}
+ $issued_challenges = idx($challenge_map, $factor_phid, array());
+
$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) {
+ // We're letting you through, so mark all the challenges you
+ // responded to as completed. These challenges can never be used
+ // again, even by the same session and workflow: you can't use the
+ // same response to take two different actions, even if those actions
+ // are of the same type.
+ foreach ($validation_results as $validation_result) {
+ $challenge = $validation_result->getAnsweredChallenge()
+ ->markChallengeAsCompleted();
+ }
+
// 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 be99df9c79..8cd61f089d 100644
--- a/src/applications/auth/factor/PhabricatorAuthFactor.php
+++ b/src/applications/auth/factor/PhabricatorAuthFactor.php
@@ -1,168 +1,168 @@
<?php
abstract class PhabricatorAuthFactor extends Phobject {
abstract public function getFactorName();
abstract public function getFactorKey();
abstract public function getFactorDescription();
abstract public function processAddFactorForm(
AphrontFormView $form,
AphrontRequest $request,
PhabricatorUser $user);
abstract public function renderValidateFactorForm(
PhabricatorAuthFactorConfig $config,
AphrontFormView $form,
PhabricatorUser $viewer,
PhabricatorAuthFactorResult $validation_result);
public function getParameterName(
PhabricatorAuthFactorConfig $config,
$name) {
return 'authfactor.'.$config->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())
+ return PhabricatorAuthChallenge::initializeNewChallenge()
->setUserPHID($viewer->getPHID())
->setSessionPHID($viewer->getSession()->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/PhabricatorAuthFactorResult.php b/src/applications/auth/factor/PhabricatorAuthFactorResult.php
index d75480747d..faa25b4f42 100644
--- a/src/applications/auth/factor/PhabricatorAuthFactorResult.php
+++ b/src/applications/auth/factor/PhabricatorAuthFactorResult.php
@@ -1,58 +1,75 @@
<?php
final class PhabricatorAuthFactorResult
extends Phobject {
- private $isValid = false;
+ private $answeredChallenge;
private $isWait = false;
private $errorMessage;
private $value;
private $issuedChallenges = array();
- public function setIsValid($is_valid) {
- $this->isValid = $is_valid;
+ public function setAnsweredChallenge(PhabricatorAuthChallenge $challenge) {
+ if (!$challenge->getIsAnsweredChallenge()) {
+ throw new PhutilInvalidStateException('markChallengeAsAnswered');
+ }
+
+ if ($challenge->getIsCompleted()) {
+ throw new Exception(
+ pht(
+ 'A completed challenge was provided as an answered challenge. '.
+ 'The underlying factor is implemented improperly, challenges '.
+ 'may not be reused.'));
+ }
+
+ $this->answeredChallenge = $challenge;
+
return $this;
}
+ public function getAnsweredChallenge() {
+ return $this->answeredChallenge;
+ }
+
public function getIsValid() {
- return $this->isValid;
+ return (bool)$this->getAnsweredChallenge();
}
public function setIsWait($is_wait) {
$this->isWait = $is_wait;
return $this;
}
public function getIsWait() {
return $this->isWait;
}
public function setErrorMessage($error_message) {
$this->errorMessage = $error_message;
return $this;
}
public function getErrorMessage() {
return $this->errorMessage;
}
public function setValue($value) {
$this->value = $value;
return $this;
}
public function getValue() {
return $this->value;
}
public function setIssuedChallenges(array $issued_challenges) {
assert_instances_of($issued_challenges, 'PhabricatorAuthChallenge');
$this->issuedChallenges = $issued_challenges;
return $this;
}
public function getIssuedChallenges() {
return $this->issuedChallenges;
}
}
diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
index e2f0dc74b3..0984347197 100644
--- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
+++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
@@ -1,476 +1,496 @@
<?php
final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
const DIGEST_TEMPORARY_KEY = 'mfa.totp.sync';
public function getFactorKey() {
return 'totp';
}
public function getFactorName() {
return pht('Mobile Phone App (TOTP)');
}
public function getFactorDescription() {
return pht(
'Attach a mobile authenticator application (like Authy '.
'or Google Authenticator) to your account. When you need to '.
'authenticate, you will enter a code shown on your phone.');
}
public function processAddFactorForm(
AphrontFormView $form,
AphrontRequest $request,
PhabricatorUser $user) {
$totp_token_type = PhabricatorAuthTOTPKeyTemporaryTokenType::TOKENTYPE;
$key = $request->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 = (bool)$this->getTimestepAtWhichResponseIsValid(
$this->getAllowedTimesteps($this->getCurrentTimestep()),
new PhutilOpaqueEnvelope($key),
(string)$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) {
$current_step = $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 and compute
// the TTL.
// When computing the TTL, note that we accept codes within a certain
// window of the challenge timestep to account for clock skew and users
// needing time to enter codes.
// We don't want this challenge to expire until after all valid responses
// to it are no longer valid responses to any other challenge we might
// issue in the future. If the challenge expires too quickly, we may issue
// a new challenge which can accept the same TOTP code response.
// This means that we need to keep this challenge alive for double the
// window size: if we're currently at timestep 3, the user might respond
// with the code for timestep 5. This is valid, since timestep 5 is within
// the window for timestep 3.
// But the code for timestep 5 can be used to respond at timesteps 3, 4, 5,
// 6, and 7. To prevent any valid response to this challenge from being
// used again, we need to keep this challenge active until timestep 8.
$window_size = $this->getTimestepWindowSize();
$step_duration = $this->getTimestepDuration();
$ttl_steps = ($window_size * 2) + 1;
$ttl_seconds = ($ttl_steps * $step_duration);
return array(
$this->newChallenge($config, $viewer)
->setChallengeKey($current_step)
->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.
$session_phid = $viewer->getSession()->getPHID();
$now = PhabricatorTime::getNow();
$engine = $config->getSessionEngine();
$workflow_key = $engine->getWorkflowKey();
$current_timestep = $this->getCurrentTimestep();
foreach ($challenges as $challenge) {
$challenge_timestep = (int)$challenge->getChallengeKey();
$wait_duration = ($challenge->getChallengeTTL() - $now) + 1;
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 second(s) 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 second(s) for the code to cycle, then try '.
'again.',
new PhutilNumber($wait_duration)));
}
// If the current realtime timestep isn't a valid response to the current
// challenge but the challenge hasn't expired yet, we're locking out
// the factor to prevent challenge windows from overlapping. Let the user
// know that they should wait for a new challenge.
$challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);
if (!isset($challenge_timesteps[$current_timestep])) {
return $this->newResult()
->setIsWait(true)
->setErrorMessage(
pht(
'This factor recently issued a challenge which has expired. '.
'A new challenge can not be issued yet. Wait %s second(s) for '.
'the code to cycle, then try again.',
new PhutilNumber($wait_duration)));
}
+
+ if ($challenge->getIsReusedChallenge()) {
+ return $this->newResult()
+ ->setIsWait(true)
+ ->setErrorMessage(
+ pht(
+ 'You recently provided a response to this factor. Responses '.
+ 'may not be reused. Wait %s second(s) 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'));
$result = $this->newResult()
->setValue($code);
// We expect to reach TOTP validation with exactly one valid challenge.
if (count($challenges) !== 1) {
throw new Exception(
pht(
'Reached TOTP challenge validation with an unexpected number of '.
'unexpired challenges (%d), expected exactly one.',
phutil_count($challenges)));
}
$challenge = head($challenges);
$challenge_timestep = (int)$challenge->getChallengeKey();
$current_timestep = $this->getCurrentTimestep();
$challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep);
$current_timesteps = $this->getAllowedTimesteps($current_timestep);
// We require responses be both valid for the challenge and valid for the
// current timestep. A longer challenge TTL doesn't let you use older
// codes for a longer period of time.
$valid_timestep = $this->getTimestepAtWhichResponseIsValid(
array_intersect_key($challenge_timesteps, $current_timesteps),
new PhutilOpaqueEnvelope($config->getFactorSecret()),
(string)$code);
if ($valid_timestep) {
- $result->setIsValid(true);
+ $now = PhabricatorTime::getNow();
+ $step_duration = $this->getTimestepDuration();
+ $step_window = $this->getTimestepWindowSize();
+ $ttl = $now + ($step_duration * $step_window);
+
+ $challenge
+ ->setProperty('totp.timestep', $valid_timestep)
+ ->markChallengeAsAnswered($ttl);
+
+ $result->setAnsweredChallenge($challenge);
} 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));
}
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($at_timestep) {
$window = $this->getTimestepWindowSize();
$range = range($at_timestep - $window, $at_timestep + $window);
return array_fuse($range);
}
private function getTimestepWindowSize() {
// The user is allowed to provide a code from the recent past or the
// near future to account for minor clock skew between the client
// and server, and the time it takes to actually enter a code.
return 2;
}
private function getTimestepAtWhichResponseIsValid(
array $timesteps,
PhutilOpaqueEnvelope $key,
$code) {
foreach ($timesteps as $timestep) {
$expect_code = self::getTOTPCode($key, $timestep);
if (phutil_hashes_are_identical($code, $expect_code)) {
return $timestep;
}
}
return null;
}
}
diff --git a/src/applications/auth/storage/PhabricatorAuthChallenge.php b/src/applications/auth/storage/PhabricatorAuthChallenge.php
index 63d2092e49..89ffd67ef7 100644
--- a/src/applications/auth/storage/PhabricatorAuthChallenge.php
+++ b/src/applications/auth/storage/PhabricatorAuthChallenge.php
@@ -1,59 +1,172 @@
<?php
final class PhabricatorAuthChallenge
extends PhabricatorAuthDAO
implements PhabricatorPolicyInterface {
protected $userPHID;
protected $factorPHID;
protected $sessionPHID;
protected $workflowKey;
protected $challengeKey;
protected $challengeTTL;
+ protected $responseDigest;
+ protected $responseTTL;
+ protected $isCompleted;
protected $properties = array();
+ private $responseToken;
+
+ const TOKEN_DIGEST_KEY = 'auth.challenge.token';
+
+ public static function initializeNewChallenge() {
+ return id(new self())
+ ->setIsCompleted(0);
+ }
+
protected function getConfiguration() {
return array(
self::CONFIG_SERIALIZATION => array(
'properties' => self::SERIALIZATION_JSON,
),
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'challengeKey' => 'text255',
'challengeTTL' => 'epoch',
'workflowKey' => 'text255',
+ 'responseDigest' => 'text255?',
+ 'responseTTL' => 'epoch?',
+ 'isCompleted' => 'bool',
),
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;
}
+ public function getIsReusedChallenge() {
+ if ($this->getIsCompleted()) {
+ return true;
+ }
+
+ // TODO: A challenge is "reused" if it has been answered previously and
+ // the request doesn't include proof that the client provided the answer.
+ // Since we aren't tracking client responses yet, any answered challenge
+ // is always a reused challenge for now.
+
+ return $this->getIsAnsweredChallenge();
+ }
+
+ public function getIsAnsweredChallenge() {
+ return (bool)$this->getResponseDigest();
+ }
+
+ public function markChallengeAsAnswered($ttl) {
+ $token = Filesystem::readRandomCharacters(32);
+ $token = new PhutilOpaqueEnvelope($token);
+
+ return $this
+ ->setResponseToken($token, $ttl)
+ ->save();
+ }
+
+ public function markChallengeAsCompleted() {
+ return $this
+ ->setIsCompleted(true)
+ ->save();
+ }
+
+ public function setResponseToken(PhutilOpaqueEnvelope $token, $ttl) {
+ if (!$this->getUserPHID()) {
+ throw new PhutilInvalidStateException('setUserPHID');
+ }
+
+ if ($this->responseToken) {
+ throw new Exception(
+ pht(
+ 'This challenge already has a response token; you can not '.
+ 'set a new response token.'));
+ }
+
+ $now = PhabricatorTime::getNow();
+ if ($ttl < $now) {
+ throw new Exception(
+ pht(
+ 'Response TTL is invalid: TTLs must be an epoch timestamp '.
+ 'coresponding to a future time (did you use a relative TTL by '.
+ 'mistake?).'));
+ }
+
+ if (preg_match('/ /', $token->openEnvelope())) {
+ throw new Exception(
+ pht(
+ 'The response token for this challenge is invalid: response '.
+ 'tokens may not include spaces.'));
+ }
+
+ $digest = PhabricatorHash::digestWithNamedKey(
+ $token->openEnvelope(),
+ self::TOKEN_DIGEST_KEY);
+
+ if ($this->responseDigest !== null) {
+ if (!phutil_hashes_are_identical($digest, $this->responseDigest)) {
+ throw new Exception(
+ pht(
+ 'Invalid response token for this challenge: token digest does '.
+ 'not match stored digest.'));
+ }
+ } else {
+ $this->responseDigest = $digest;
+ }
+
+ $this->responseToken = $token;
+ $this->responseTTL = $ttl;
+
+ return $this;
+ }
+
+ public function setResponseDigest($value) {
+ throw new Exception(
+ pht(
+ 'You can not set the response digest for a challenge directly. '.
+ 'Instead, set a response token. A response digest will be computed '.
+ 'automatically.'));
+ }
+
+ public function setProperty($key, $value) {
+ $this->properties[$key] = $value;
+ return $this;
+ }
+
+ public function getProperty($key, $default = null) {
+ return $this->properties[$key];
+ }
+
/* -( 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());
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Feb 20, 19:40 (2 d, 1 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1164606
Default Alt Text
(62 KB)
Attached To
Mode
rP Phorge
Attached
Detach File
Event Timeline
Log In to Comment