Page MenuHomestyx hydra

No OneTemporary

diff --git a/resources/sql/autopatches/20160316.lfs.01.token.resource.sql b/resources/sql/autopatches/20160316.lfs.01.token.resource.sql
new file mode 100644
index 0000000000..7be5bbda54
--- /dev/null
+++ b/resources/sql/autopatches/20160316.lfs.01.token.resource.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_temporarytoken
+ CHANGE objectPHID tokenResource VARBINARY(64) NOT NULL;
diff --git a/resources/sql/autopatches/20160316.lfs.02.token.user.sql b/resources/sql/autopatches/20160316.lfs.02.token.user.sql
new file mode 100644
index 0000000000..72174d6fe8
--- /dev/null
+++ b/resources/sql/autopatches/20160316.lfs.02.token.user.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_temporarytoken
+ ADD userPHID VARBINARY(64);
diff --git a/resources/sql/autopatches/20160316.lfs.03.token.properties.sql b/resources/sql/autopatches/20160316.lfs.03.token.properties.sql
new file mode 100644
index 0000000000..2cb4449d73
--- /dev/null
+++ b/resources/sql/autopatches/20160316.lfs.03.token.properties.sql
@@ -0,0 +1,2 @@
+ALTER TABLE {$NAMESPACE}_auth.auth_temporarytoken
+ ADD properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT};
diff --git a/resources/sql/autopatches/20160316.lfs.04.token.default.sql b/resources/sql/autopatches/20160316.lfs.04.token.default.sql
new file mode 100644
index 0000000000..0f0ce4abc4
--- /dev/null
+++ b/resources/sql/autopatches/20160316.lfs.04.token.default.sql
@@ -0,0 +1,2 @@
+UPDATE {$NAMESPACE}_auth.auth_temporarytoken
+ SET properties = '{}' WHERE properties = '';
diff --git a/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php b/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php
index 9ecea74d6a..d98879d0ed 100644
--- a/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php
+++ b/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php
@@ -1,202 +1,202 @@
<?php
final class PhabricatorAuthOneTimeLoginController
extends PhabricatorAuthController {
public function shouldRequireLogin() {
return false;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$id = $request->getURIData('id');
$link_type = $request->getURIData('type');
$key = $request->getURIData('key');
$email_id = $request->getURIData('emailID');
if ($request->getUser()->isLoggedIn()) {
return $this->renderError(
pht('You are already logged in.'));
}
$target_user = id(new PhabricatorPeopleQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withIDs(array($id))
->executeOne();
if (!$target_user) {
return new Aphront404Response();
}
// NOTE: As a convenience to users, these one-time login URIs may also
// be associated with an email address which will be verified when the
// URI is used.
// This improves the new user experience for users receiving "Welcome"
// emails on installs that require verification: if we did not verify the
// email, they'd immediately get roadblocked with a "Verify Your Email"
// error and have to go back to their email account, wait for a
// "Verification" email, and then click that link to actually get access to
// their account. This is hugely unwieldy, and if the link was only sent
// to the user's email in the first place we can safely verify it as a
// side effect of login.
// The email hashed into the URI so users can't verify some email they
// do not own by doing this:
//
// - Add some address you do not own;
// - request a password reset;
// - change the URI in the email to the address you don't own;
// - login via the email link; and
// - get a "verified" address you don't control.
$target_email = null;
if ($email_id) {
$target_email = id(new PhabricatorUserEmail())->loadOneWhere(
'userPHID = %s AND id = %d',
$target_user->getPHID(),
$email_id);
if (!$target_email) {
return new Aphront404Response();
}
}
$engine = new PhabricatorAuthSessionEngine();
$token = $engine->loadOneTimeLoginKey(
$target_user,
$target_email,
$key);
if (!$token) {
return $this->newDialog()
->setTitle(pht('Unable to Login'))
->setShortTitle(pht('Login Failure'))
->appendParagraph(
pht(
'The login link you clicked is invalid, out of date, or has '.
'already been used.'))
->appendParagraph(
pht(
'Make sure you are copy-and-pasting the entire link into '.
'your browser. Login links are only valid for 24 hours, and '.
'can only be used once.'))
->appendParagraph(
pht('You can try again, or request a new link via email.'))
->addCancelButton('/login/email/', pht('Send Another Email'));
}
if (!$target_user->canEstablishWebSessions()) {
return $this->newDialog()
->setTitle(pht('Unable to Establish Web Session'))
->setShortTitle(pht('Login Failure'))
->appendParagraph(
pht(
'You are trying to gain access to an account ("%s") that can not '.
'establish a web session.',
$target_user->getUsername()))
->appendParagraph(
pht(
'Special users like daemons and mailing lists are not permitted '.
'to log in via the web. Log in as a normal user instead.'))
->addCancelButton('/');
}
if ($request->isFormPost()) {
// If we have an email bound into this URI, verify email so that clicking
// the link in the "Welcome" email is good enough, without requiring users
// to go through a second round of email verification.
$editor = id(new PhabricatorUserEditor())
->setActor($target_user);
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
// Nuke the token and all other outstanding password reset tokens.
// There is no particular security benefit to destroying them all, but
// it should reduce HackerOne reports of nebulous harm.
$editor->revokePasswordResetLinks($target_user);
if ($target_email) {
$editor->verifyEmail($target_user, $target_email);
}
unset($unguarded);
$next = '/';
if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) {
$next = '/settings/panel/external/';
} else {
// We're going to let the user reset their password without knowing
// the old one. Generate a one-time token for that.
$key = Filesystem::readRandomCharacters(16);
$password_type =
PhabricatorAuthPasswordResetTemporaryTokenType::TOKENTYPE;
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
id(new PhabricatorAuthTemporaryToken())
- ->setObjectPHID($target_user->getPHID())
+ ->setTokenResource($target_user->getPHID())
->setTokenType($password_type)
->setTokenExpires(time() + phutil_units('1 hour in seconds'))
->setTokenCode(PhabricatorHash::digest($key))
->save();
unset($unguarded);
$next = (string)id(new PhutilURI('/settings/panel/password/'))
->setQueryParams(
array(
'key' => $key,
));
$request->setTemporaryCookie(PhabricatorCookies::COOKIE_HISEC, 'yes');
}
PhabricatorCookies::setNextURICookie($request, $next, $force = true);
return $this->loginUser($target_user);
}
// NOTE: We need to CSRF here so attackers can't generate an email link,
// then log a user in to an account they control via sneaky invisible
// form submissions.
switch ($link_type) {
case PhabricatorAuthSessionEngine::ONETIME_WELCOME:
$title = pht('Welcome to Phabricator');
break;
case PhabricatorAuthSessionEngine::ONETIME_RECOVER:
$title = pht('Account Recovery');
break;
case PhabricatorAuthSessionEngine::ONETIME_USERNAME:
case PhabricatorAuthSessionEngine::ONETIME_RESET:
default:
$title = pht('Login to Phabricator');
break;
}
$body = array();
$body[] = pht(
'Use the button below to log in as: %s',
phutil_tag('strong', array(), $target_user->getUsername()));
if ($target_email && !$target_email->getIsVerified()) {
$body[] = pht(
'Logging in will verify %s as an email address you own.',
phutil_tag('strong', array(), $target_email->getAddress()));
}
$body[] = pht(
'After logging in you should set a password for your account, or '.
'link your account to an external account that you can use to '.
'authenticate in the future.');
$dialog = $this->newDialog()
->setTitle($title)
->addSubmitButton(pht('Login (%s)', $target_user->getUsername()))
->addCancelButton('/');
foreach ($body as $paragraph) {
$dialog->appendParagraph($paragraph);
}
return id(new AphrontDialogResponse())->setDialog($dialog);
}
}
diff --git a/src/applications/auth/controller/PhabricatorAuthRevokeTokenController.php b/src/applications/auth/controller/PhabricatorAuthRevokeTokenController.php
index c1f0c21cb1..6d516916eb 100644
--- a/src/applications/auth/controller/PhabricatorAuthRevokeTokenController.php
+++ b/src/applications/auth/controller/PhabricatorAuthRevokeTokenController.php
@@ -1,72 +1,72 @@
<?php
final class PhabricatorAuthRevokeTokenController
extends PhabricatorAuthController {
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$id = $request->getURIData('id');
$is_all = ($id === 'all');
$query = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer($viewer)
- ->withObjectPHIDs(array($viewer->getPHID()));
+ ->withTokenResources(array($viewer->getPHID()));
if (!$is_all) {
$query->withIDs(array($id));
}
$tokens = $query->execute();
foreach ($tokens as $key => $token) {
if (!$token->isRevocable()) {
// Don't revoke unrevocable tokens.
unset($tokens[$key]);
}
}
$panel_uri = '/settings/panel/tokens/';
if (!$tokens) {
return $this->newDialog()
->setTitle(pht('No Matching Tokens'))
->appendParagraph(
pht('There are no matching tokens to revoke.'))
->appendParagraph(
pht(
'(Some types of token can not be revoked, and you can not revoke '.
'tokens which have already expired.)'))
->addCancelButton($panel_uri);
}
if ($request->isDialogFormPost()) {
foreach ($tokens as $token) {
$token->revokeToken();
}
return id(new AphrontRedirectResponse())->setURI($panel_uri);
}
if ($is_all) {
$title = pht('Revoke Tokens?');
$short = pht('Revoke Tokens');
$body = pht(
'Really revoke all tokens? Among other temporary authorizations, '.
'this will disable any outstanding password reset or account '.
'recovery links.');
} else {
$title = pht('Revoke Token?');
$short = pht('Revoke Token');
$body = pht(
'Really revoke this token? Any temporary authorization it enables '.
'will be disabled.');
}
return $this->newDialog()
->setTitle($title)
->setShortTitle($short)
->appendParagraph($body)
->addSubmitButton(pht('Revoke'))
->addCancelButton($panel_uri);
}
}
diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
index e6289926dd..98b6a63b5a 100644
--- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
+++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
@@ -1,717 +1,717 @@
<?php
/**
*
* @task use Using Sessions
* @task new Creating Sessions
* @task hisec High Security
* @task partial Partial Sessions
* @task onetime One Time Login URIs
*/
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';
/**
* 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_r = $session_table->establishConnection('r');
$session_key = PhabricatorHash::digest($session_token);
// NOTE: We're being clever here because this happens on every page load,
// and by joining we can save a query. This might be getting too clever
// for its own good, though...
$info = queryfx_one(
$conn_r,
'SELECT
s.id AS s_id,
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,
u.*
FROM %T u JOIN %T s ON u.phid = s.userPHID
AND s.type = %s AND s.sessionKey = %s',
$user_table->getTableName(),
$session_table->getTableName(),
$session_type,
$session_key);
if (!$info) {
return null;
}
$session_dict = array(
'userPHID' => $info['phid'],
'sessionKey' => $session_key,
'type' => $session_type,
);
foreach ($info as $key => $value) {
if (strncmp($key, 's_', 2) === 0) {
unset($info[$key]);
$session_dict[substr($key, 2)] = $value;
}
}
$user = $user_table->loadFromArray($info);
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);
}
$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 = PhabricatorHash::digest($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);
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,
$except_session = null) {
$sessions = id(new PhabricatorAuthSessionQuery())
->setViewer($user)
->withIdentityPHIDs(array($user->getPHID()))
->execute();
if ($except_session !== null) {
$except_session = PhabricatorHash::digest($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();
}
}
/* -( High Security )------------------------------------------------------ */
/**
* 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.
*
* @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) {
if (!$viewer->hasSession()) {
throw new Exception(
pht('Requiring a high-security session from a user with no 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);
}
// 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);
$validation_results = array();
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);
$ok = true;
foreach ($factors as $factor) {
$id = $factor->getID();
$impl = $factor->requireImplementation();
$validation_results[$id] = $impl->processValidateFactorForm(
$factor,
$viewer,
$request);
if (!$impl->isFactorValid($factor, $validation_results[$id])) {
$ok = false;
}
}
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);
}
$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;
}
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) {
$until = $session->getHighSecurityUntil();
if ($until > time() || $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) {
$form = id(new AphrontFormView())
->setUser($viewer)
->appendRemarkupInstructions('');
foreach ($factors as $factor) {
$factor->requireImplementation()->renderValidateFactorForm(
$factor,
$form,
$viewer,
idx($validation_results, $factor->getID()));
}
$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())
- ->setObjectPHID($user->getPHID())
+ ->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)
- ->withObjectPHIDs(array($user->getPHID()))
+ ->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::digest(implode(':', $parts));
}
}
diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
index 8c29eb7d14..418270de63 100644
--- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
+++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
@@ -1,305 +1,305 @@
<?php
final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor {
const TEMPORARY_TOKEN_TYPE = 'mfa:totp:key';
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) {
$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.)
$temporary_token = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer($user)
- ->withObjectPHIDs(array($user->getPHID()))
+ ->withTokenResources(array($user->getPHID()))
->withTokenTypes(array(self::TEMPORARY_TOKEN_TYPE))
->withExpired(false)
->withTokenCodes(array(PhabricatorHash::digest($key)))
->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.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
id(new PhabricatorAuthTemporaryToken())
- ->setObjectPHID($user->getPHID())
+ ->setTokenResource($user->getPHID())
->setTokenType(self::TEMPORARY_TOKEN_TYPE)
->setTokenExpires(time() + phutil_units('1 hour in seconds'))
->setTokenCode(PhabricatorHash::digest($key))
->save();
unset($unguarded);
}
$code = $request->getStr('totpcode');
$e_code = true;
if ($request->getExists('totp')) {
$okay = self::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 AphrontFormTextControl())
->setLabel(pht('TOTP Code'))
->setName('totpcode')
->setValue($code)
->setError($e_code));
}
public function renderValidateFactorForm(
PhabricatorAuthFactorConfig $config,
AphrontFormView $form,
PhabricatorUser $viewer,
$validation_result) {
if (!$validation_result) {
$validation_result = array();
}
$form->appendChild(
id(new AphrontFormTextControl())
->setName($this->getParameterName($config, 'totpcode'))
->setLabel(pht('App Code'))
->setCaption(pht('Factor Name: %s', $config->getFactorName()))
->setValue(idx($validation_result, 'value'))
->setError(idx($validation_result, 'error', true)));
}
public function processValidateFactorForm(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request) {
$code = $request->getStr($this->getParameterName($config, 'totpcode'));
$key = new PhutilOpaqueEnvelope($config->getFactorSecret());
if (self::verifyTOTPCode($viewer, $key, $code)) {
return array(
'error' => null,
'value' => $code,
'valid' => true,
);
} else {
return array(
'error' => strlen($code) ? pht('Invalid') : pht('Required'),
'value' => $code,
'valid' => false,
);
}
}
public static function generateNewTOTPKey() {
return strtoupper(Filesystem::readRandomCharacters(16));
}
public static 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);
}
}
diff --git a/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php b/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php
index a22707b6cc..bc9572061f 100644
--- a/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php
+++ b/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php
@@ -1,277 +1,277 @@
<?php
abstract class PhabricatorOAuth1AuthProvider
extends PhabricatorOAuthAuthProvider {
protected $adapter;
const PROPERTY_CONSUMER_KEY = 'oauth1:consumer:key';
const PROPERTY_CONSUMER_SECRET = 'oauth1:consumer:secret';
const PROPERTY_PRIVATE_KEY = 'oauth1:private:key';
const TEMPORARY_TOKEN_TYPE = 'oauth1:request:secret';
protected function getIDKey() {
return self::PROPERTY_CONSUMER_KEY;
}
protected function getSecretKey() {
return self::PROPERTY_CONSUMER_SECRET;
}
protected function configureAdapter(PhutilOAuth1AuthAdapter $adapter) {
$config = $this->getProviderConfig();
$adapter->setConsumerKey($config->getProperty(self::PROPERTY_CONSUMER_KEY));
$secret = $config->getProperty(self::PROPERTY_CONSUMER_SECRET);
if (strlen($secret)) {
$adapter->setConsumerSecret(new PhutilOpaqueEnvelope($secret));
}
$adapter->setCallbackURI(PhabricatorEnv::getURI($this->getLoginURI()));
return $adapter;
}
protected function renderLoginForm(AphrontRequest $request, $mode) {
$attributes = array(
'method' => 'POST',
'uri' => $this->getLoginURI(),
);
return $this->renderStandardLoginButton($request, $mode, $attributes);
}
public function processLoginRequest(
PhabricatorAuthLoginController $controller) {
$request = $controller->getRequest();
$adapter = $this->getAdapter();
$account = null;
$response = null;
if ($request->isHTTPPost()) {
// Add a CSRF code to the callback URI, which we'll verify when
// performing the login.
$client_code = $this->getAuthCSRFCode($request);
$callback_uri = $adapter->getCallbackURI();
$callback_uri = $callback_uri.$client_code.'/';
$adapter->setCallbackURI($callback_uri);
$uri = $adapter->getClientRedirectURI();
$this->saveHandshakeTokenSecret(
$client_code,
$adapter->getTokenSecret());
$response = id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI($uri);
return array($account, $response);
}
$denied = $request->getStr('denied');
if (strlen($denied)) {
// Twitter indicates that the user cancelled the login attempt by
// returning "denied" as a parameter.
throw new PhutilAuthUserAbortedException();
}
// NOTE: You can get here via GET, this should probably be a bit more
// user friendly.
$this->verifyAuthCSRFCode($request, $controller->getExtraURIData());
$token = $request->getStr('oauth_token');
$verifier = $request->getStr('oauth_verifier');
if (!$token) {
throw new Exception(pht("Expected '%s' in request!", 'oauth_token'));
}
if (!$verifier) {
throw new Exception(pht("Expected '%s' in request!", 'oauth_verifier'));
}
$adapter->setToken($token);
$adapter->setVerifier($verifier);
$client_code = $this->getAuthCSRFCode($request);
$token_secret = $this->loadHandshakeTokenSecret($client_code);
$adapter->setTokenSecret($token_secret);
// NOTE: As a side effect, this will cause the OAuth adapter to request
// an access token.
try {
$account_id = $adapter->getAccountID();
} catch (Exception $ex) {
// TODO: Handle this in a more user-friendly way.
throw $ex;
}
if (!strlen($account_id)) {
$response = $controller->buildProviderErrorResponse(
$this,
pht(
'The OAuth provider failed to retrieve an account ID.'));
return array($account, $response);
}
return array($this->loadOrCreateAccount($account_id), $response);
}
public function processEditForm(
AphrontRequest $request,
array $values) {
$key_ckey = self::PROPERTY_CONSUMER_KEY;
$key_csecret = self::PROPERTY_CONSUMER_SECRET;
return $this->processOAuthEditForm(
$request,
$values,
pht('Consumer key is required.'),
pht('Consumer secret is required.'));
}
public function extendEditForm(
AphrontRequest $request,
AphrontFormView $form,
array $values,
array $issues) {
return $this->extendOAuthEditForm(
$request,
$form,
$values,
$issues,
pht('OAuth Consumer Key'),
pht('OAuth Consumer Secret'));
}
public function renderConfigPropertyTransactionTitle(
PhabricatorAuthProviderConfigTransaction $xaction) {
$author_phid = $xaction->getAuthorPHID();
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$key = $xaction->getMetadataValue(
PhabricatorAuthProviderConfigTransaction::PROPERTY_KEY);
switch ($key) {
case self::PROPERTY_CONSUMER_KEY:
if (strlen($old)) {
return pht(
'%s updated the OAuth consumer key for this provider from '.
'"%s" to "%s".',
$xaction->renderHandleLink($author_phid),
$old,
$new);
} else {
return pht(
'%s set the OAuth consumer key for this provider to '.
'"%s".',
$xaction->renderHandleLink($author_phid),
$new);
}
case self::PROPERTY_CONSUMER_SECRET:
if (strlen($old)) {
return pht(
'%s updated the OAuth consumer secret for this provider.',
$xaction->renderHandleLink($author_phid));
} else {
return pht(
'%s set the OAuth consumer secret for this provider.',
$xaction->renderHandleLink($author_phid));
}
}
return parent::renderConfigPropertyTransactionTitle($xaction);
}
protected function synchronizeOAuthAccount(
PhabricatorExternalAccount $account) {
$adapter = $this->getAdapter();
$oauth_token = $adapter->getToken();
$oauth_token_secret = $adapter->getTokenSecret();
$account->setProperty('oauth1.token', $oauth_token);
$account->setProperty('oauth1.token.secret', $oauth_token_secret);
}
public function willRenderLinkedAccount(
PhabricatorUser $viewer,
PHUIObjectItemView $item,
PhabricatorExternalAccount $account) {
$item->addAttribute(pht('OAuth1 Account'));
parent::willRenderLinkedAccount($viewer, $item, $account);
}
/* -( Temporary Secrets )-------------------------------------------------- */
private function saveHandshakeTokenSecret($client_code, $secret) {
$key = $this->getHandshakeTokenKeyFromClientCode($client_code);
$type = $this->getTemporaryTokenType(self::TEMPORARY_TOKEN_TYPE);
// Wipe out an existing token, if one exists.
$token = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
- ->withObjectPHIDs(array($key))
+ ->withTokenResources(array($key))
->withTokenTypes(array($type))
->executeOne();
if ($token) {
$token->delete();
}
// Save the new secret.
id(new PhabricatorAuthTemporaryToken())
- ->setObjectPHID($key)
+ ->setTokenResource($key)
->setTokenType($type)
->setTokenExpires(time() + phutil_units('1 hour in seconds'))
->setTokenCode($secret)
->save();
}
private function loadHandshakeTokenSecret($client_code) {
$key = $this->getHandshakeTokenKeyFromClientCode($client_code);
$type = $this->getTemporaryTokenType(self::TEMPORARY_TOKEN_TYPE);
$token = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
- ->withObjectPHIDs(array($key))
+ ->withTokenResources(array($key))
->withTokenTypes(array($type))
->withExpired(false)
->executeOne();
if (!$token) {
throw new Exception(
pht(
'Unable to load your OAuth1 token secret from storage. It may '.
'have expired. Try authenticating again.'));
}
return $token->getTokenCode();
}
private function getTemporaryTokenType($core_type) {
// Namespace the type so that multiple providers don't step on each
// others' toes if a user starts Mediawiki and Bitbucket auth at the
// same time.
return $core_type.':'.$this->getProviderConfig()->getID();
}
private function getHandshakeTokenKeyFromClientCode($client_code) {
// NOTE: This is very slightly coersive since the TemporaryToken table
// expects an "objectPHID" as an identifier, but nothing about the storage
// is bound to PHIDs.
return 'oauth1:secret/'.$client_code;
}
}
diff --git a/src/applications/auth/query/PhabricatorAuthTemporaryTokenQuery.php b/src/applications/auth/query/PhabricatorAuthTemporaryTokenQuery.php
index aecd92a4a7..72141f75f0 100644
--- a/src/applications/auth/query/PhabricatorAuthTemporaryTokenQuery.php
+++ b/src/applications/auth/query/PhabricatorAuthTemporaryTokenQuery.php
@@ -1,106 +1,110 @@
<?php
final class PhabricatorAuthTemporaryTokenQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
- private $objectPHIDs;
+ private $tokenResources;
private $tokenTypes;
+ private $userPHIDs;
private $expired;
private $tokenCodes;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
- public function withObjectPHIDs(array $object_phids) {
- $this->objectPHIDs = $object_phids;
+ public function withTokenResources(array $resources) {
+ $this->tokenResources = $resources;
return $this;
}
public function withTokenTypes(array $types) {
$this->tokenTypes = $types;
return $this;
}
public function withExpired($expired) {
$this->expired = $expired;
return $this;
}
public function withTokenCodes(array $codes) {
$this->tokenCodes = $codes;
return $this;
}
+ public function withUserPHIDs(array $phids) {
+ $this->userPHIDs = $phids;
+ return $this;
+ }
+
+ public function newResultObject() {
+ return new PhabricatorAuthTemporaryToken();
+ }
+
protected function loadPage() {
- $table = new PhabricatorAuthTemporaryToken();
- $conn_r = $table->establishConnection('r');
-
- $data = queryfx_all(
- $conn_r,
- 'SELECT * FROM %T %Q %Q %Q',
- $table->getTableName(),
- $this->buildWhereClause($conn_r),
- $this->buildOrderClause($conn_r),
- $this->buildLimitClause($conn_r));
-
- return $table->loadAllFromArray($data);
+ return $this->loadStandardPage($this->newResultObject());
}
- protected function buildWhereClause(AphrontDatabaseConnection $conn_r) {
- $where = array();
+ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
+ $where = parent::buildWhereClauseParts($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
- $conn_r,
+ $conn,
'id IN (%Ld)',
$this->ids);
}
- if ($this->objectPHIDs !== null) {
+ if ($this->tokenResources !== null) {
$where[] = qsprintf(
- $conn_r,
- 'objectPHID IN (%Ls)',
- $this->objectPHIDs);
+ $conn,
+ 'tokenResource IN (%Ls)',
+ $this->tokenResources);
}
if ($this->tokenTypes !== null) {
$where[] = qsprintf(
- $conn_r,
+ $conn,
'tokenType IN (%Ls)',
$this->tokenTypes);
}
if ($this->expired !== null) {
if ($this->expired) {
$where[] = qsprintf(
- $conn_r,
+ $conn,
'tokenExpires <= %d',
time());
} else {
$where[] = qsprintf(
- $conn_r,
+ $conn,
'tokenExpires > %d',
time());
}
}
if ($this->tokenCodes !== null) {
$where[] = qsprintf(
- $conn_r,
+ $conn,
'tokenCode IN (%Ls)',
$this->tokenCodes);
}
- $where[] = $this->buildPagingClause($conn_r);
+ if ($this->userPHIDs !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'userPHID IN (%Ls)',
+ $this->userPHIDs);
+ }
- return $this->formatWhereClause($where);
+ return $where;
}
public function getQueryApplicationClass() {
return 'PhabricatorAuthApplication';
}
}
diff --git a/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php b/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php
index 93e491bdb0..be08f5db2d 100644
--- a/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php
+++ b/src/applications/auth/storage/PhabricatorAuthTemporaryToken.php
@@ -1,114 +1,132 @@
<?php
final class PhabricatorAuthTemporaryToken extends PhabricatorAuthDAO
implements PhabricatorPolicyInterface {
- // TODO: OAuth1 stores a client identifier here, which is not a real PHID.
- // At some point, we should rename this column to be a little more generic.
- protected $objectPHID;
-
+ // NOTE: This is usually a PHID, but may be some other kind of resource
+ // identifier for some token types.
+ protected $tokenResource;
protected $tokenType;
protected $tokenExpires;
protected $tokenCode;
+ protected $userPHID;
+ protected $properties;
protected function getConfiguration() {
return array(
self::CONFIG_TIMESTAMPS => false,
+ self::CONFIG_SERIALIZATION => array(
+ 'properties' => self::SERIALIZATION_JSON,
+ ),
self::CONFIG_COLUMN_SCHEMA => array(
+ 'tokenResource' => 'phid',
'tokenType' => 'text64',
'tokenExpires' => 'epoch',
'tokenCode' => 'text64',
+ 'userPHID' => 'phid?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_token' => array(
- 'columns' => array('objectPHID', 'tokenType', 'tokenCode'),
+ 'columns' => array('tokenResource', 'tokenType', 'tokenCode'),
'unique' => true,
),
'key_expires' => array(
'columns' => array('tokenExpires'),
),
+ 'key_user' => array(
+ 'columns' => array('userPHID'),
+ ),
),
) + parent::getConfiguration();
}
private function newTokenTypeImplementation() {
$types = PhabricatorAuthTemporaryTokenType::getAllTypes();
$type = idx($types, $this->tokenType);
if ($type) {
return clone $type;
}
return null;
}
public function getTokenReadableTypeName() {
$type = $this->newTokenTypeImplementation();
if ($type) {
return $type->getTokenReadableTypeName($this);
}
return $this->tokenType;
}
public function isRevocable() {
if ($this->tokenExpires < time()) {
return false;
}
$type = $this->newTokenTypeImplementation();
if ($type) {
return $type->isTokenRevocable($this);
}
return false;
}
public function revokeToken() {
if ($this->isRevocable()) {
$this->setTokenExpires(PhabricatorTime::getNow() - 1)->save();
}
return $this;
}
public static function revokeTokens(
PhabricatorUser $viewer,
- array $object_phids,
+ array $token_resources,
array $token_types) {
$tokens = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer($viewer)
- ->withObjectPHIDs($object_phids)
+ ->withTokenResources($token_resources)
->withTokenTypes($token_types)
->withExpired(false)
->execute();
foreach ($tokens as $token) {
$token->revokeToken();
}
}
+ public function getTemporaryTokenProperty($key, $default = null) {
+ return idx($this->properties, $key, $default);
+ }
+
+ public function setTemporaryTokenProperty($key, $value) {
+ $this->properties[$key] = $value;
+ return $this;
+ }
+
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
);
}
public function getPolicy($capability) {
// We're just implement this interface to get access to the standard
// query infrastructure.
return PhabricatorPolicies::getMostOpenPolicy();
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
public function describeAutomaticCapability($capability) {
return null;
}
}
diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php
index 28328796ce..58cfe346c4 100644
--- a/src/applications/files/storage/PhabricatorFile.php
+++ b/src/applications/files/storage/PhabricatorFile.php
@@ -1,1373 +1,1373 @@
<?php
/**
* Parameters
* ==========
*
* When creating a new file using a method like @{method:newFromFileData}, these
* parameters are supported:
*
* | name | Human readable filename.
* | authorPHID | User PHID of uploader.
* | ttl | Temporary file lifetime, in seconds.
* | viewPolicy | File visibility policy.
* | isExplicitUpload | Used to show users files they explicitly uploaded.
* | canCDN | Allows the file to be cached and delivered over a CDN.
* | mime-type | Optional, explicit file MIME type.
* | builtin | Optional filename, identifies this as a builtin.
*
*/
final class PhabricatorFile extends PhabricatorFileDAO
implements
PhabricatorApplicationTransactionInterface,
PhabricatorTokenReceiverInterface,
PhabricatorSubscribableInterface,
PhabricatorFlaggableInterface,
PhabricatorPolicyInterface,
PhabricatorDestructibleInterface {
const ONETIME_TEMPORARY_TOKEN_TYPE = 'file:onetime';
const STORAGE_FORMAT_RAW = 'raw';
const METADATA_IMAGE_WIDTH = 'width';
const METADATA_IMAGE_HEIGHT = 'height';
const METADATA_CAN_CDN = 'canCDN';
const METADATA_BUILTIN = 'builtin';
const METADATA_PARTIAL = 'partial';
const METADATA_PROFILE = 'profile';
protected $name;
protected $mimeType;
protected $byteSize;
protected $authorPHID;
protected $secretKey;
protected $contentHash;
protected $metadata = array();
protected $mailKey;
protected $storageEngine;
protected $storageFormat;
protected $storageHandle;
protected $ttl;
protected $isExplicitUpload = 1;
protected $viewPolicy = PhabricatorPolicies::POLICY_USER;
protected $isPartial = 0;
private $objects = self::ATTACHABLE;
private $objectPHIDs = self::ATTACHABLE;
private $originalFile = self::ATTACHABLE;
public static function initializeNewFile() {
$app = id(new PhabricatorApplicationQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withClasses(array('PhabricatorFilesApplication'))
->executeOne();
$view_policy = $app->getPolicy(
FilesDefaultViewCapability::CAPABILITY);
return id(new PhabricatorFile())
->setViewPolicy($view_policy)
->setIsPartial(0)
->attachOriginalFile(null)
->attachObjects(array())
->attachObjectPHIDs(array());
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'metadata' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'name' => 'text255?',
'mimeType' => 'text255?',
'byteSize' => 'uint64',
'storageEngine' => 'text32',
'storageFormat' => 'text32',
'storageHandle' => 'text255',
'authorPHID' => 'phid?',
'secretKey' => 'bytes20?',
'contentHash' => 'bytes40?',
'ttl' => 'epoch?',
'isExplicitUpload' => 'bool?',
'mailKey' => 'bytes20',
'isPartial' => 'bool',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'authorPHID' => array(
'columns' => array('authorPHID'),
),
'contentHash' => array(
'columns' => array('contentHash'),
),
'key_ttl' => array(
'columns' => array('ttl'),
),
'key_dateCreated' => array(
'columns' => array('dateCreated'),
),
'key_partial' => array(
'columns' => array('authorPHID', 'isPartial'),
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorFileFilePHIDType::TYPECONST);
}
public function save() {
if (!$this->getSecretKey()) {
$this->setSecretKey($this->generateSecretKey());
}
if (!$this->getMailKey()) {
$this->setMailKey(Filesystem::readRandomCharacters(20));
}
return parent::save();
}
public function getMonogram() {
return 'F'.$this->getID();
}
public static function readUploadedFileData($spec) {
if (!$spec) {
throw new Exception(pht('No file was uploaded!'));
}
$err = idx($spec, 'error');
if ($err) {
throw new PhabricatorFileUploadException($err);
}
$tmp_name = idx($spec, 'tmp_name');
$is_valid = @is_uploaded_file($tmp_name);
if (!$is_valid) {
throw new Exception(pht('File is not an uploaded file.'));
}
$file_data = Filesystem::readFile($tmp_name);
$file_size = idx($spec, 'size');
if (strlen($file_data) != $file_size) {
throw new Exception(pht('File size disagrees with uploaded size.'));
}
return $file_data;
}
public static function newFromPHPUpload($spec, array $params = array()) {
$file_data = self::readUploadedFileData($spec);
$file_name = nonempty(
idx($params, 'name'),
idx($spec, 'name'));
$params = array(
'name' => $file_name,
) + $params;
return self::newFromFileData($file_data, $params);
}
public static function newFromXHRUpload($data, array $params = array()) {
return self::newFromFileData($data, $params);
}
/**
* Given a block of data, try to load an existing file with the same content
* if one exists. If it does not, build a new file.
*
* This method is generally used when we have some piece of semi-trusted data
* like a diff or a file from a repository that we want to show to the user.
* We can't just dump it out because it may be dangerous for any number of
* reasons; instead, we need to serve it through the File abstraction so it
* ends up on the CDN domain if one is configured and so on. However, if we
* simply wrote a new file every time we'd potentially end up with a lot
* of redundant data in file storage.
*
* To solve these problems, we use file storage as a cache and reuse the
* same file again if we've previously written it.
*
* NOTE: This method unguards writes.
*
* @param string Raw file data.
* @param dict Dictionary of file information.
*/
public static function buildFromFileDataOrHash(
$data,
array $params = array()) {
$file = id(new PhabricatorFile())->loadOneWhere(
'name = %s AND contentHash = %s LIMIT 1',
idx($params, 'name'),
self::hashFileContent($data));
if (!$file) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$file = self::newFromFileData($data, $params);
unset($unguarded);
}
return $file;
}
public static function newFileFromContentHash($hash, array $params) {
// Check to see if a file with same contentHash exist
$file = id(new PhabricatorFile())->loadOneWhere(
'contentHash = %s LIMIT 1',
$hash);
if ($file) {
// copy storageEngine, storageHandle, storageFormat
$copy_of_storage_engine = $file->getStorageEngine();
$copy_of_storage_handle = $file->getStorageHandle();
$copy_of_storage_format = $file->getStorageFormat();
$copy_of_byte_size = $file->getByteSize();
$copy_of_mime_type = $file->getMimeType();
$new_file = self::initializeNewFile();
$new_file->setByteSize($copy_of_byte_size);
$new_file->setContentHash($hash);
$new_file->setStorageEngine($copy_of_storage_engine);
$new_file->setStorageHandle($copy_of_storage_handle);
$new_file->setStorageFormat($copy_of_storage_format);
$new_file->setMimeType($copy_of_mime_type);
$new_file->copyDimensions($file);
$new_file->readPropertiesFromParameters($params);
$new_file->save();
return $new_file;
}
return $file;
}
public static function newChunkedFile(
PhabricatorFileStorageEngine $engine,
$length,
array $params) {
$file = self::initializeNewFile();
$file->setByteSize($length);
// TODO: We might be able to test the first chunk in order to figure
// this out more reliably, since MIME detection usually examines headers.
// However, enormous files are probably always either actually raw data
// or reasonable to treat like raw data.
$file->setMimeType('application/octet-stream');
$chunked_hash = idx($params, 'chunkedHash');
if ($chunked_hash) {
$file->setContentHash($chunked_hash);
} else {
// See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some
// discussion of this.
$seed = Filesystem::readRandomBytes(64);
$hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput(
$seed);
$file->setContentHash($hash);
}
$file->setStorageEngine($engine->getEngineIdentifier());
$file->setStorageHandle(PhabricatorFileChunk::newChunkHandle());
$file->setStorageFormat(self::STORAGE_FORMAT_RAW);
$file->setIsPartial(1);
$file->readPropertiesFromParameters($params);
return $file;
}
private static function buildFromFileData($data, array $params = array()) {
if (isset($params['storageEngines'])) {
$engines = $params['storageEngines'];
} else {
$size = strlen($data);
$engines = PhabricatorFileStorageEngine::loadStorageEngines($size);
if (!$engines) {
throw new Exception(
pht(
'No configured storage engine can store this file. See '.
'"Configuring File Storage" in the documentation for '.
'information on configuring storage engines.'));
}
}
assert_instances_of($engines, 'PhabricatorFileStorageEngine');
if (!$engines) {
throw new Exception(pht('No valid storage engines are available!'));
}
$file = self::initializeNewFile();
$data_handle = null;
$engine_identifier = null;
$exceptions = array();
foreach ($engines as $engine) {
$engine_class = get_class($engine);
try {
list($engine_identifier, $data_handle) = $file->writeToEngine(
$engine,
$data,
$params);
// We stored the file somewhere so stop trying to write it to other
// places.
break;
} catch (PhabricatorFileStorageConfigurationException $ex) {
// If an engine is outright misconfigured (or misimplemented), raise
// that immediately since it probably needs attention.
throw $ex;
} catch (Exception $ex) {
phlog($ex);
// If an engine doesn't work, keep trying all the other valid engines
// in case something else works.
$exceptions[$engine_class] = $ex;
}
}
if (!$data_handle) {
throw new PhutilAggregateException(
pht('All storage engines failed to write file:'),
$exceptions);
}
$file->setByteSize(strlen($data));
$file->setContentHash(self::hashFileContent($data));
$file->setStorageEngine($engine_identifier);
$file->setStorageHandle($data_handle);
// TODO: This is probably YAGNI, but allows for us to do encryption or
// compression later if we want.
$file->setStorageFormat(self::STORAGE_FORMAT_RAW);
$file->readPropertiesFromParameters($params);
if (!$file->getMimeType()) {
$tmp = new TempFile();
Filesystem::writeFile($tmp, $data);
$file->setMimeType(Filesystem::getMimeType($tmp));
}
try {
$file->updateDimensions(false);
} catch (Exception $ex) {
// Do nothing
}
$file->save();
return $file;
}
public static function newFromFileData($data, array $params = array()) {
$hash = self::hashFileContent($data);
$file = self::newFileFromContentHash($hash, $params);
if ($file) {
return $file;
}
return self::buildFromFileData($data, $params);
}
public function migrateToEngine(PhabricatorFileStorageEngine $engine) {
if (!$this->getID() || !$this->getStorageHandle()) {
throw new Exception(
pht("You can not migrate a file which hasn't yet been saved."));
}
$data = $this->loadFileData();
$params = array(
'name' => $this->getName(),
);
list($new_identifier, $new_handle) = $this->writeToEngine(
$engine,
$data,
$params);
$old_engine = $this->instantiateStorageEngine();
$old_identifier = $this->getStorageEngine();
$old_handle = $this->getStorageHandle();
$this->setStorageEngine($new_identifier);
$this->setStorageHandle($new_handle);
$this->save();
$this->deleteFileDataIfUnused(
$old_engine,
$old_identifier,
$old_handle);
return $this;
}
private function writeToEngine(
PhabricatorFileStorageEngine $engine,
$data,
array $params) {
$engine_class = get_class($engine);
$data_handle = $engine->writeFile($data, $params);
if (!$data_handle || strlen($data_handle) > 255) {
// This indicates an improperly implemented storage engine.
throw new PhabricatorFileStorageConfigurationException(
pht(
"Storage engine '%s' executed %s but did not return a valid ".
"handle ('%s') to the data: it must be nonempty and no longer ".
"than 255 characters.",
$engine_class,
'writeFile()',
$data_handle));
}
$engine_identifier = $engine->getEngineIdentifier();
if (!$engine_identifier || strlen($engine_identifier) > 32) {
throw new PhabricatorFileStorageConfigurationException(
pht(
"Storage engine '%s' returned an improper engine identifier '{%s}': ".
"it must be nonempty and no longer than 32 characters.",
$engine_class,
$engine_identifier));
}
return array($engine_identifier, $data_handle);
}
/**
* Download a remote resource over HTTP and save the response body as a file.
*
* This method respects `security.outbound-blacklist`, and protects against
* HTTP redirection (by manually following "Location" headers and verifying
* each destination). It does not protect against DNS rebinding. See
* discussion in T6755.
*/
public static function newFromFileDownload($uri, array $params = array()) {
$timeout = 5;
$redirects = array();
$current = $uri;
while (true) {
try {
if (count($redirects) > 10) {
throw new Exception(
pht('Too many redirects trying to fetch remote URI.'));
}
$resolved = PhabricatorEnv::requireValidRemoteURIForFetch(
$current,
array(
'http',
'https',
));
list($resolved_uri, $resolved_domain) = $resolved;
$current = new PhutilURI($current);
if ($current->getProtocol() == 'http') {
// For HTTP, we can use a pre-resolved URI to defuse DNS rebinding.
$fetch_uri = $resolved_uri;
$fetch_host = $resolved_domain;
} else {
// For HTTPS, we can't: cURL won't verify the SSL certificate if
// the domain has been replaced with an IP. But internal services
// presumably will not have valid certificates for rebindable
// domain names on attacker-controlled domains, so the DNS rebinding
// attack should generally not be possible anyway.
$fetch_uri = $current;
$fetch_host = null;
}
$future = id(new HTTPSFuture($fetch_uri))
->setFollowLocation(false)
->setTimeout($timeout);
if ($fetch_host !== null) {
$future->addHeader('Host', $fetch_host);
}
list($status, $body, $headers) = $future->resolve();
if ($status->isRedirect()) {
// This is an HTTP 3XX status, so look for a "Location" header.
$location = null;
foreach ($headers as $header) {
list($name, $value) = $header;
if (phutil_utf8_strtolower($name) == 'location') {
$location = $value;
break;
}
}
// HTTP 3XX status with no "Location" header, just treat this like
// a normal HTTP error.
if ($location === null) {
throw $status;
}
if (isset($redirects[$location])) {
throw new Exception(
pht('Encountered loop while following redirects.'));
}
$redirects[$location] = $location;
$current = $location;
// We'll fall off the bottom and go try this URI now.
} else if ($status->isError()) {
// This is something other than an HTTP 2XX or HTTP 3XX status, so
// just bail out.
throw $status;
} else {
// This is HTTP 2XX, so use the response body to save the
// file data.
$params = $params + array(
'name' => basename($uri),
);
return self::newFromFileData($body, $params);
}
} catch (Exception $ex) {
if ($redirects) {
throw new PhutilProxyException(
pht(
'Failed to fetch remote URI "%s" after following %s redirect(s) '.
'(%s): %s',
$uri,
phutil_count($redirects),
implode(' > ', array_keys($redirects)),
$ex->getMessage()),
$ex);
} else {
throw $ex;
}
}
}
}
public static function normalizeFileName($file_name) {
$pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@";
$file_name = preg_replace($pattern, '_', $file_name);
$file_name = preg_replace('@_+@', '_', $file_name);
$file_name = trim($file_name, '_');
$disallowed_filenames = array(
'.' => 'dot',
'..' => 'dotdot',
'' => 'file',
);
$file_name = idx($disallowed_filenames, $file_name, $file_name);
return $file_name;
}
public function delete() {
// We want to delete all the rows which mark this file as the transformation
// of some other file (since we're getting rid of it). We also delete all
// the transformations of this file, so that a user who deletes an image
// doesn't need to separately hunt down and delete a bunch of thumbnails and
// resizes of it.
$outbound_xforms = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTransforms(
array(
array(
'originalPHID' => $this->getPHID(),
'transform' => true,
),
))
->execute();
foreach ($outbound_xforms as $outbound_xform) {
$outbound_xform->delete();
}
$inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
'transformedPHID = %s',
$this->getPHID());
$this->openTransaction();
foreach ($inbound_xforms as $inbound_xform) {
$inbound_xform->delete();
}
$ret = parent::delete();
$this->saveTransaction();
$this->deleteFileDataIfUnused(
$this->instantiateStorageEngine(),
$this->getStorageEngine(),
$this->getStorageHandle());
return $ret;
}
/**
* Destroy stored file data if there are no remaining files which reference
* it.
*/
public function deleteFileDataIfUnused(
PhabricatorFileStorageEngine $engine,
$engine_identifier,
$handle) {
// Check to see if any files are using storage.
$usage = id(new PhabricatorFile())->loadAllWhere(
'storageEngine = %s AND storageHandle = %s LIMIT 1',
$engine_identifier,
$handle);
// If there are no files using the storage, destroy the actual storage.
if (!$usage) {
try {
$engine->deleteFile($handle);
} catch (Exception $ex) {
// In the worst case, we're leaving some data stranded in a storage
// engine, which is not a big deal.
phlog($ex);
}
}
}
public static function hashFileContent($data) {
return sha1($data);
}
public function loadFileData() {
$engine = $this->instantiateStorageEngine();
$data = $engine->readFile($this->getStorageHandle());
switch ($this->getStorageFormat()) {
case self::STORAGE_FORMAT_RAW:
$data = $data;
break;
default:
throw new Exception(pht('Unknown storage format.'));
}
return $data;
}
/**
* Return an iterable which emits file content bytes.
*
* @param int Offset for the start of data.
* @param int Offset for the end of data.
* @return Iterable Iterable object which emits requested data.
*/
public function getFileDataIterator($begin = null, $end = null) {
$engine = $this->instantiateStorageEngine();
return $engine->getFileDataIterator($this, $begin, $end);
}
public function getViewURI() {
if (!$this->getPHID()) {
throw new Exception(
pht('You must save a file before you can generate a view URI.'));
}
return $this->getCDNURI(null);
}
private function getCDNURI($token) {
$name = self::normalizeFileName($this->getName());
$name = phutil_escape_uri($name);
$parts = array();
$parts[] = 'file';
$parts[] = 'data';
// If this is an instanced install, add the instance identifier to the URI.
// Instanced configurations behind a CDN may not be able to control the
// request domain used by the CDN (as with AWS CloudFront). Embedding the
// instance identity in the path allows us to distinguish between requests
// originating from different instances but served through the same CDN.
$instance = PhabricatorEnv::getEnvConfig('cluster.instance');
if (strlen($instance)) {
$parts[] = '@'.$instance;
}
$parts[] = $this->getSecretKey();
$parts[] = $this->getPHID();
if ($token) {
$parts[] = $token;
}
$parts[] = $name;
$path = '/'.implode('/', $parts);
// If this file is only partially uploaded, we're just going to return a
// local URI to make sure that Ajax works, since the page is inevitably
// going to give us an error back.
if ($this->getIsPartial()) {
return PhabricatorEnv::getURI($path);
} else {
return PhabricatorEnv::getCDNURI($path);
}
}
/**
* Get the CDN URI for this file, including a one-time-use security token.
*
*/
public function getCDNURIWithToken() {
if (!$this->getPHID()) {
throw new Exception(
pht('You must save a file before you can generate a CDN URI.'));
}
return $this->getCDNURI($this->generateOneTimeToken());
}
public function getInfoURI() {
return '/'.$this->getMonogram();
}
public function getBestURI() {
if ($this->isViewableInBrowser()) {
return $this->getViewURI();
} else {
return $this->getInfoURI();
}
}
public function getDownloadURI() {
$uri = id(new PhutilURI($this->getViewURI()))
->setQueryParam('download', true);
return (string)$uri;
}
public function getURIForTransform(PhabricatorFileTransform $transform) {
return $this->getTransformedURI($transform->getTransformKey());
}
private function getTransformedURI($transform) {
$parts = array();
$parts[] = 'file';
$parts[] = 'xform';
$instance = PhabricatorEnv::getEnvConfig('cluster.instance');
if (strlen($instance)) {
$parts[] = '@'.$instance;
}
$parts[] = $transform;
$parts[] = $this->getPHID();
$parts[] = $this->getSecretKey();
$path = implode('/', $parts);
$path = $path.'/';
return PhabricatorEnv::getCDNURI($path);
}
public function isViewableInBrowser() {
return ($this->getViewableMimeType() !== null);
}
public function isViewableImage() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.image-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
public function isAudio() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.audio-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
public function isTransformableImage() {
// NOTE: The way the 'gd' extension works in PHP is that you can install it
// with support for only some file types, so it might be able to handle
// PNG but not JPEG. Try to generate thumbnails for whatever we can. Setup
// warns you if you don't have complete support.
$matches = null;
$ok = preg_match(
'@^image/(gif|png|jpe?g)@',
$this->getViewableMimeType(),
$matches);
if (!$ok) {
return false;
}
switch ($matches[1]) {
case 'jpg';
case 'jpeg':
return function_exists('imagejpeg');
break;
case 'png':
return function_exists('imagepng');
break;
case 'gif':
return function_exists('imagegif');
break;
default:
throw new Exception(pht('Unknown type matched as image MIME type.'));
}
}
public static function getTransformableImageFormats() {
$supported = array();
if (function_exists('imagejpeg')) {
$supported[] = 'jpg';
}
if (function_exists('imagepng')) {
$supported[] = 'png';
}
if (function_exists('imagegif')) {
$supported[] = 'gif';
}
return $supported;
}
public function instantiateStorageEngine() {
return self::buildEngine($this->getStorageEngine());
}
public static function buildEngine($engine_identifier) {
$engines = self::buildAllEngines();
foreach ($engines as $engine) {
if ($engine->getEngineIdentifier() == $engine_identifier) {
return $engine;
}
}
throw new Exception(
pht(
"Storage engine '%s' could not be located!",
$engine_identifier));
}
public static function buildAllEngines() {
return id(new PhutilClassMapQuery())
->setAncestorClass('PhabricatorFileStorageEngine')
->execute();
}
public function getViewableMimeType() {
$mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types');
$mime_type = $this->getMimeType();
$mime_parts = explode(';', $mime_type);
$mime_type = trim(reset($mime_parts));
return idx($mime_map, $mime_type);
}
public function getDisplayIconForMimeType() {
$mime_map = PhabricatorEnv::getEnvConfig('files.icon-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type, 'fa-file-o');
}
public function validateSecretKey($key) {
return ($key == $this->getSecretKey());
}
public function generateSecretKey() {
return Filesystem::readRandomCharacters(20);
}
public function updateDimensions($save = true) {
if (!$this->isViewableImage()) {
throw new Exception(pht('This file is not a viewable image.'));
}
if (!function_exists('imagecreatefromstring')) {
throw new Exception(pht('Cannot retrieve image information.'));
}
$data = $this->loadFileData();
$img = imagecreatefromstring($data);
if ($img === false) {
throw new Exception(pht('Error when decoding image.'));
}
$this->metadata[self::METADATA_IMAGE_WIDTH] = imagesx($img);
$this->metadata[self::METADATA_IMAGE_HEIGHT] = imagesy($img);
if ($save) {
$this->save();
}
return $this;
}
public function copyDimensions(PhabricatorFile $file) {
$metadata = $file->getMetadata();
$width = idx($metadata, self::METADATA_IMAGE_WIDTH);
if ($width) {
$this->metadata[self::METADATA_IMAGE_WIDTH] = $width;
}
$height = idx($metadata, self::METADATA_IMAGE_HEIGHT);
if ($height) {
$this->metadata[self::METADATA_IMAGE_HEIGHT] = $height;
}
return $this;
}
/**
* Load (or build) the {@class:PhabricatorFile} objects for builtin file
* resources. The builtin mechanism allows files shipped with Phabricator
* to be treated like normal files so that APIs do not need to special case
* things like default images or deleted files.
*
* Builtins are located in `resources/builtin/` and identified by their
* name.
*
* @param PhabricatorUser Viewing user.
* @param list<PhabricatorFilesBuiltinFile> List of builtin file specs.
* @return dict<string, PhabricatorFile> Dictionary of named builtins.
*/
public static function loadBuiltins(PhabricatorUser $user, array $builtins) {
$builtins = mpull($builtins, null, 'getBuiltinFileKey');
$specs = array();
foreach ($builtins as $key => $buitin) {
$specs[] = array(
'originalPHID' => PhabricatorPHIDConstants::PHID_VOID,
'transform' => $key,
);
}
// NOTE: Anyone is allowed to access builtin files.
$files = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTransforms($specs)
->execute();
$results = array();
foreach ($files as $file) {
$builtin_key = $file->getBuiltinName();
if ($builtin_key !== null) {
$results[$builtin_key] = $file;
}
}
$build = array();
foreach ($builtins as $key => $builtin) {
if (isset($results[$key])) {
continue;
}
$data = $builtin->loadBuiltinFileData();
$params = array(
'name' => $builtin->getBuiltinDisplayName(),
'ttl' => time() + (60 * 60 * 24 * 7),
'canCDN' => true,
'builtin' => $key,
);
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$file = self::newFromFileData($data, $params);
$xform = id(new PhabricatorTransformedFile())
->setOriginalPHID(PhabricatorPHIDConstants::PHID_VOID)
->setTransform($key)
->setTransformedPHID($file->getPHID())
->save();
unset($unguarded);
$file->attachObjectPHIDs(array());
$file->attachObjects(array());
$results[$key] = $file;
}
return $results;
}
/**
* Convenience wrapper for @{method:loadBuiltins}.
*
* @param PhabricatorUser Viewing user.
* @param string Single builtin name to load.
* @return PhabricatorFile Corresponding builtin file.
*/
public static function loadBuiltin(PhabricatorUser $user, $name) {
$builtin = id(new PhabricatorFilesOnDiskBuiltinFile())
->setName($name);
$key = $builtin->getBuiltinFileKey();
return idx(self::loadBuiltins($user, array($builtin)), $key);
}
public function getObjects() {
return $this->assertAttached($this->objects);
}
public function attachObjects(array $objects) {
$this->objects = $objects;
return $this;
}
public function getObjectPHIDs() {
return $this->assertAttached($this->objectPHIDs);
}
public function attachObjectPHIDs(array $object_phids) {
$this->objectPHIDs = $object_phids;
return $this;
}
public function getOriginalFile() {
return $this->assertAttached($this->originalFile);
}
public function attachOriginalFile(PhabricatorFile $file = null) {
$this->originalFile = $file;
return $this;
}
public function getImageHeight() {
if (!$this->isViewableImage()) {
return null;
}
return idx($this->metadata, self::METADATA_IMAGE_HEIGHT);
}
public function getImageWidth() {
if (!$this->isViewableImage()) {
return null;
}
return idx($this->metadata, self::METADATA_IMAGE_WIDTH);
}
public function getCanCDN() {
if (!$this->isViewableImage()) {
return false;
}
return idx($this->metadata, self::METADATA_CAN_CDN);
}
public function setCanCDN($can_cdn) {
$this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0;
return $this;
}
public function isBuiltin() {
return ($this->getBuiltinName() !== null);
}
public function getBuiltinName() {
return idx($this->metadata, self::METADATA_BUILTIN);
}
public function setBuiltinName($name) {
$this->metadata[self::METADATA_BUILTIN] = $name;
return $this;
}
public function getIsProfileImage() {
return idx($this->metadata, self::METADATA_PROFILE);
}
public function setIsProfileImage($value) {
$this->metadata[self::METADATA_PROFILE] = $value;
return $this;
}
protected function generateOneTimeToken() {
$key = Filesystem::readRandomCharacters(16);
// Save the new secret.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$token = id(new PhabricatorAuthTemporaryToken())
- ->setObjectPHID($this->getPHID())
+ ->setTokenResource($this->getPHID())
->setTokenType(self::ONETIME_TEMPORARY_TOKEN_TYPE)
->setTokenExpires(time() + phutil_units('1 hour in seconds'))
->setTokenCode(PhabricatorHash::digest($key))
->save();
unset($unguarded);
return $key;
}
public function validateOneTimeToken($token_code) {
$token = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
- ->withObjectPHIDs(array($this->getPHID()))
+ ->withTokenResources(array($this->getPHID()))
->withTokenTypes(array(self::ONETIME_TEMPORARY_TOKEN_TYPE))
->withExpired(false)
->withTokenCodes(array(PhabricatorHash::digest($token_code)))
->executeOne();
return $token;
}
/**
* Write the policy edge between this file and some object.
*
* @param phid Object PHID to attach to.
* @return this
*/
public function attachToObject($phid) {
$edge_type = PhabricatorObjectHasFileEdgeType::EDGECONST;
id(new PhabricatorEdgeEditor())
->addEdge($phid, $edge_type, $this->getPHID())
->save();
return $this;
}
/**
* Remove the policy edge between this file and some object.
*
* @param phid Object PHID to detach from.
* @return this
*/
public function detachFromObject($phid) {
$edge_type = PhabricatorObjectHasFileEdgeType::EDGECONST;
id(new PhabricatorEdgeEditor())
->removeEdge($phid, $edge_type, $this->getPHID())
->save();
return $this;
}
/**
* Configure a newly created file object according to specified parameters.
*
* This method is called both when creating a file from fresh data, and
* when creating a new file which reuses existing storage.
*
* @param map<string, wild> Bag of parameters, see @{class:PhabricatorFile}
* for documentation.
* @return this
*/
private function readPropertiesFromParameters(array $params) {
$file_name = idx($params, 'name');
$this->setName($file_name);
$author_phid = idx($params, 'authorPHID');
$this->setAuthorPHID($author_phid);
$file_ttl = idx($params, 'ttl');
$this->setTtl($file_ttl);
$view_policy = idx($params, 'viewPolicy');
if ($view_policy) {
$this->setViewPolicy($params['viewPolicy']);
}
$is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0);
$this->setIsExplicitUpload($is_explicit);
$can_cdn = idx($params, 'canCDN');
if ($can_cdn) {
$this->setCanCDN(true);
}
$builtin = idx($params, 'builtin');
if ($builtin) {
$this->setBuiltinName($builtin);
}
$profile = idx($params, 'profile');
if ($profile) {
$this->setIsProfileImage(true);
}
$mime_type = idx($params, 'mime-type');
if ($mime_type) {
$this->setMimeType($mime_type);
}
return $this;
}
public function getRedirectResponse() {
$uri = $this->getBestURI();
// TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI
// (if the file is a viewable image) and sometimes a local URI (if not).
// For now, just detect which one we got and configure the response
// appropriately. In the long run, if this endpoint is served from a CDN
// domain, we can't issue a local redirect to an info URI (which is not
// present on the CDN domain). We probably never actually issue local
// redirects here anyway, since we only ever transform viewable images
// right now.
$is_external = strlen(id(new PhutilURI($uri))->getDomain());
return id(new AphrontRedirectResponse())
->setIsExternal($is_external)
->setURI($uri);
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorFileEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new PhabricatorFileTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
if ($this->isBuiltin()) {
return PhabricatorPolicies::getMostOpenPolicy();
}
if ($this->getIsProfileImage()) {
return PhabricatorPolicies::getMostOpenPolicy();
}
return $this->getViewPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return PhabricatorPolicies::POLICY_NOONE;
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
$viewer_phid = $viewer->getPHID();
if ($viewer_phid) {
if ($this->getAuthorPHID() == $viewer_phid) {
return true;
}
}
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
// If you can see the file this file is a transform of, you can see
// this file.
if ($this->getOriginalFile()) {
return true;
}
// If you can see any object this file is attached to, you can see
// the file.
return (count($this->getObjects()) > 0);
}
return false;
}
public function describeAutomaticCapability($capability) {
$out = array();
$out[] = pht('The user who uploaded a file can always view and edit it.');
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
$out[] = pht(
'Files attached to objects are visible to users who can view '.
'those objects.');
$out[] = pht(
'Thumbnails are visible only to users who can view the original '.
'file.');
break;
}
return $out;
}
/* -( PhabricatorSubscribableInterface Implementation )-------------------- */
public function isAutomaticallySubscribed($phid) {
return ($this->authorPHID == $phid);
}
/* -( PhabricatorTokenReceiverInterface )---------------------------------- */
public function getUsersToNotifyOfTokenGiven() {
return array(
$this->getAuthorPHID(),
);
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$this->delete();
$this->saveTransaction();
}
}
diff --git a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php
index dd26890fc2..f2db373945 100644
--- a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php
+++ b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php
@@ -1,217 +1,217 @@
<?php
final class PhabricatorPasswordSettingsPanel extends PhabricatorSettingsPanel {
public function getPanelKey() {
return 'password';
}
public function getPanelName() {
return pht('Password');
}
public function getPanelGroup() {
return pht('Authentication');
}
public function isEnabled() {
// There's no sense in showing a change password panel if this install
// doesn't support password authentication.
if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) {
return false;
}
return true;
}
public function processRequest(AphrontRequest $request) {
$user = $request->getUser();
$token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession(
$user,
$request,
'/settings/');
$min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length');
$min_len = (int)$min_len;
// NOTE: To change your password, you need to prove you own the account,
// either by providing the old password or by carrying a token to
// the workflow from a password reset email.
$key = $request->getStr('key');
$password_type = PhabricatorAuthPasswordResetTemporaryTokenType::TOKENTYPE;
$token = null;
if ($key) {
$token = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer($user)
- ->withObjectPHIDs(array($user->getPHID()))
+ ->withTokenResources(array($user->getPHID()))
->withTokenTypes(array($password_type))
->withTokenCodes(array(PhabricatorHash::digest($key)))
->withExpired(false)
->executeOne();
}
$e_old = true;
$e_new = true;
$e_conf = true;
$errors = array();
if ($request->isFormPost()) {
if (!$token) {
$envelope = new PhutilOpaqueEnvelope($request->getStr('old_pw'));
if (!$user->comparePassword($envelope)) {
$errors[] = pht('The old password you entered is incorrect.');
$e_old = pht('Invalid');
}
}
$pass = $request->getStr('new_pw');
$conf = $request->getStr('conf_pw');
if (strlen($pass) < $min_len) {
$errors[] = pht('Your new password is too short.');
$e_new = pht('Too Short');
} else if ($pass !== $conf) {
$errors[] = pht('New password and confirmation do not match.');
$e_conf = pht('Invalid');
} else if (PhabricatorCommonPasswords::isCommonPassword($pass)) {
$e_new = pht('Very Weak');
$e_conf = pht('Very Weak');
$errors[] = pht(
'Your new password is very weak: it is one of the most common '.
'passwords in use. Choose a stronger password.');
}
if (!$errors) {
// This write is unguarded because the CSRF token has already
// been checked in the call to $request->isFormPost() and
// the CSRF token depends on the password hash, so when it
// is changed here the CSRF token check will fail.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$envelope = new PhutilOpaqueEnvelope($pass);
id(new PhabricatorUserEditor())
->setActor($user)
->changePassword($user, $envelope);
unset($unguarded);
if ($token) {
// Destroy the token.
$token->delete();
// If this is a password set/reset, kick the user to the home page
// after we update their account.
$next = '/';
} else {
$next = $this->getPanelURI('?saved=true');
}
id(new PhabricatorAuthSessionEngine())->terminateLoginSessions(
$user,
$request->getCookie(PhabricatorCookies::COOKIE_SESSION));
return id(new AphrontRedirectResponse())->setURI($next);
}
}
$hash_envelope = new PhutilOpaqueEnvelope($user->getPasswordHash());
if (strlen($hash_envelope->openEnvelope())) {
try {
$can_upgrade = PhabricatorPasswordHasher::canUpgradeHash(
$hash_envelope);
} catch (PhabricatorPasswordHasherUnavailableException $ex) {
$can_upgrade = false;
// Only show this stuff if we aren't on the reset workflow. We can
// do resets regardless of the old hasher's availability.
if (!$token) {
$errors[] = pht(
'Your password is currently hashed using an algorithm which is '.
'no longer available on this install.');
$errors[] = pht(
'Because the algorithm implementation is missing, your password '.
'can not be used or updated.');
$errors[] = pht(
'To set a new password, request a password reset link from the '.
'login screen and then follow the instructions.');
}
}
if ($can_upgrade) {
$errors[] = pht(
'The strength of your stored password hash can be upgraded. '.
'To upgrade, either: log out and log in using your password; or '.
'change your password.');
}
}
$len_caption = null;
if ($min_len) {
$len_caption = pht('Minimum password length: %d characters.', $min_len);
}
$form = new AphrontFormView();
$form
->setUser($user)
->addHiddenInput('key', $key);
if (!$token) {
$form->appendChild(
id(new AphrontFormPasswordControl())
->setLabel(pht('Old Password'))
->setError($e_old)
->setName('old_pw'));
}
$form
->appendChild(
id(new AphrontFormPasswordControl())
->setDisableAutocomplete(true)
->setLabel(pht('New Password'))
->setError($e_new)
->setName('new_pw'));
$form
->appendChild(
id(new AphrontFormPasswordControl())
->setDisableAutocomplete(true)
->setLabel(pht('Confirm Password'))
->setCaption($len_caption)
->setError($e_conf)
->setName('conf_pw'));
$form
->appendChild(
id(new AphrontFormSubmitControl())
->setValue(pht('Change Password')));
$form->appendChild(
id(new AphrontFormStaticControl())
->setLabel(pht('Current Algorithm'))
->setValue(PhabricatorPasswordHasher::getCurrentAlgorithmName(
new PhutilOpaqueEnvelope($user->getPasswordHash()))));
$form->appendChild(
id(new AphrontFormStaticControl())
->setLabel(pht('Best Available Algorithm'))
->setValue(PhabricatorPasswordHasher::getBestAlgorithmName()));
$form->appendRemarkupInstructions(
pht(
'NOTE: Changing your password will terminate any other outstanding '.
'login sessions.'));
$form_box = id(new PHUIObjectBoxView())
->setHeaderText(pht('Change Password'))
->setFormSaved($request->getStr('saved'))
->setFormErrors($errors)
->setForm($form);
return array(
$form_box,
);
}
}
diff --git a/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php b/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php
index a92026333a..030ad37116 100644
--- a/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php
+++ b/src/applications/settings/panel/PhabricatorTokensSettingsPanel.php
@@ -1,96 +1,96 @@
<?php
final class PhabricatorTokensSettingsPanel extends PhabricatorSettingsPanel {
public function getPanelKey() {
return 'tokens';
}
public function getPanelName() {
return pht('Temporary Tokens');
}
public function getPanelGroup() {
return pht('Sessions and Logs');
}
public function isEnabled() {
return true;
}
public function processRequest(AphrontRequest $request) {
$viewer = $request->getUser();
$tokens = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer($viewer)
- ->withObjectPHIDs(array($viewer->getPHID()))
+ ->withTokenResources(array($viewer->getPHID()))
->execute();
$rows = array();
foreach ($tokens as $token) {
if ($token->isRevocable()) {
$button = javelin_tag(
'a',
array(
'href' => '/auth/token/revoke/'.$token->getID().'/',
'class' => 'small grey button',
'sigil' => 'workflow',
),
pht('Revoke'));
} else {
$button = javelin_tag(
'a',
array(
'class' => 'small grey button disabled',
),
pht('Revoke'));
}
if ($token->getTokenExpires() >= time()) {
$expiry = phabricator_datetime($token->getTokenExpires(), $viewer);
} else {
$expiry = pht('Expired');
}
$rows[] = array(
$token->getTokenReadableTypeName(),
$expiry,
$button,
);
}
$table = new AphrontTableView($rows);
$table->setNoDataString(pht("You don't have any active tokens."));
$table->setHeaders(
array(
pht('Type'),
pht('Expires'),
pht(''),
));
$table->setColumnClasses(
array(
'wide',
'right',
'action',
));
$terminate_button = id(new PHUIButtonView())
->setText(pht('Revoke All'))
->setHref('/auth/token/revoke/all/')
->setTag('a')
->setWorkflow(true)
->setIcon('fa-exclamation-triangle');
$header = id(new PHUIHeaderView())
->setHeader(pht('Temporary Tokens'))
->addActionLink($terminate_button);
$panel = id(new PHUIObjectBoxView())
->setHeader($header)
->setTable($table);
return $panel;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Mar 17, 1:06 AM (23 h, 12 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
963963
Default Alt Text
(109 KB)

Event Timeline