diff --git a/resources/sql/autopatches/20141107.ssh.1.colname.sql b/resources/sql/autopatches/20141107.ssh.1.colname.sql new file mode 100644 index 000000000..af34fd727 --- /dev/null +++ b/resources/sql/autopatches/20141107.ssh.1.colname.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + CHANGE userPHID objectPHID VARBINARY(64) NOT NULL; diff --git a/resources/sql/autopatches/20141107.ssh.2.keyhash.sql b/resources/sql/autopatches/20141107.ssh.2.keyhash.sql new file mode 100644 index 000000000..71c11edb1 --- /dev/null +++ b/resources/sql/autopatches/20141107.ssh.2.keyhash.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + DROP COLUMN keyHash; diff --git a/resources/sql/autopatches/20141107.ssh.3.keyindex.sql b/resources/sql/autopatches/20141107.ssh.3.keyindex.sql new file mode 100644 index 000000000..8df02acfe --- /dev/null +++ b/resources/sql/autopatches/20141107.ssh.3.keyindex.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + ADD COLUMN keyIndex BINARY(12); diff --git a/resources/sql/autopatches/20141107.ssh.4.keymig.php b/resources/sql/autopatches/20141107.ssh.4.keymig.php new file mode 100644 index 000000000..2388a282c --- /dev/null +++ b/resources/sql/autopatches/20141107.ssh.4.keymig.php @@ -0,0 +1,50 @@ +establishConnection('w'); + +echo "Updating SSH public key indexes...\n"; + +$keys = new LiskMigrationIterator($table); +foreach ($keys as $key) { + $id = $key->getID(); + + echo "Updating key {$id}...\n"; + + try { + $hash = $key->toPublicKey()->getHash(); + } catch (Exception $ex) { + echo "Key has bad format! Removing key.\n"; + queryfx( + $conn_w, + 'DELETE FROM %T WHERE id = %d', + $table->getTableName(), + $id); + continue; + } + + $collision = queryfx_all( + $conn_w, + 'SELECT * FROM %T WHERE keyIndex = %s AND id < %d', + $table->getTableName(), + $hash, + $key->getID()); + if ($collision) { + echo "Key is a duplicate! Removing key.\n"; + queryfx( + $conn_w, + 'DELETE FROM %T WHERE id = %d', + $table->getTableName(), + $id); + continue; + } + + queryfx( + $conn_w, + 'UPDATE %T SET keyIndex = %s WHERE id = %d', + $table->getTableName(), + $hash, + $key->getID()); +} + +echo "Done.\n"; diff --git a/resources/sql/autopatches/20141107.ssh.5.indexnull.sql b/resources/sql/autopatches/20141107.ssh.5.indexnull.sql new file mode 100644 index 000000000..c011ecc9f --- /dev/null +++ b/resources/sql/autopatches/20141107.ssh.5.indexnull.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + CHANGE keyIndex keyIndex BINARY(12) NOT NULL; diff --git a/resources/sql/autopatches/20141107.ssh.6.indexkey.sql b/resources/sql/autopatches/20141107.ssh.6.indexkey.sql new file mode 100644 index 000000000..967c813be --- /dev/null +++ b/resources/sql/autopatches/20141107.ssh.6.indexkey.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + ADD UNIQUE KEY `key_unique` (keyIndex); diff --git a/resources/sql/autopatches/20141107.ssh.7.colnull.sql b/resources/sql/autopatches/20141107.ssh.7.colnull.sql new file mode 100644 index 000000000..3182c2d9c --- /dev/null +++ b/resources/sql/autopatches/20141107.ssh.7.colnull.sql @@ -0,0 +1,23 @@ +UPDATE {$NAMESPACE}_auth.auth_sshkey + SET name = '' WHERE name IS NULL; + +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + CHANGE name name VARCHAR(255) COLLATE {$COLLATE_TEXT} NOT NULL; + +UPDATE {$NAMESPACE}_auth.auth_sshkey + SET keyType = '' WHERE keyType IS NULL; + +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + CHANGE keyType keyType VARCHAR(255) COLLATE {$COLLATE_TEXT} NOT NULL; + +UPDATE {$NAMESPACE}_auth.auth_sshkey + SET keyBody = '' WHERE keyBody IS NULL; + +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + CHANGE keyBody keyBody LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL; + +UPDATE {$NAMESPACE}_auth.auth_sshkey + SET keyComment = '' WHERE keyComment IS NULL; + +ALTER TABLE {$NAMESPACE}_auth.auth_sshkey + CHANGE keyComment keyComment VARCHAR(255) COLLATE {$COLLATE_TEXT} NOT NULL; diff --git a/scripts/ssh/ssh-auth.php b/scripts/ssh/ssh-auth.php index d84a1dded..f4f3de9e6 100755 --- a/scripts/ssh/ssh-auth.php +++ b/scripts/ssh/ssh-auth.php @@ -1,54 +1,60 @@ #!/usr/bin/env php setViewer(PhabricatorUser::getOmnipotentUser()) ->execute(); foreach ($keys as $key => $ssh_key) { // For now, filter out any keys which don't belong to users. Eventually we // may allow devices to use this channel. if (!($ssh_key->getObject() instanceof PhabricatorUser)) { unset($keys[$key]); continue; } } if (!$keys) { echo pht('No keys found.')."\n"; exit(1); } $bin = $root.'/bin/ssh-exec'; foreach ($keys as $ssh_key) { $user = $ssh_key->getObject()->getUsername(); $cmd = csprintf('%s --phabricator-ssh-user %s', $bin, $user); // This is additional escaping for the SSH 'command="..."' string. $cmd = addcslashes($cmd, '"\\'); // Strip out newlines and other nonsense from the key type and key body. $type = $ssh_key->getKeyType(); $type = preg_replace('@[\x00-\x20]+@', '', $type); + if (!strlen($type)) { + continue; + } $key = $ssh_key->getKeyBody(); $key = preg_replace('@[\x00-\x20]+@', '', $key); + if (!strlen($key)) { + continue; + } $options = array( 'command="'.$cmd.'"', 'no-port-forwarding', 'no-X11-forwarding', 'no-agent-forwarding', 'no-pty', ); $options = implode(',', $options); $lines[] = $options.' '.$type.' '.$key."\n"; } echo implode('', $lines); exit(0); diff --git a/src/applications/auth/query/PhabricatorAuthSSHKeyQuery.php b/src/applications/auth/query/PhabricatorAuthSSHKeyQuery.php index 35f4eca4b..d2963234f 100644 --- a/src/applications/auth/query/PhabricatorAuthSSHKeyQuery.php +++ b/src/applications/auth/query/PhabricatorAuthSSHKeyQuery.php @@ -1,104 +1,101 @@ ids = $ids; return $this; } public function withObjectPHIDs(array $object_phids) { $this->objectPHIDs = $object_phids; return $this; } public function withKeys(array $keys) { assert_instances_of($keys, 'PhabricatorAuthSSHPublicKey'); $this->keys = $keys; return $this; } protected function loadPage() { $table = new PhabricatorAuthSSHKey(); $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); } protected function willFilterPage(array $keys) { $object_phids = mpull($keys, 'getObjectPHID'); $objects = id(new PhabricatorObjectQuery()) ->setViewer($this->getViewer()) ->setParentQuery($this) ->withPHIDs($object_phids) ->execute(); $objects = mpull($objects, null, 'getPHID'); foreach ($keys as $key => $ssh_key) { $object = idx($objects, $ssh_key->getObjectPHID()); if (!$object) { unset($keys[$key]); continue; } $ssh_key->attachObject($object); } return $keys; } protected function buildWhereClause(AphrontDatabaseConnection $conn_r) { $where = array(); if ($this->ids !== null) { $where[] = qsprintf( $conn_r, 'id IN (%Ld)', $this->ids); } if ($this->objectPHIDs !== null) { $where[] = qsprintf( $conn_r, - 'userPHID IN (%Ls)', + 'objectPHID IN (%Ls)', $this->objectPHIDs); } if ($this->keys !== null) { - // TODO: This could take advantage of a better key, and the hashing - // scheme for this table is a bit nonstandard and questionable. - $sql = array(); foreach ($this->keys as $key) { $sql[] = qsprintf( $conn_r, - '(keyType = %s AND keyBody = %s)', + '(keyType = %s AND keyIndex = %s)', $key->getType(), - $key->getBody()); + $key->getHash()); } $where[] = implode(' OR ', $sql); } $where[] = $this->buildPagingClause($conn_r); return $this->formatWhereClause($where); } public function getQueryApplicationClass() { return 'PhabricatorAuthApplication'; } } diff --git a/src/applications/auth/storage/PhabricatorAuthSSHKey.php b/src/applications/auth/storage/PhabricatorAuthSSHKey.php index eace71131..c789c5ffd 100644 --- a/src/applications/auth/storage/PhabricatorAuthSSHKey.php +++ b/src/applications/auth/storage/PhabricatorAuthSSHKey.php @@ -1,86 +1,90 @@ getUserPHID(); - } - public function getConfiguration() { return array( self::CONFIG_COLUMN_SCHEMA => array( - 'keyHash' => 'bytes32', - 'keyComment' => 'text255?', - - // T6203/NULLABILITY - // These seem like they should not be nullable. - 'name' => 'text255?', - 'keyType' => 'text255?', - 'keyBody' => 'text?', + 'name' => 'text255', + 'keyType' => 'text255', + 'keyIndex' => 'bytes12', + 'keyBody' => 'text', + 'keyComment' => 'text255', ), self::CONFIG_KEY_SCHEMA => array( - 'userPHID' => array( - 'columns' => array('userPHID'), + 'key_object' => array( + 'columns' => array('objectPHID'), ), - 'keyHash' => array( - 'columns' => array('keyHash'), + 'key_unique' => array( + 'columns' => array('keyIndex'), 'unique' => true, ), ), ) + parent::getConfiguration(); } + public function save() { + $this->setKeyIndex($this->toPublicKey()->getHash()); + return parent::save(); + } + + public function toPublicKey() { + return PhabricatorAuthSSHPublicKey::newFromStoredKey($this); + } + public function getEntireKey() { $parts = array( $this->getKeyType(), $this->getKeyBody(), $this->getKeyComment(), ); return trim(implode(' ', $parts)); } public function getObject() { return $this->assertAttached($this->object); } public function attachObject($object) { $this->object = $object; return $this; } + + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { return $this->getObject()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getObject()->hasAutomaticCapability($capability, $viewer); } public function describeAutomaticCapability($capability) { return pht( 'SSH keys inherit the policies of the user or object they authenticate.'); } } diff --git a/src/applications/auth/storage/PhabricatorAuthSSHPublicKey.php b/src/applications/auth/storage/PhabricatorAuthSSHPublicKey.php index 78325157c..6ed5ff20d 100644 --- a/src/applications/auth/storage/PhabricatorAuthSSHPublicKey.php +++ b/src/applications/auth/storage/PhabricatorAuthSSHPublicKey.php @@ -1,86 +1,102 @@ } + public static function newFromStoredKey(PhabricatorAuthSSHKey $key) { + $public_key = new PhabricatorAuthSSHPublicKey(); + $public_key->type = $key->getKeyType(); + $public_key->body = $key->getKeyBody(); + $public_key->comment = $key->getKeyComment(); + + return $public_key; + } + public static function newFromRawKey($entire_key) { $entire_key = trim($entire_key); if (!strlen($entire_key)) { throw new Exception(pht('No public key was provided.')); } $parts = str_replace("\n", '', $entire_key); // The third field (the comment) can have spaces in it, so split this // into a maximum of three parts. $parts = preg_split('/\s+/', $parts, 3); if (preg_match('/private\s*key/i', $entire_key)) { // Try to give the user a better error message if it looks like // they uploaded a private key. throw new Exception(pht('Provide a public key, not a private key!')); } switch (count($parts)) { case 1: throw new Exception( pht('Provided public key is not properly formatted.')); case 2: // Add an empty comment part. $parts[] = ''; break; case 3: // This is the expected case. break; } list($type, $body, $comment) = $parts; $recognized_keys = array( 'ssh-dsa', 'ssh-dss', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', ); if (!in_array($type, $recognized_keys)) { $type_list = implode(', ', $recognized_keys); throw new Exception( pht( 'Public key type should be one of: %s', $type_list)); } $public_key = new PhabricatorAuthSSHPublicKey(); $public_key->type = $type; $public_key->body = $body; $public_key->comment = $comment; return $public_key; } public function getType() { return $this->type; } public function getBody() { return $this->body; } public function getComment() { return $this->comment; } + public function getHash() { + $body = $this->getBody(); + $body = trim($body); + $body = rtrim($body, '='); + return PhabricatorHash::digestForIndex($body); + } + } diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index 2fc686461..90f9892ec 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -1,931 +1,931 @@ timezoneIdentifier, date_default_timezone_get()); // Make sure these return booleans. case 'isAdmin': return (bool)$this->isAdmin; case 'isDisabled': return (bool)$this->isDisabled; case 'isSystemAgent': return (bool)$this->isSystemAgent; case 'isEmailVerified': return (bool)$this->isEmailVerified; case 'isApproved': return (bool)$this->isApproved; default: return parent::readField($field); } } /** * Is this a live account which has passed required approvals? Returns true * if this is an enabled, verified (if required), approved (if required) * account, and false otherwise. * * @return bool True if this is a standard, usable account. */ public function isUserActivated() { if ($this->getIsDisabled()) { return false; } if (!$this->getIsApproved()) { return false; } if (PhabricatorUserEmail::isEmailVerificationRequired()) { if (!$this->getIsEmailVerified()) { return false; } } return true; } /** * Returns `true` if this is a standard user who is logged in. Returns `false` * for logged out, anonymous, or external users. * * @return bool `true` if the user is a standard user who is logged in with * a normal session. */ public function getIsStandardUser() { $type_user = PhabricatorPeopleUserPHIDType::TYPECONST; return $this->getPHID() && (phid_get_type($this->getPHID()) == $type_user); } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'userName' => 'sort64', 'realName' => 'text128', 'sex' => 'text4?', 'translation' => 'text64?', 'passwordSalt' => 'text32?', 'passwordHash' => 'text128?', 'profileImagePHID' => 'phid?', 'consoleEnabled' => 'bool', 'consoleVisible' => 'bool', 'consoleTab' => 'text64', 'conduitCertificate' => 'text255', 'isSystemAgent' => 'bool', 'isDisabled' => 'bool', 'isAdmin' => 'bool', 'timezoneIdentifier' => 'text255', 'isEmailVerified' => 'uint32', 'isApproved' => 'uint32', 'accountSecret' => 'bytes64', 'isEnrolledInMultiFactor' => 'bool', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'userName' => array( 'columns' => array('userName'), 'unique' => true, ), 'realName' => array( 'columns' => array('realName'), ), 'key_approved' => array( 'columns' => array('isApproved'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPeopleUserPHIDType::TYPECONST); } public function setPassword(PhutilOpaqueEnvelope $envelope) { if (!$this->getPHID()) { throw new Exception( 'You can not set a password for an unsaved user because their PHID '. 'is a salt component in the password hash.'); } if (!strlen($envelope->openEnvelope())) { $this->setPasswordHash(''); } else { $this->setPasswordSalt(md5(Filesystem::readRandomBytes(32))); $hash = $this->hashPassword($envelope); $this->setPasswordHash($hash->openEnvelope()); } return $this; } // To satisfy PhutilPerson. public function getSex() { return $this->sex; } public function getMonogram() { return '@'.$this->getUsername(); } public function getTranslation() { try { if ($this->translation && class_exists($this->translation) && is_subclass_of($this->translation, 'PhabricatorTranslation')) { return $this->translation; } } catch (PhutilMissingSymbolException $ex) { return null; } return null; } public function isLoggedIn() { return !($this->getPHID() === null); } public function save() { if (!$this->getConduitCertificate()) { $this->setConduitCertificate($this->generateConduitCertificate()); } if (!strlen($this->getAccountSecret())) { $this->setAccountSecret(Filesystem::readRandomCharacters(64)); } $result = parent::save(); if ($this->profile) { $this->profile->save(); } $this->updateNameTokens(); id(new PhabricatorSearchIndexer()) ->queueDocumentForIndexing($this->getPHID()); return $result; } public function attachSession(PhabricatorAuthSession $session) { $this->session = $session; return $this; } public function getSession() { return $this->assertAttached($this->session); } public function hasSession() { return ($this->session !== self::ATTACHABLE); } private function generateConduitCertificate() { return Filesystem::readRandomCharacters(255); } public function comparePassword(PhutilOpaqueEnvelope $envelope) { if (!strlen($envelope->openEnvelope())) { return false; } if (!strlen($this->getPasswordHash())) { return false; } return PhabricatorPasswordHasher::comparePassword( $this->getPasswordHashInput($envelope), new PhutilOpaqueEnvelope($this->getPasswordHash())); } private function getPasswordHashInput(PhutilOpaqueEnvelope $password) { $input = $this->getUsername(). $password->openEnvelope(). $this->getPHID(). $this->getPasswordSalt(); return new PhutilOpaqueEnvelope($input); } private function hashPassword(PhutilOpaqueEnvelope $password) { $hasher = PhabricatorPasswordHasher::getBestHasher(); $input_envelope = $this->getPasswordHashInput($password); return $hasher->getPasswordHashForStorage($input_envelope); } const CSRF_CYCLE_FREQUENCY = 3600; const CSRF_SALT_LENGTH = 8; const CSRF_TOKEN_LENGTH = 16; const CSRF_BREACH_PREFIX = 'B@'; const EMAIL_CYCLE_FREQUENCY = 86400; const EMAIL_TOKEN_LENGTH = 24; private function getRawCSRFToken($offset = 0) { return $this->generateToken( time() + (self::CSRF_CYCLE_FREQUENCY * $offset), self::CSRF_CYCLE_FREQUENCY, PhabricatorEnv::getEnvConfig('phabricator.csrf-key'), self::CSRF_TOKEN_LENGTH); } /** * @phutil-external-symbol class PhabricatorStartup */ public function getCSRFToken() { $salt = PhabricatorStartup::getGlobal('csrf.salt'); if (!$salt) { $salt = Filesystem::readRandomCharacters(self::CSRF_SALT_LENGTH); PhabricatorStartup::setGlobal('csrf.salt', $salt); } // Generate a token hash to mitigate BREACH attacks against SSL. See // discussion in T3684. $token = $this->getRawCSRFToken(); $hash = PhabricatorHash::digest($token, $salt); return 'B@'.$salt.substr($hash, 0, self::CSRF_TOKEN_LENGTH); } public function validateCSRFToken($token) { $salt = null; $version = 'plain'; // This is a BREACH-mitigating token. See T3684. $breach_prefix = self::CSRF_BREACH_PREFIX; $breach_prelen = strlen($breach_prefix); if (!strncmp($token, $breach_prefix, $breach_prelen)) { $version = 'breach'; $salt = substr($token, $breach_prelen, self::CSRF_SALT_LENGTH); $token = substr($token, $breach_prelen + self::CSRF_SALT_LENGTH); } // When the user posts a form, we check that it contains a valid CSRF token. // Tokens cycle each hour (every CSRF_CYLCE_FREQUENCY seconds) and we accept // either the current token, the next token (users can submit a "future" // token if you have two web frontends that have some clock skew) or any of // the last 6 tokens. This means that pages are valid for up to 7 hours. // There is also some Javascript which periodically refreshes the CSRF // tokens on each page, so theoretically pages should be valid indefinitely. // However, this code may fail to run (if the user loses their internet // connection, or there's a JS problem, or they don't have JS enabled). // Choosing the size of the window in which we accept old CSRF tokens is // an issue of balancing concerns between security and usability. We could // choose a very narrow (e.g., 1-hour) window to reduce vulnerability to // attacks using captured CSRF tokens, but it's also more likely that real // users will be affected by this, e.g. if they close their laptop for an // hour, open it back up, and try to submit a form before the CSRF refresh // can kick in. Since the user experience of submitting a form with expired // CSRF is often quite bad (you basically lose data, or it's a big pain to // recover at least) and I believe we gain little additional protection // by keeping the window very short (the overwhelming value here is in // preventing blind attacks, and most attacks which can capture CSRF tokens // can also just capture authentication information [sniffing networks] // or act as the user [xss]) the 7 hour default seems like a reasonable // balance. Other major platforms have much longer CSRF token lifetimes, // like Rails (session duration) and Django (forever), which suggests this // is a reasonable analysis. $csrf_window = 6; for ($ii = -$csrf_window; $ii <= 1; $ii++) { $valid = $this->getRawCSRFToken($ii); switch ($version) { // TODO: We can remove this after the BREACH version has been in the // wild for a while. case 'plain': if ($token == $valid) { return true; } break; case 'breach': $digest = PhabricatorHash::digest($valid, $salt); if (substr($digest, 0, self::CSRF_TOKEN_LENGTH) == $token) { return true; } break; default: throw new Exception('Unknown CSRF token format!'); } } return false; } private function generateToken($epoch, $frequency, $key, $len) { if ($this->getPHID()) { $vec = $this->getPHID().$this->getAccountSecret(); } else { $vec = $this->getAlternateCSRFString(); } if ($this->hasSession()) { $vec = $vec.$this->getSession()->getSessionKey(); } $time_block = floor($epoch / $frequency); $vec = $vec.$key.$time_block; return substr(PhabricatorHash::digest($vec), 0, $len); } public function attachUserProfile(PhabricatorUserProfile $profile) { $this->profile = $profile; return $this; } public function loadUserProfile() { if ($this->profile) { return $this->profile; } $profile_dao = new PhabricatorUserProfile(); $this->profile = $profile_dao->loadOneWhere('userPHID = %s', $this->getPHID()); if (!$this->profile) { $profile_dao->setUserPHID($this->getPHID()); $this->profile = $profile_dao; } return $this->profile; } public function loadPrimaryEmailAddress() { $email = $this->loadPrimaryEmail(); if (!$email) { throw new Exception('User has no primary email address!'); } return $email->getAddress(); } public function loadPrimaryEmail() { return $this->loadOneRelative( new PhabricatorUserEmail(), 'userPHID', 'getPHID', '(isPrimary = 1)'); } public function loadPreferences() { if ($this->preferences) { return $this->preferences; } $preferences = null; if ($this->getPHID()) { $preferences = id(new PhabricatorUserPreferences())->loadOneWhere( 'userPHID = %s', $this->getPHID()); } if (!$preferences) { $preferences = new PhabricatorUserPreferences(); $preferences->setUserPHID($this->getPHID()); $default_dict = array( PhabricatorUserPreferences::PREFERENCE_TITLES => 'glyph', PhabricatorUserPreferences::PREFERENCE_EDITOR => '', PhabricatorUserPreferences::PREFERENCE_MONOSPACED => '', PhabricatorUserPreferences::PREFERENCE_DARK_CONSOLE => 0, ); $preferences->setPreferences($default_dict); } $this->preferences = $preferences; return $preferences; } public function loadEditorLink($path, $line, $callsign) { $editor = $this->loadPreferences()->getPreference( PhabricatorUserPreferences::PREFERENCE_EDITOR); if (is_array($path)) { $multiedit = $this->loadPreferences()->getPreference( PhabricatorUserPreferences::PREFERENCE_MULTIEDIT); switch ($multiedit) { case '': $path = implode(' ', $path); break; case 'disable': return null; } } if (!strlen($editor)) { return null; } $uri = strtr($editor, array( '%%' => '%', '%f' => phutil_escape_uri($path), '%l' => phutil_escape_uri($line), '%r' => phutil_escape_uri($callsign), )); // The resulting URI must have an allowed protocol. Otherwise, we'll return // a link to an error page explaining the misconfiguration. $ok = PhabricatorHelpEditorProtocolController::hasAllowedProtocol($uri); if (!$ok) { return '/help/editorprotocol/'; } return (string)$uri; } public function getAlternateCSRFString() { return $this->assertAttached($this->alternateCSRFString); } public function attachAlternateCSRFString($string) { $this->alternateCSRFString = $string; return $this; } /** * Populate the nametoken table, which used to fetch typeahead results. When * a user types "linc", we want to match "Abraham Lincoln" from on-demand * typeahead sources. To do this, we need a separate table of name fragments. */ public function updateNameTokens() { $table = self::NAMETOKEN_TABLE; $conn_w = $this->establishConnection('w'); $tokens = PhabricatorTypeaheadDatasource::tokenizeString( $this->getUserName().' '.$this->getRealName()); $sql = array(); foreach ($tokens as $token) { $sql[] = qsprintf( $conn_w, '(%d, %s)', $this->getID(), $token); } queryfx( $conn_w, 'DELETE FROM %T WHERE userID = %d', $table, $this->getID()); if ($sql) { queryfx( $conn_w, 'INSERT INTO %T (userID, token) VALUES %Q', $table, implode(', ', $sql)); } } public function sendWelcomeEmail(PhabricatorUser $admin) { $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $user_username = $this->getUserName(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); $base_uri = PhabricatorEnv::getProductionURI('/'); $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $this, $this->loadPrimaryEmail(), PhabricatorAuthSessionEngine::ONETIME_WELCOME); $body = <<addTos(array($this->getPHID())) ->setForceDelivery(true) ->setSubject('[Phabricator] Welcome to Phabricator') ->setBody($body) ->saveAndSend(); } public function sendUsernameChangeEmail( PhabricatorUser $admin, $old_username) { $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $new_username = $this->getUserName(); $password_instructions = null; if (PhabricatorPasswordAuthProvider::getPasswordProvider()) { $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $this, null, PhabricatorAuthSessionEngine::ONETIME_USERNAME); $password_instructions = <<addTos(array($this->getPHID())) ->setForceDelivery(true) ->setSubject('[Phabricator] Username Changed') ->setBody($body) ->saveAndSend(); } public static function describeValidUsername() { return pht( 'Usernames must contain only numbers, letters, period, underscore and '. 'hyphen, and can not end with a period. They must have no more than %d '. 'characters.', new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH)); } public static function validateUsername($username) { // NOTE: If you update this, make sure to update: // // - Remarkup rule for @mentions. // - Routing rule for "/p/username/". // - Unit tests, obviously. // - describeValidUsername() method, above. if (strlen($username) > self::MAXIMUM_USERNAME_LENGTH) { return false; } return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]\z/', $username); } public static function getDefaultProfileImageURI() { return celerity_get_resource_uri('/rsrc/image/avatar.png'); } public function attachStatus(PhabricatorCalendarEvent $status) { $this->status = $status; return $this; } public function getStatus() { return $this->assertAttached($this->status); } public function hasStatus() { return $this->status !== self::ATTACHABLE; } public function attachProfileImageURI($uri) { $this->profileImage = $uri; return $this; } public function getProfileImageURI() { return $this->assertAttached($this->profileImage); } public function loadProfileImageURI() { if ($this->profileImage && ($this->profileImage !== self::ATTACHABLE)) { return $this->profileImage; } $src_phid = $this->getProfileImagePHID(); if ($src_phid) { // TODO: (T603) Can we get rid of this entirely and move it to // PeopleQuery with attach/attachable? $file = id(new PhabricatorFile())->loadOneWhere('phid = %s', $src_phid); if ($file) { $this->profileImage = $file->getBestURI(); return $this->profileImage; } } $this->profileImage = self::getDefaultProfileImageURI(); return $this->profileImage; } public function getFullName() { if (strlen($this->getRealName())) { return $this->getUsername().' ('.$this->getRealName().')'; } else { return $this->getUsername(); } } public function __toString() { return $this->getUsername(); } public static function loadOneWithEmailAddress($address) { $email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $address); if (!$email) { return null; } return id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $email->getUserPHID()); } /* -( Multi-Factor Authentication )---------------------------------------- */ /** * Update the flag storing this user's enrollment in multi-factor auth. * * With certain settings, we need to check if a user has MFA on every page, * so we cache MFA enrollment on the user object for performance. Calling this * method synchronizes the cache by examining enrollment records. After * updating the cache, use @{method:getIsEnrolledInMultiFactor} to check if * the user is enrolled. * * This method should be called after any changes are made to a given user's * multi-factor configuration. * * @return void * @task factors */ public function updateMultiFactorEnrollment() { $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $this->getPHID()); $enrolled = count($factors) ? 1 : 0; if ($enrolled !== $this->isEnrolledInMultiFactor) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); queryfx( $this->establishConnection('w'), 'UPDATE %T SET isEnrolledInMultiFactor = %d WHERE id = %d', $this->getTableName(), $enrolled, $this->getID()); unset($unguarded); $this->isEnrolledInMultiFactor = $enrolled; } } /** * Check if the user is enrolled in multi-factor authentication. * * Enrolled users have one or more multi-factor authentication sources * attached to their account. For performance, this value is cached. You * can use @{method:updateMultiFactorEnrollment} to update the cache. * * @return bool True if the user is enrolled. * @task factors */ public function getIsEnrolledInMultiFactor() { return $this->isEnrolledInMultiFactor; } /* -( Omnipotence )-------------------------------------------------------- */ /** * Returns true if this user is omnipotent. Omnipotent users bypass all policy * checks. * * @return bool True if the user bypasses policy checks. */ public function isOmnipotent() { return $this->omnipotent; } /** * Get an omnipotent user object for use in contexts where there is no acting * user, notably daemons. * * @return PhabricatorUser An omnipotent user. */ public static function getOmnipotentUser() { static $user = null; if (!$user) { $user = new PhabricatorUser(); $user->omnipotent = true; $user->makeEphemeral(); } return $user; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::POLICY_PUBLIC; case PhabricatorPolicyCapability::CAN_EDIT: if ($this->getIsSystemAgent()) { return PhabricatorPolicies::POLICY_ADMIN; } else { return PhabricatorPolicies::POLICY_NOONE; } } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getPHID() && ($viewer->getPHID() === $this->getPHID()); } public function describeAutomaticCapability($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_EDIT: return pht('Only you can edit your information.'); default: return null; } } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('user.fields'); } public function getCustomFieldBaseClass() { return 'PhabricatorUserCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $externals = id(new PhabricatorExternalAccount())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($externals as $external) { $external->delete(); } $prefs = id(new PhabricatorUserPreferences())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($prefs as $pref) { $pref->delete(); } $profiles = id(new PhabricatorUserProfile())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($profiles as $profile) { $profile->delete(); } $keys = id(new PhabricatorAuthSSHKey())->loadAllWhere( - 'userPHID = %s', + 'objectPHID = %s', $this->getPHID()); foreach ($keys as $key) { $key->delete(); } $emails = id(new PhabricatorUserEmail())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($emails as $email) { $email->delete(); } $sessions = id(new PhabricatorAuthSession())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($sessions as $session) { $session->delete(); } $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($factors as $factor) { $factor->delete(); } $this->saveTransaction(); } } diff --git a/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php b/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php index a2bf1bac8..de300477e 100644 --- a/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php +++ b/src/applications/settings/panel/PhabricatorSettingsPanelSSHKeys.php @@ -1,392 +1,389 @@ getUser(); $user = $this->getUser(); $generate = $request->getStr('generate'); if ($generate) { return $this->processGenerate($request); } $edit = $request->getStr('edit'); $delete = $request->getStr('delete'); if (!$edit && !$delete) { return $this->renderKeyListView($request); } $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, $this->getPanelURI()); $id = nonempty($edit, $delete); - - if ($id) { + if ($id && (int)$id) { $key = id(new PhabricatorAuthSSHKeyQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$key) { return new Aphront404Response(); } } else { - $key = new PhabricatorAuthSSHKey(); - $key->setUserPHID($user->getPHID()); + $key = id(new PhabricatorAuthSSHKey()) + ->setObjectPHID($user->getPHID()); } if ($delete) { return $this->processDelete($request, $key); } $e_name = true; $e_key = true; $errors = array(); $entire_key = $key->getEntireKey(); if ($request->isFormPost()) { $key->setName($request->getStr('name')); $entire_key = $request->getStr('key'); if (!strlen($entire_key)) { $errors[] = pht('You must provide an SSH Public Key.'); $e_key = pht('Required'); } else { try { $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($entire_key); $type = $public_key->getType(); $body = $public_key->getBody(); $comment = $public_key->getComment(); $key->setKeyType($type); $key->setKeyBody($body); - $key->setKeyHash(md5($body)); $key->setKeyComment($comment); $e_key = null; } catch (Exception $ex) { $e_key = pht('Invalid'); $errors[] = $ex->getMessage(); } } if (!strlen($key->getName())) { $errors[] = pht('You must name this public key.'); $e_name = pht('Required'); } else { $e_name = null; } if (!$errors) { try { $key->save(); return id(new AphrontRedirectResponse()) ->setURI($this->getPanelURI()); } catch (AphrontDuplicateKeyQueryException $ex) { $e_key = pht('Duplicate'); $errors[] = pht('This public key is already associated with a user '. 'account.'); } } } $is_new = !$key->getID(); if ($is_new) { $header = pht('Add New SSH Public Key'); $save = pht('Add Key'); } else { $header = pht('Edit SSH Public Key'); $save = pht('Save Changes'); } $form = id(new AphrontFormView()) ->setUser($viewer) ->addHiddenInput('edit', $is_new ? 'true' : $key->getID()) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Name')) ->setName('name') ->setValue($key->getName()) ->setError($e_name)) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel(pht('Public Key')) ->setName('key') ->setValue($entire_key) ->setError($e_key)) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton($this->getPanelURI()) ->setValue($save)); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($header) ->setFormErrors($errors) ->setForm($form); return $form_box; } private function renderKeyListView(AphrontRequest $request) { $user = $this->getUser(); $viewer = $request->getUser(); $keys = id(new PhabricatorAuthSSHKeyQuery()) ->setViewer($viewer) ->withObjectPHIDs(array($user->getPHID())) ->execute(); $rows = array(); foreach ($keys as $key) { $rows[] = array( phutil_tag( 'a', array( 'href' => $this->getPanelURI('?edit='.$key->getID()), ), $key->getName()), $key->getKeyComment(), $key->getKeyType(), phabricator_date($key->getDateCreated(), $viewer), phabricator_time($key->getDateCreated(), $viewer), javelin_tag( 'a', array( 'href' => $this->getPanelURI('?delete='.$key->getID()), 'class' => 'small grey button', 'sigil' => 'workflow', ), pht('Delete')), ); } $table = new AphrontTableView($rows); $table->setNoDataString(pht("You haven't added any SSH Public Keys.")); $table->setHeaders( array( pht('Name'), pht('Comment'), pht('Type'), pht('Created'), pht('Time'), '', )); $table->setColumnClasses( array( 'wide pri', '', '', '', 'right', 'action', )); $panel = new PHUIObjectBoxView(); $header = new PHUIHeaderView(); $upload_icon = id(new PHUIIconView()) ->setIconFont('fa-upload'); $upload_button = id(new PHUIButtonView()) ->setText(pht('Upload Public Key')) ->setHref($this->getPanelURI('?edit=true')) ->setTag('a') ->setIcon($upload_icon); try { PhabricatorSSHKeyGenerator::assertCanGenerateKeypair(); $can_generate = true; } catch (Exception $ex) { $can_generate = false; } $generate_icon = id(new PHUIIconView()) ->setIconFont('fa-lock'); $generate_button = id(new PHUIButtonView()) ->setText(pht('Generate Keypair')) ->setHref($this->getPanelURI('?generate=true')) ->setTag('a') ->setWorkflow(true) ->setDisabled(!$can_generate) ->setIcon($generate_icon); $header->setHeader(pht('SSH Public Keys')); $header->addActionLink($generate_button); $header->addActionLink($upload_button); $panel->setHeader($header); $panel->appendChild($table); return $panel; } private function processDelete( AphrontRequest $request, PhabricatorAuthSSHKey $key) { $viewer = $request->getUser(); $user = $this->getUser(); $name = phutil_tag('strong', array(), $key->getName()); if ($request->isDialogFormPost()) { $key->delete(); return id(new AphrontReloadResponse()) ->setURI($this->getPanelURI()); } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addHiddenInput('delete', $key->getID()) ->setTitle(pht('Really delete SSH Public Key?')) ->appendChild(phutil_tag('p', array(), pht( 'The key "%s" will be permanently deleted, and you will not longer be '. 'able to use the corresponding private key to authenticate.', $name))) ->addSubmitButton(pht('Delete Public Key')) ->addCancelButton($this->getPanelURI()); return id(new AphrontDialogResponse()) ->setDialog($dialog); } private function processGenerate(AphrontRequest $request) { $user = $this->getUser(); $viewer = $request->getUser(); $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, $this->getPanelURI()); $is_self = ($user->getPHID() == $viewer->getPHID()); if ($request->isFormPost()) { $keys = PhabricatorSSHKeyGenerator::generateKeypair(); list($public_key, $private_key) = $keys; $file = PhabricatorFile::buildFromFileDataOrHash( $private_key, array( 'name' => 'id_rsa_phabricator.key', 'ttl' => time() + (60 * 10), 'viewPolicy' => $viewer->getPHID(), )); $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($public_key); $type = $public_key->getType(); $body = $public_key->getBody(); $key = id(new PhabricatorAuthSSHKey()) - ->setUserPHID($user->getPHID()) + ->setObjectPHID($user->getPHID()) ->setName('id_rsa_phabricator') ->setKeyType($type) ->setKeyBody($body) - ->setKeyHash(md5($body)) ->setKeyComment(pht('Generated')) ->save(); // NOTE: We're disabling workflow on submit so the download works. We're // disabling workflow on cancel so the page reloads, showing the new // key. if ($is_self) { $what_happened = pht( 'The public key has been associated with your Phabricator '. 'account. Use the button below to download the private key.'); } else { $what_happened = pht( 'The public key has been associated with the %s account. '. 'Use the button below to download the private key.', phutil_tag('strong', array(), $user->getUsername())); } $dialog = id(new AphrontDialogView()) ->setTitle(pht('Download Private Key')) ->setUser($viewer) ->setDisableWorkflowOnCancel(true) ->setDisableWorkflowOnSubmit(true) ->setSubmitURI($file->getDownloadURI()) ->appendParagraph( pht( 'Successfully generated a new keypair.')) ->appendParagraph($what_happened) ->appendParagraph( pht( 'After you download the private key, it will be destroyed. '. 'You will not be able to retrieve it if you lose your copy.')) ->addSubmitButton(pht('Download Private Key')) ->addCancelButton($this->getPanelURI(), pht('Done')); return id(new AphrontDialogResponse()) ->setDialog($dialog); } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->addCancelButton($this->getPanelURI()); try { PhabricatorSSHKeyGenerator::assertCanGenerateKeypair(); if ($is_self) { $explain = pht( 'This will generate an SSH keypair, associate the public key '. 'with your account, and let you download the private key.'); } else { $explain = pht( 'This will generate an SSH keypair, associate the public key with '. 'the %s account, and let you download the private key.', phutil_tag('strong', array(), $user->getUsername())); } $dialog ->addHiddenInput('generate', true) ->setTitle(pht('Generate New Keypair')) ->appendParagraph($explain) ->appendParagraph( pht( 'Phabricator will not retain a copy of the private key.')) ->addSubmitButton(pht('Generate Keypair')); } catch (Exception $ex) { $dialog ->setTitle(pht('Unable to Generate Keys')) ->appendParagraph($ex->getMessage()); } return id(new AphrontDialogResponse()) ->setDialog($dialog); } }