diff --git a/resources/sql/autopatches/20180120.auth.03.vcsdata.sql b/resources/sql/autopatches/20180120.auth.03.vcsdata.sql index 3d1392bd5c..0cf73f84d9 100644 --- a/resources/sql/autopatches/20180120.auth.03.vcsdata.sql +++ b/resources/sql/autopatches/20180120.auth.03.vcsdata.sql @@ -1,5 +1,6 @@ INSERT INTO {$NAMESPACE}_auth.auth_password (objectPHID, phid, passwordType, passwordHash, isRevoked, dateCreated, dateModified) - SELECT userPHID, '', 'vcs', passwordHash, 0, dateCreated, dateModified + SELECT userPHID, CONCAT('XVCS', id), 'vcs', passwordHash, 0, + dateCreated, dateModified FROM {$NAMESPACE}_repository.repository_vcspassword; diff --git a/resources/sql/autopatches/20180120.auth.04.vcsphid.php b/resources/sql/autopatches/20180120.auth.04.vcsphid.php index 40b61a54b9..0a5dd0f067 100644 --- a/resources/sql/autopatches/20180120.auth.04.vcsphid.php +++ b/resources/sql/autopatches/20180120.auth.04.vcsphid.php @@ -1,22 +1,24 @@ establishConnection('w'); +$password_type = PhabricatorAuthPasswordPHIDType::TYPECONST; + foreach (new LiskMigrationIterator($table) as $row) { - if ($row->getPHID()) { + if (phid_get_type($row->getPHID()) == $password_type) { continue; } $new_phid = $row->generatePHID(); queryfx( $conn, 'UPDATE %T SET phid = %s WHERE id = %d', $table->getTableName(), $new_phid, $row->getID()); } diff --git a/resources/sql/autopatches/20180121.auth.03.accountdata.sql b/resources/sql/autopatches/20180121.auth.03.accountdata.sql new file mode 100644 index 0000000000..cbb4dc2a50 --- /dev/null +++ b/resources/sql/autopatches/20180121.auth.03.accountdata.sql @@ -0,0 +1,7 @@ +INSERT INTO {$NAMESPACE}_auth.auth_password + (objectPHID, phid, passwordType, passwordHash, passwordSalt, isRevoked, + dateCreated, dateModified) + SELECT phid, CONCAT('XACCOUNT', id), 'account', passwordHash, passwordSalt, 0, + dateCreated, dateModified + FROM {$NAMESPACE}_user.user + WHERE passwordHash != ''; diff --git a/resources/sql/autopatches/20180120.auth.04.vcsphid.php b/resources/sql/autopatches/20180121.auth.04.accountphid.php similarity index 59% copy from resources/sql/autopatches/20180120.auth.04.vcsphid.php copy to resources/sql/autopatches/20180121.auth.04.accountphid.php index 40b61a54b9..e0ebc04f4b 100644 --- a/resources/sql/autopatches/20180120.auth.04.vcsphid.php +++ b/resources/sql/autopatches/20180121.auth.04.accountphid.php @@ -1,22 +1,24 @@ establishConnection('w'); +$password_type = PhabricatorAuthPasswordPHIDType::TYPECONST; + foreach (new LiskMigrationIterator($table) as $row) { - if ($row->getPHID()) { + if (phid_get_type($row->getPHID()) == $password_type) { continue; } $new_phid = $row->generatePHID(); queryfx( $conn, 'UPDATE %T SET phid = %s WHERE id = %d', $table->getTableName(), $new_phid, $row->getID()); } diff --git a/src/applications/auth/controller/PhabricatorAuthRegisterController.php b/src/applications/auth/controller/PhabricatorAuthRegisterController.php index 5a792dc864..1138d52b33 100644 --- a/src/applications/auth/controller/PhabricatorAuthRegisterController.php +++ b/src/applications/auth/controller/PhabricatorAuthRegisterController.php @@ -1,721 +1,724 @@ getViewer(); $account_key = $request->getURIData('akey'); if ($request->getUser()->isLoggedIn()) { return id(new AphrontRedirectResponse())->setURI('/'); } $is_setup = false; if (strlen($account_key)) { $result = $this->loadAccountForRegistrationOrLinking($account_key); list($account, $provider, $response) = $result; $is_default = false; } else if ($this->isFirstTimeSetup()) { list($account, $provider, $response) = $this->loadSetupAccount(); $is_default = true; $is_setup = true; } else { list($account, $provider, $response) = $this->loadDefaultAccount(); $is_default = true; } if ($response) { return $response; } $invite = $this->loadInvite(); if (!$provider->shouldAllowRegistration()) { if ($invite) { // If the user has an invite, we allow them to register with any // provider, even a login-only provider. } else { // TODO: This is a routine error if you click "Login" on an external // auth source which doesn't allow registration. The error should be // more tailored. return $this->renderError( pht( 'The account you are attempting to register with uses an '. 'authentication provider ("%s") which does not allow '. 'registration. An administrator may have recently disabled '. 'registration with this provider.', $provider->getProviderName())); } } $errors = array(); $user = new PhabricatorUser(); $default_username = $account->getUsername(); $default_realname = $account->getRealName(); + $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; + $content_source = PhabricatorContentSource::newFromRequest($request); + $default_email = $account->getEmail(); if ($invite) { $default_email = $invite->getEmailAddress(); } if ($default_email !== null) { if (!PhabricatorUserEmail::isValidAddress($default_email)) { $errors[] = pht( 'The email address associated with this external account ("%s") is '. 'not a valid email address and can not be used to register a '. 'Phabricator account. Choose a different, valid address.', phutil_tag('strong', array(), $default_email)); $default_email = null; } } if ($default_email !== null) { // We should bypass policy here because e.g. limiting an application use // to a subset of users should not allow the others to overwrite // configured application emails. $application_email = id(new PhabricatorMetaMTAApplicationEmailQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAddresses(array($default_email)) ->executeOne(); if ($application_email) { $errors[] = pht( 'The email address associated with this account ("%s") is '. 'already in use by an application and can not be used to '. 'register a new Phabricator account. Choose a different, valid '. 'address.', phutil_tag('strong', array(), $default_email)); $default_email = null; } } $show_existing = null; if ($default_email !== null) { // If the account source provided an email, but it's not allowed by // the configuration, roadblock the user. Previously, we let the user // pick a valid email address instead, but this does not align well with // user expectation and it's not clear the cases it enables are valuable. // See discussion in T3472. if (!PhabricatorUserEmail::isAllowedAddress($default_email)) { $debug_email = new PHUIInvisibleCharacterView($default_email); return $this->renderError( array( pht( 'The account you are attempting to register with has an invalid '. 'email address (%s). This Phabricator install only allows '. 'registration with specific email addresses:', $debug_email), phutil_tag('br'), phutil_tag('br'), PhabricatorUserEmail::describeAllowedAddresses(), )); } // If the account source provided an email, but another account already // has that email, just pretend we didn't get an email. if ($default_email !== null) { $same_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $default_email); if ($same_email) { if ($invite) { // We're allowing this to continue. The fact that we loaded the // invite means that the address is nonprimary and unverified and // we're OK to steal it. } else { $show_existing = $default_email; $default_email = null; } } } } if ($show_existing !== null) { if (!$request->getInt('phase')) { return $this->newDialog() ->setTitle(pht('Email Address Already in Use')) ->addHiddenInput('phase', 1) ->appendParagraph( pht( 'You are creating a new Phabricator account linked to an '. 'existing external account from outside Phabricator.')) ->appendParagraph( pht( 'The email address ("%s") associated with the external account '. 'is already in use by an existing Phabricator account. Multiple '. 'Phabricator accounts may not have the same email address, so '. 'you can not use this email address to register a new '. 'Phabricator account.', phutil_tag('strong', array(), $show_existing))) ->appendParagraph( pht( 'If you want to register a new account, continue with this '. 'registration workflow and choose a new, unique email address '. 'for the new account.')) ->appendParagraph( pht( 'If you want to link an existing Phabricator account to this '. 'external account, do not continue. Instead: log in to your '. 'existing account, then go to "Settings" and link the account '. 'in the "External Accounts" panel.')) ->appendParagraph( pht( 'If you continue, you will create a new account. You will not '. 'be able to link this external account to an existing account.')) ->addCancelButton('/auth/login/', pht('Cancel')) ->addSubmitButton(pht('Create New Account')); } else { $errors[] = pht( 'The external account you are registering with has an email address '. 'that is already in use ("%s") by an existing Phabricator account. '. 'Choose a new, valid email address to register a new Phabricator '. 'account.', phutil_tag('strong', array(), $show_existing)); } } $profile = id(new PhabricatorRegistrationProfile()) ->setDefaultUsername($default_username) ->setDefaultEmail($default_email) ->setDefaultRealName($default_realname) ->setCanEditUsername(true) ->setCanEditEmail(($default_email === null)) ->setCanEditRealName(true) ->setShouldVerifyEmail(false); $event_type = PhabricatorEventType::TYPE_AUTH_WILLREGISTERUSER; $event_data = array( 'account' => $account, 'profile' => $profile, ); $event = id(new PhabricatorEvent($event_type, $event_data)) ->setUser($user); PhutilEventEngine::dispatchEvent($event); $default_username = $profile->getDefaultUsername(); $default_email = $profile->getDefaultEmail(); $default_realname = $profile->getDefaultRealName(); $can_edit_username = $profile->getCanEditUsername(); $can_edit_email = $profile->getCanEditEmail(); $can_edit_realname = $profile->getCanEditRealName(); $must_set_password = $provider->shouldRequireRegistrationPassword(); $can_edit_anything = $profile->getCanEditAnything() || $must_set_password; $force_verify = $profile->getShouldVerifyEmail(); // Automatically verify the administrator's email address during first-time // setup. if ($is_setup) { $force_verify = true; } $value_username = $default_username; $value_realname = $default_realname; $value_email = $default_email; $value_password = null; $require_real_name = PhabricatorEnv::getEnvConfig('user.require-real-name'); $e_username = strlen($value_username) ? null : true; $e_realname = $require_real_name ? true : null; $e_email = strlen($value_email) ? null : true; $e_password = true; $e_captcha = true; $skip_captcha = false; if ($invite) { // If the user is accepting an invite, assume they're trustworthy enough // that we don't need to CAPTCHA them. $skip_captcha = true; } $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; $from_invite = $request->getStr('invite'); if ($from_invite && $can_edit_username) { $value_username = $request->getStr('username'); $e_username = null; } $try_register = ($request->isFormPost() || !$can_edit_anything) && !$from_invite && ($request->getInt('phase') != 1); if ($try_register) { $errors = array(); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); if ($must_set_password && !$skip_captcha) { $e_captcha = pht('Again'); $captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request); if (!$captcha_ok) { $errors[] = pht('Captcha response is incorrect, try again.'); $e_captcha = pht('Invalid'); } } if ($can_edit_username) { $value_username = $request->getStr('username'); if (!strlen($value_username)) { $e_username = pht('Required'); $errors[] = pht('Username is required.'); } else if (!PhabricatorUser::validateUsername($value_username)) { $e_username = pht('Invalid'); $errors[] = PhabricatorUser::describeValidUsername(); } else { $e_username = null; } } if ($must_set_password) { $value_password = $request->getStr('password'); $value_confirm = $request->getStr('confirm'); - if (!strlen($value_password)) { - $e_password = pht('Required'); - $errors[] = pht('You must choose a password.'); - } else if ($value_password !== $value_confirm) { - $e_password = pht('No Match'); - $errors[] = pht('Password and confirmation must match.'); - } else if (strlen($value_password) < $min_len) { - $e_password = pht('Too Short'); - $errors[] = pht( - 'Password is too short (must be at least %d characters long).', - $min_len); - } else if ( - PhabricatorCommonPasswords::isCommonPassword($value_password)) { - - $e_password = pht('Very Weak'); - $errors[] = pht( - 'Password is pathologically weak. This password is one of the '. - 'most common passwords in use, and is extremely easy for '. - 'attackers to guess. You must choose a stronger password.'); - } else { + + $password_envelope = new PhutilOpaqueEnvelope($value_password); + $confirm_envelope = new PhutilOpaqueEnvelope($value_confirm); + + $engine = id(new PhabricatorAuthPasswordEngine()) + ->setViewer($user) + ->setContentSource($content_source) + ->setPasswordType($account_type) + ->setObject($user); + + try { + $engine->checkNewPassword($password_envelope, $confirm_envelope); $e_password = null; + } catch (PhabricatorAuthPasswordException $ex) { + $errors[] = $ex->getMessage(); + $e_password = $ex->getPasswordError(); } } if ($can_edit_email) { $value_email = $request->getStr('email'); if (!strlen($value_email)) { $e_email = pht('Required'); $errors[] = pht('Email is required.'); } else if (!PhabricatorUserEmail::isValidAddress($value_email)) { $e_email = pht('Invalid'); $errors[] = PhabricatorUserEmail::describeValidAddresses(); } else if (!PhabricatorUserEmail::isAllowedAddress($value_email)) { $e_email = pht('Disallowed'); $errors[] = PhabricatorUserEmail::describeAllowedAddresses(); } else { $e_email = null; } } if ($can_edit_realname) { $value_realname = $request->getStr('realName'); if (!strlen($value_realname) && $require_real_name) { $e_realname = pht('Required'); $errors[] = pht('Real name is required.'); } else { $e_realname = null; } } if (!$errors) { $image = $this->loadProfilePicture($account); if ($image) { $user->setProfileImagePHID($image->getPHID()); } try { $verify_email = false; if ($force_verify) { $verify_email = true; } if ($value_email === $default_email) { if ($account->getEmailVerified()) { $verify_email = true; } if ($provider->shouldTrustEmails()) { $verify_email = true; } if ($invite) { $verify_email = true; } } $email_obj = null; if ($invite) { // If we have a valid invite, this email may exist but be // nonprimary and unverified, so we'll reassign it. $email_obj = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $value_email); } if (!$email_obj) { $email_obj = id(new PhabricatorUserEmail()) ->setAddress($value_email); } $email_obj->setIsVerified((int)$verify_email); $user->setUsername($value_username); $user->setRealname($value_realname); if ($is_setup) { $must_approve = false; } else if ($invite) { $must_approve = false; } else { $must_approve = PhabricatorEnv::getEnvConfig( 'auth.require-approval'); } if ($must_approve) { $user->setIsApproved(0); } else { $user->setIsApproved(1); } if ($invite) { $allow_reassign_email = true; } else { $allow_reassign_email = false; } $user->openTransaction(); $editor = id(new PhabricatorUserEditor()) ->setActor($user); $editor->createNewUser($user, $email_obj, $allow_reassign_email); if ($must_set_password) { - $envelope = new PhutilOpaqueEnvelope($value_password); - $editor->changePassword($user, $envelope); + $password_object = PhabricatorAuthPassword::initializeNewPassword( + $user, + $account_type); + + $password_object + ->setPassword($password_envelope, $user) + ->save(); } if ($is_setup) { $editor->makeAdminUser($user, true); } $account->setUserPHID($user->getPHID()); $provider->willRegisterAccount($account); $account->save(); $user->saveTransaction(); if (!$email_obj->getIsVerified()) { $email_obj->sendVerificationEmail($user); } if ($must_approve) { $this->sendWaitingForApprovalEmail($user); } if ($invite) { $invite->setAcceptedByPHID($user->getPHID())->save(); } return $this->loginUser($user); } catch (AphrontDuplicateKeyQueryException $exception) { $same_username = id(new PhabricatorUser())->loadOneWhere( 'userName = %s', $user->getUserName()); $same_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $value_email); if ($same_username) { $e_username = pht('Duplicate'); $errors[] = pht('Another user already has that username.'); } if ($same_email) { // TODO: See T3340. $e_email = pht('Duplicate'); $errors[] = pht('Another user already has that email.'); } if (!$same_username && !$same_email) { throw $exception; } } } unset($unguarded); } $form = id(new AphrontFormView()) ->setUser($request->getUser()) ->addHiddenInput('phase', 2); if (!$is_default) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('External Account')) ->setValue( id(new PhabricatorAuthAccountView()) ->setUser($request->getUser()) ->setExternalAccount($account) ->setAuthProvider($provider))); } if ($can_edit_username) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Username')) ->setName('username') ->setValue($value_username) ->setError($e_username)); } else { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Username')) ->setValue($value_username) ->setError($e_username)); } if ($can_edit_realname) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Real Name')) ->setName('realName') ->setValue($value_realname) ->setError($e_realname)); } if ($must_set_password) { $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Password')) ->setName('password') ->setError($e_password)); $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Confirm Password')) ->setName('confirm') ->setError($e_password) ->setCaption( $min_len ? pht('Minimum length of %d characters.', $min_len) : null)); } if ($can_edit_email) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Email')) ->setName('email') ->setValue($value_email) ->setCaption(PhabricatorUserEmail::describeAllowedAddresses()) ->setError($e_email)); } if ($must_set_password && !$skip_captcha) { $form->appendChild( id(new AphrontFormRecaptchaControl()) ->setLabel(pht('Captcha')) ->setError($e_captcha)); } $submit = id(new AphrontFormSubmitControl()); if ($is_setup) { $submit ->setValue(pht('Create Admin Account')); } else { $submit ->addCancelButton($this->getApplicationURI('start/')) ->setValue(pht('Register Account')); } $form->appendChild($submit); $crumbs = $this->buildApplicationCrumbs(); if ($is_setup) { $crumbs->addTextCrumb(pht('Setup Admin Account')); $title = pht('Welcome to Phabricator'); } else { $crumbs->addTextCrumb(pht('Register')); $crumbs->addTextCrumb($provider->getProviderName()); $title = pht('Create a New Account'); } $crumbs->setBorder(true); $welcome_view = null; if ($is_setup) { $welcome_view = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) ->setTitle(pht('Welcome to Phabricator')) ->appendChild( pht( 'Installation is complete. Register your administrator account '. 'below to log in. You will be able to configure options and add '. 'other authentication mechanisms (like LDAP or OAuth) later on.')); } $object_box = id(new PHUIObjectBoxView()) ->setForm($form) ->setFormErrors($errors); $invite_header = null; if ($invite) { $invite_header = $this->renderInviteHeader($invite); } $header = id(new PHUIHeaderView()) ->setHeader($title); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter(array( $welcome_view, $invite_header, $object_box, )); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($view); } private function loadDefaultAccount() { $providers = PhabricatorAuthProvider::getAllEnabledProviders(); $account = null; $provider = null; $response = null; foreach ($providers as $key => $candidate_provider) { if (!$candidate_provider->shouldAllowRegistration()) { unset($providers[$key]); continue; } if (!$candidate_provider->isDefaultRegistrationProvider()) { unset($providers[$key]); } } if (!$providers) { $response = $this->renderError( pht( 'There are no configured default registration providers.')); return array($account, $provider, $response); } else if (count($providers) > 1) { $response = $this->renderError( pht('There are too many configured default registration providers.')); return array($account, $provider, $response); } $provider = head($providers); $account = $provider->getDefaultExternalAccount(); return array($account, $provider, $response); } private function loadSetupAccount() { $provider = new PhabricatorPasswordAuthProvider(); $provider->attachProviderConfig( id(new PhabricatorAuthProviderConfig()) ->setShouldAllowRegistration(1) ->setShouldAllowLogin(1) ->setIsEnabled(true)); $account = $provider->getDefaultExternalAccount(); $response = null; return array($account, $provider, $response); } private function loadProfilePicture(PhabricatorExternalAccount $account) { $phid = $account->getProfileImagePHID(); if (!$phid) { return null; } // NOTE: Use of omnipotent user is okay here because the registering user // can not control the field value, and we can't use their user object to // do meaningful policy checks anyway since they have not registered yet. // Reaching this means the user holds the account secret key and the // registration secret key, and thus has permission to view the image. $file = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($phid)) ->executeOne(); if (!$file) { return null; } $xform = PhabricatorFileTransform::getTransformByKey( PhabricatorFileThumbnailTransform::TRANSFORM_PROFILE); return $xform->executeTransform($file); } protected function renderError($message) { return $this->renderErrorPage( pht('Registration Failed'), array($message)); } private function sendWaitingForApprovalEmail(PhabricatorUser $user) { $title = '[Phabricator] '.pht( 'New User "%s" Awaiting Approval', $user->getUsername()); $body = new PhabricatorMetaMTAMailBody(); $body->addRawSection( pht( 'Newly registered user "%s" is awaiting account approval by an '. 'administrator.', $user->getUsername())); $body->addLinkSection( pht('APPROVAL QUEUE'), PhabricatorEnv::getProductionURI( '/people/query/approval/')); $body->addLinkSection( pht('DISABLE APPROVAL QUEUE'), PhabricatorEnv::getProductionURI( '/config/edit/auth.require-approval/')); $admins = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withIsAdmin(true) ->execute(); if (!$admins) { return; } $mail = id(new PhabricatorMetaMTAMail()) ->addTos(mpull($admins, 'getPHID')) ->setSubject($title) ->setBody($body->render()) ->saveAndSend(); } } diff --git a/src/applications/auth/controller/PhabricatorAuthSetPasswordController.php b/src/applications/auth/controller/PhabricatorAuthSetPasswordController.php index 0db23e52a7..1218276043 100644 --- a/src/applications/auth/controller/PhabricatorAuthSetPasswordController.php +++ b/src/applications/auth/controller/PhabricatorAuthSetPasswordController.php @@ -1,155 +1,157 @@ getViewer(); if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) { return new Aphront404Response(); } $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $viewer, $request, '/'); $key = $request->getStr('key'); $password_type = PhabricatorAuthPasswordResetTemporaryTokenType::TOKENTYPE; if (!$key) { return new Aphront404Response(); } $auth_token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($viewer) ->withTokenResources(array($viewer->getPHID())) ->withTokenTypes(array($password_type)) ->withTokenCodes(array(PhabricatorHash::weakDigest($key))) ->withExpired(false) ->executeOne(); if (!$auth_token) { return new Aphront404Response(); } - $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); - $min_len = (int)$min_len; + $content_source = PhabricatorContentSource::newFromRequest($request); + $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; + + $password_objects = id(new PhabricatorAuthPasswordQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(array($viewer->getPHID())) + ->withPasswordTypes(array($account_type)) + ->withIsRevoked(false) + ->execute(); + if ($password_objects) { + $password_object = head($password_objects); + $has_password = true; + } else { + $password_object = PhabricatorAuthPassword::initializeNewPassword( + $viewer, + $account_type); + $has_password = false; + } + + $engine = id(new PhabricatorAuthPasswordEngine()) + ->setViewer($viewer) + ->setContentSource($content_source) + ->setPasswordType($account_type) + ->setObject($viewer); $e_password = true; $e_confirm = true; $errors = array(); if ($request->isFormPost()) { $password = $request->getStr('password'); $confirm = $request->getStr('confirm'); - $e_password = null; - $e_confirm = null; - - if (!strlen($password)) { - $errors[] = pht('You must choose a password or skip this step.'); - $e_password = pht('Required'); - } else if (strlen($password) < $min_len) { - $errors[] = pht( - 'The selected password is too short. Passwords must be a minimum '. - 'of %s characters.', - new PhutilNumber($min_len)); - $e_password = pht('Too Short'); - } else if (!strlen($confirm)) { - $errors[] = pht('You must confirm the selecetd password.'); - $e_confirm = pht('Required'); - } else if ($password !== $confirm) { - $errors[] = pht('The password and confirmation do not match.'); - $e_password = pht('Invalid'); - $e_confirm = pht('Invalid'); - } else if (PhabricatorCommonPasswords::isCommonPassword($password)) { - $e_password = pht('Very Weak'); - $errors[] = pht( - 'The selected password is very weak: it is one of the most common '. - 'passwords in use. Choose a stronger password.'); + $password_envelope = new PhutilOpaqueEnvelope($password); + $confirm_envelope = new PhutilOpaqueEnvelope($confirm); + + try { + $engine->checkNewPassword($password_envelope, $confirm_envelope, true); + $e_password = null; + $e_confirm = null; + } catch (PhabricatorAuthPasswordException $ex) { + $errors[] = $ex->getMessage(); + $e_password = $ex->getPasswordError(); + $e_confirm = $ex->getConfirmError(); } if (!$errors) { - $envelope = new PhutilOpaqueEnvelope($password); - - // 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(); - - id(new PhabricatorUserEditor()) - ->setActor($viewer) - ->changePassword($viewer, $envelope); - - unset($unguarded); + $password_object + ->setPassword($password_envelope, $viewer) + ->save(); // Destroy the token. $auth_token->delete(); return id(new AphrontRedirectResponse())->setURI('/'); } } + $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); + $min_len = (int)$min_len; + $len_caption = null; if ($min_len) { $len_caption = pht('Minimum password length: %d characters.', $min_len); } - if ($viewer->hasPassword()) { + if ($has_password) { $title = pht('Reset Password'); $crumb = pht('Reset Password'); $submit = pht('Reset Password'); } else { $title = pht('Set Password'); $crumb = pht('Set Password'); $submit = pht('Set Account Password'); } $form = id(new AphrontFormView()) ->setViewer($viewer) ->addHiddenInput('key', $key) ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setLabel(pht('New Password')) ->setError($e_password) ->setName('password')) ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setLabel(pht('Confirm Password')) ->setCaption($len_caption) ->setError($e_confirm) ->setName('confirm')) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton('/', pht('Skip This Step')) ->setValue($submit)); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setFormErrors($errors) ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) ->setForm($form); $main_view = id(new PHUITwoColumnView()) ->setFooter($form_box); $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb($crumb) ->setBorder(true); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($main_view); } } diff --git a/src/applications/auth/engine/PhabricatorAuthPasswordEngine.php b/src/applications/auth/engine/PhabricatorAuthPasswordEngine.php index 66d32ad4dc..595a523083 100644 --- a/src/applications/auth/engine/PhabricatorAuthPasswordEngine.php +++ b/src/applications/auth/engine/PhabricatorAuthPasswordEngine.php @@ -1,313 +1,320 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function getContentSource() { return $this->contentSource; } public function setObject(PhabricatorPasswordHashInterface $object) { $this->object = $object; return $this; } public function getObject() { return $this->object; } public function setPasswordType($password_type) { $this->passwordType = $password_type; return $this; } public function getPasswordType() { return $this->passwordType; } public function setUpgradeHashers($upgrade_hashers) { $this->upgradeHashers = $upgrade_hashers; return $this; } public function getUpgradeHashers() { return $this->upgradeHashers; } public function checkNewPassword( PhutilOpaqueEnvelope $password, PhutilOpaqueEnvelope $confirm, $can_skip = false) { $raw_password = $password->openEnvelope(); if (!strlen($raw_password)) { if ($can_skip) { throw new PhabricatorAuthPasswordException( pht('You must choose a password or skip this step.'), pht('Required')); } else { throw new PhabricatorAuthPasswordException( pht('You must choose a password.'), pht('Required')); } } $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; if ($min_len) { if (strlen($raw_password) < $min_len) { throw new PhabricatorAuthPasswordException( pht( 'The selected password is too short. Passwords must be a minimum '. 'of %s characters long.', new PhutilNumber($min_len)), pht('Too Short')); } } $raw_confirm = $confirm->openEnvelope(); if (!strlen($raw_confirm)) { throw new PhabricatorAuthPasswordException( pht('You must confirm the selected password.'), null, pht('Required')); } if ($raw_password !== $raw_confirm) { throw new PhabricatorAuthPasswordException( pht('The password and confirmation do not match.'), pht('Invalid'), pht('Invalid')); } if (PhabricatorCommonPasswords::isCommonPassword($raw_password)) { throw new PhabricatorAuthPasswordException( pht( 'The selected password is very weak: it is one of the most common '. 'passwords in use. Choose a stronger password.'), pht('Very Weak')); } - if ($this->isRevokedPassword($password)) { - throw new PhabricatorAuthPasswordException( - pht( - 'The password you entered has been revoked. You can not reuse '. - 'a password which has been revoked. Choose a new password.'), - pht('Revoked')); - } + // If we're creating a brand new object (like registering a new user) + // and it does not have a PHID yet, it isn't possible for it to have any + // revoked passwords or colliding passwords either, so we can skip these + // checks. - if (!$this->isUniquePassword($password)) { - throw new PhabricatorAuthPasswordException( - pht( - 'The password you entered is the same as another password '. - 'associated with your account. Each password must be unique.'), - pht('Not Unique')); + if ($this->getObject()->getPHID()) { + if ($this->isRevokedPassword($password)) { + throw new PhabricatorAuthPasswordException( + pht( + 'The password you entered has been revoked. You can not reuse '. + 'a password which has been revoked. Choose a new password.'), + pht('Revoked')); + } + + if (!$this->isUniquePassword($password)) { + throw new PhabricatorAuthPasswordException( + pht( + 'The password you entered is the same as another password '. + 'associated with your account. Each password must be unique.'), + pht('Not Unique')); + } } } public function isValidPassword(PhutilOpaqueEnvelope $envelope) { $this->requireSetup(); $password_type = $this->getPasswordType(); $passwords = $this->newQuery() ->withPasswordTypes(array($password_type)) ->withIsRevoked(false) ->execute(); $matches = $this->getMatches($envelope, $passwords); if (!$matches) { return false; } if ($this->shouldUpgradeHashers()) { $this->upgradeHashers($envelope, $matches); } return true; } public function isUniquePassword(PhutilOpaqueEnvelope $envelope) { $this->requireSetup(); $password_type = $this->getPasswordType(); // To test that the password is unique, we're loading all active and // revoked passwords for all roles for the given user, then throwing out // the active passwords for the current role (so a password can't // collide with itself). // Note that two different objects can have the same password (say, // users @alice and @bailey). We're only preventing @alice from using // the same password for everything. $passwords = $this->newQuery() ->execute(); foreach ($passwords as $key => $password) { $same_type = ($password->getPasswordType() === $password_type); $is_active = !$password->getIsRevoked(); if ($same_type && $is_active) { unset($passwords[$key]); } } $matches = $this->getMatches($envelope, $passwords); return !$matches; } public function isRevokedPassword(PhutilOpaqueEnvelope $envelope) { $this->requireSetup(); // To test if a password is revoked, we're loading all revoked passwords // across all roles for the given user. If a password was revoked in one // role, you can't reuse it in a different role. $passwords = $this->newQuery() ->withIsRevoked(true) ->execute(); $matches = $this->getMatches($envelope, $passwords); return (bool)$matches; } private function requireSetup() { if (!$this->getObject()) { throw new PhutilInvalidStateException('setObject'); } if (!$this->getPasswordType()) { throw new PhutilInvalidStateException('setPasswordType'); } if (!$this->getViewer()) { throw new PhutilInvalidStateException('setViewer'); } if ($this->shouldUpgradeHashers()) { if (!$this->getContentSource()) { throw new PhutilInvalidStateException('setContentSource'); } } } private function shouldUpgradeHashers() { if (!$this->getUpgradeHashers()) { return false; } if (PhabricatorEnv::isReadOnly()) { // Don't try to upgrade hashers if we're in read-only mode, since we // won't be able to write the new hash to the database. return false; } return true; } private function newQuery() { $viewer = $this->getViewer(); $object = $this->getObject(); $password_type = $this->getPasswordType(); return id(new PhabricatorAuthPasswordQuery()) ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())); } private function getMatches( PhutilOpaqueEnvelope $envelope, array $passwords) { $object = $this->getObject(); $matches = array(); foreach ($passwords as $password) { try { $is_match = $password->comparePassword($envelope, $object); } catch (PhabricatorPasswordHasherUnavailableException $ex) { $is_match = false; } if ($is_match) { $matches[] = $password; } } return $matches; } private function upgradeHashers( PhutilOpaqueEnvelope $envelope, array $passwords) { assert_instances_of($passwords, 'PhabricatorAuthPassword'); $need_upgrade = array(); foreach ($passwords as $password) { if (!$password->canUpgrade()) { continue; } $need_upgrade[] = $password; } if (!$need_upgrade) { return; } $upgrade_type = PhabricatorAuthPasswordUpgradeTransaction::TRANSACTIONTYPE; $viewer = $this->getViewer(); $content_source = $this->getContentSource(); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); foreach ($need_upgrade as $password) { // This does the actual upgrade. We then apply a transaction to make // the upgrade more visible and auditable. $old_hasher = $password->getHasher(); $password->upgradePasswordHasher($envelope, $this->getObject()); $new_hasher = $password->getHasher(); $xactions = array(); $xactions[] = $password->getApplicationTransactionTemplate() ->setTransactionType($upgrade_type) ->setNewValue($new_hasher->getHashName()); $editor = $password->getApplicationTransactionEditor() ->setActor($viewer) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setContentSource($content_source) ->setOldHasher($old_hasher) ->applyTransactions($password, $xactions); } unset($unguarded); } } diff --git a/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php b/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php index 206eba4578..75deb9dde5 100644 --- a/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php @@ -1,380 +1,375 @@ 'color: #009900', ), pht('Yes')); $no = phutil_tag( 'strong', array( 'style' => 'color: #990000', ), pht('Not Installed')); $best_hasher_name = null; try { $best_hasher = PhabricatorPasswordHasher::getBestHasher(); $best_hasher_name = $best_hasher->getHashName(); } catch (PhabricatorPasswordHasherUnavailableException $ex) { // There are no suitable hashers. The user might be able to enable some, // so we don't want to fatal here. We'll fatal when users try to actually // use this stuff if it isn't fixed before then. Until then, we just // don't highlight a row. In practice, at least one hasher should always // be available. } $rows = array(); $rowc = array(); foreach ($hashers as $hasher) { $is_installed = $hasher->canHashPasswords(); $rows[] = array( $hasher->getHumanReadableName(), $hasher->getHashName(), $hasher->getHumanReadableStrength(), ($is_installed ? $yes : $no), ($is_installed ? null : $hasher->getInstallInstructions()), ); $rowc[] = ($best_hasher_name == $hasher->getHashName()) ? 'highlighted' : null; } $table = new AphrontTableView($rows); $table->setRowClasses($rowc); $table->setHeaders( array( pht('Algorithm'), pht('Name'), pht('Strength'), pht('Installed'), pht('Install Instructions'), )); $table->setColumnClasses( array( '', '', '', '', 'wide', )); $header = id(new PHUIHeaderView()) ->setHeader(pht('Password Hash Algorithms')) ->setSubheader( pht( 'Stronger algorithms are listed first. The highlighted algorithm '. 'will be used when storing new hashes. Older hashes will be '. 'upgraded to the best algorithm over time.')); return id(new PHUIObjectBoxView()) ->setHeader($header) ->setTable($table); } public function getDescriptionForCreate() { return pht( 'Allow users to log in or register using a username and password.'); } public function getAdapter() { if (!$this->adapter) { $adapter = new PhutilEmptyAuthAdapter(); $adapter->setAdapterType('password'); $adapter->setAdapterDomain('self'); $this->adapter = $adapter; } return $this->adapter; } public function getLoginOrder() { // Make sure username/password appears first if it is enabled. return '100-'.$this->getProviderName(); } public function shouldAllowAccountLink() { return false; } public function shouldAllowAccountUnlink() { return false; } public function isDefaultRegistrationProvider() { return true; } public function buildLoginForm( PhabricatorAuthStartController $controller) { $request = $controller->getRequest(); return $this->renderPasswordLoginForm($request); } public function buildInviteForm( PhabricatorAuthStartController $controller) { $request = $controller->getRequest(); $viewer = $request->getViewer(); $form = id(new AphrontFormView()) ->setUser($viewer) ->addHiddenInput('invite', true) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Username')) ->setName('username')); $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->setTitle(pht('Register an Account')) ->appendForm($form) ->setSubmitURI('/auth/register/') ->addSubmitButton(pht('Continue')); return $dialog; } public function buildLinkForm( PhabricatorAuthLinkController $controller) { throw new Exception(pht("Password providers can't be linked.")); } private function renderPasswordLoginForm( AphrontRequest $request, $require_captcha = false, $captcha_valid = false) { $viewer = $request->getUser(); $dialog = id(new AphrontDialogView()) ->setSubmitURI($this->getLoginURI()) ->setUser($viewer) ->setTitle(pht('Log In')) ->addSubmitButton(pht('Log In')); if ($this->shouldAllowRegistration()) { $dialog->addCancelButton( '/auth/register/', pht('Register New Account')); } $dialog->addFooter( phutil_tag( 'a', array( 'href' => '/login/email/', ), pht('Forgot your password?'))); $v_user = nonempty( $request->getStr('username'), $request->getCookie(PhabricatorCookies::COOKIE_USERNAME)); $e_user = null; $e_pass = null; $e_captcha = null; $errors = array(); if ($require_captcha && !$captcha_valid) { if (AphrontFormRecaptchaControl::hasCaptchaResponse($request)) { $e_captcha = pht('Invalid'); $errors[] = pht('CAPTCHA was not entered correctly.'); } else { $e_captcha = pht('Required'); $errors[] = pht( 'Too many login failures recently. You must '. 'submit a CAPTCHA with your login request.'); } } else if ($request->isHTTPPost()) { // NOTE: This is intentionally vague so as not to disclose whether a // given username or email is registered. $e_user = pht('Invalid'); $e_pass = pht('Invalid'); $errors[] = pht('Username or password are incorrect.'); } if ($errors) { $errors = id(new PHUIInfoView())->setErrors($errors); } $form = id(new PHUIFormLayoutView()) ->setFullWidth(true) ->appendChild($errors) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Username or Email')) ->setName('username') ->setValue($v_user) ->setError($e_user)) ->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Password')) ->setName('password') ->setError($e_pass)); if ($require_captcha) { $form->appendChild( id(new AphrontFormRecaptchaControl()) ->setError($e_captcha)); } $dialog->appendChild($form); return $dialog; } public function processLoginRequest( PhabricatorAuthLoginController $controller) { $request = $controller->getRequest(); $viewer = $request->getUser(); + $content_source = PhabricatorContentSource::newFromRequest($request); $require_captcha = false; $captcha_valid = false; if (AphrontFormRecaptchaControl::isRecaptchaEnabled()) { $failed_attempts = PhabricatorUserLog::loadRecentEventsFromThisIP( PhabricatorUserLog::ACTION_LOGIN_FAILURE, 60 * 15); if (count($failed_attempts) > 5) { $require_captcha = true; $captcha_valid = AphrontFormRecaptchaControl::processCaptcha($request); } } $response = null; $account = null; $log_user = null; if ($request->isFormPost()) { if (!$require_captcha || $captcha_valid) { $username_or_email = $request->getStr('username'); if (strlen($username_or_email)) { $user = id(new PhabricatorUser())->loadOneWhere( 'username = %s', $username_or_email); if (!$user) { $user = PhabricatorUser::loadOneWithEmailAddress( $username_or_email); } if ($user) { $envelope = new PhutilOpaqueEnvelope($request->getStr('password')); - if ($user->comparePassword($envelope)) { - $account = $this->loadOrCreateAccount($user->getPHID()); - $log_user = $user; - - // If the user's password is stored using a less-than-optimal - // hash, upgrade them to the strongest available hash. - $hash_envelope = new PhutilOpaqueEnvelope( - $user->getPasswordHash()); - if (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) { - $user->setPassword($envelope); + $engine = id(new PhabricatorAuthPasswordEngine()) + ->setViewer($user) + ->setContentSource($content_source) + ->setPasswordType(PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT) + ->setObject($user); - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - $user->save(); - unset($unguarded); - } + if ($engine->isValidPassword($envelope)) { + $account = $this->loadOrCreateAccount($user->getPHID()); + $log_user = $user; } } } } } if (!$account) { if ($request->isFormPost()) { $log = PhabricatorUserLog::initializeNewLog( null, $log_user ? $log_user->getPHID() : null, PhabricatorUserLog::ACTION_LOGIN_FAILURE); $log->save(); } $request->clearCookie(PhabricatorCookies::COOKIE_USERNAME); $response = $controller->buildProviderPageResponse( $this, $this->renderPasswordLoginForm( $request, $require_captcha, $captcha_valid)); } return array($account, $response); } public function shouldRequireRegistrationPassword() { return true; } public function getDefaultExternalAccount() { $adapter = $this->getAdapter(); return id(new PhabricatorExternalAccount()) ->setAccountType($adapter->getAdapterType()) ->setAccountDomain($adapter->getAdapterDomain()); } protected function willSaveAccount(PhabricatorExternalAccount $account) { parent::willSaveAccount($account); $account->setUserPHID($account->getAccountID()); } public function willRegisterAccount(PhabricatorExternalAccount $account) { parent::willRegisterAccount($account); $account->setAccountID($account->getUserPHID()); } public static function getPasswordProvider() { $providers = self::getAllEnabledProviders(); foreach ($providers as $provider) { if ($provider instanceof PhabricatorPasswordAuthProvider) { return $provider; } } return null; } public function willRenderLinkedAccount( PhabricatorUser $viewer, PHUIObjectItemView $item, PhabricatorExternalAccount $account) { return; } public function shouldAllowAccountRefresh() { return false; } public function shouldAllowEmailTrustConfiguration() { return false; } } diff --git a/src/applications/auth/storage/PhabricatorAuthPassword.php b/src/applications/auth/storage/PhabricatorAuthPassword.php index 81e1af7e5c..3a88b990bf 100644 --- a/src/applications/auth/storage/PhabricatorAuthPassword.php +++ b/src/applications/auth/storage/PhabricatorAuthPassword.php @@ -1,224 +1,224 @@ setObjectPHID($object->getPHID()) ->attachObject($object) ->setPasswordType($type) ->setIsRevoked(0); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'passwordType' => 'text64', 'passwordHash' => 'text128', 'passwordSalt' => 'text64', 'isRevoked' => 'bool', ), self::CONFIG_KEY_SCHEMA => array( 'key_role' => array( 'columns' => array('objectPHID', 'passwordType'), ), ), ) + parent::getConfiguration(); } public function getPHIDType() { return PhabricatorAuthPasswordPHIDType::TYPECONST; } public function getObject() { return $this->assertAttached($this->object); } public function attachObject($object) { $this->object = $object; return $this; } public function getHasher() { $hash = $this->newPasswordEnvelope(); return PhabricatorPasswordHasher::getHasherForHash($hash); } public function canUpgrade() { $hash = $this->newPasswordEnvelope(); return PhabricatorPasswordHasher::canUpgradeHash($hash); } public function upgradePasswordHasher( PhutilOpaqueEnvelope $envelope, PhabricatorPasswordHashInterface $object) { // Before we make changes, double check that this is really the correct // password. It could be really bad if we "upgraded" a password and changed // the secret! if (!$this->comparePassword($envelope, $object)) { throw new Exception( pht( 'Attempting to upgrade password hasher, but the password for the '. 'upgrade is not the stored credential!')); } return $this->setPassword($envelope, $object); } public function setPassword( PhutilOpaqueEnvelope $password, PhabricatorPasswordHashInterface $object) { $hasher = PhabricatorPasswordHasher::getBestHasher(); return $this->setPasswordWithHasher($password, $object, $hasher); } public function setPasswordWithHasher( PhutilOpaqueEnvelope $password, PhabricatorPasswordHashInterface $object, PhabricatorPasswordHasher $hasher) { if (!strlen($password->openEnvelope())) { throw new Exception( pht('Attempting to set an empty password!')); } // Generate (or regenerate) the salt first. $new_salt = Filesystem::readRandomCharacters(64); $this->setPasswordSalt($new_salt); $digest = $this->digestPassword($password, $object); $hash = $hasher->getPasswordHashForStorage($digest); $raw_hash = $hash->openEnvelope(); return $this->setPasswordHash($raw_hash); } public function comparePassword( PhutilOpaqueEnvelope $password, PhabricatorPasswordHashInterface $object) { $digest = $this->digestPassword($password, $object); $hash = $this->newPasswordEnvelope(); return PhabricatorPasswordHasher::comparePassword($digest, $hash); } - private function newPasswordEnvelope() { + public function newPasswordEnvelope() { return new PhutilOpaqueEnvelope($this->getPasswordHash()); } private function digestPassword( PhutilOpaqueEnvelope $password, PhabricatorPasswordHashInterface $object) { $object_phid = $object->getPHID(); if ($this->getObjectPHID() !== $object->getPHID()) { throw new Exception( pht( 'This password is associated with an object PHID ("%s") for '. 'a different object than the provided one ("%s").', $this->getObjectPHID(), $object->getPHID())); } $digest = $object->newPasswordDigest($password, $this); if (!($digest instanceof PhutilOpaqueEnvelope)) { throw new Exception( pht( 'Failed to digest password: object ("%s") did not return an '. 'opaque envelope with a password digest.', $object->getPHID())); } return $digest; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return PhabricatorPolicies::getMostOpenPolicy(); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { return array( array($this->getObject(), PhabricatorPolicyCapability::CAN_VIEW), ); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->delete(); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorAuthPasswordEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorAuthPasswordTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } } diff --git a/src/applications/people/editor/PhabricatorUserEditor.php b/src/applications/people/editor/PhabricatorUserEditor.php index 20c9c01370..cda9c1e41b 100644 --- a/src/applications/people/editor/PhabricatorUserEditor.php +++ b/src/applications/people/editor/PhabricatorUserEditor.php @@ -1,746 +1,719 @@ getID()) { throw new Exception(pht('User has already been created!')); } $is_reassign = false; if ($email->getID()) { if ($allow_reassign) { if ($email->getIsPrimary()) { throw new Exception( pht('Primary email addresses can not be reassigned.')); } $is_reassign = true; } else { throw new Exception(pht('Email has already been created!')); } } if (!PhabricatorUser::validateUsername($user->getUsername())) { $valid = PhabricatorUser::describeValidUsername(); throw new Exception(pht('Username is invalid! %s', $valid)); } // Always set a new user's email address to primary. $email->setIsPrimary(1); // If the primary address is already verified, also set the verified flag // on the user themselves. if ($email->getIsVerified()) { $user->setIsEmailVerified(1); } $this->willAddEmail($email); $user->openTransaction(); try { $user->save(); $email->setUserPHID($user->getPHID()); $email->save(); } catch (AphrontDuplicateKeyQueryException $ex) { // We might have written the user but failed to write the email; if // so, erase the IDs we attached. $user->setID(null); $user->setPHID(null); $user->killTransaction(); throw $ex; } $log = PhabricatorUserLog::initializeNewLog( $this->requireActor(), $user->getPHID(), PhabricatorUserLog::ACTION_CREATE); $log->setNewValue($email->getAddress()); $log->save(); if ($is_reassign) { $log = PhabricatorUserLog::initializeNewLog( $this->requireActor(), $user->getPHID(), PhabricatorUserLog::ACTION_EMAIL_REASSIGN); $log->setNewValue($email->getAddress()); $log->save(); } $user->saveTransaction(); if ($email->getIsVerified()) { $this->didVerifyEmail($user, $email); } return $this; } /** * @task edit */ public function updateUser( PhabricatorUser $user, PhabricatorUserEmail $email = null) { if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } $user->openTransaction(); $user->save(); if ($email) { $email->save(); } $log = PhabricatorUserLog::initializeNewLog( $this->requireActor(), $user->getPHID(), PhabricatorUserLog::ACTION_EDIT); $log->save(); $user->saveTransaction(); return $this; } - /** - * @task edit - */ - public function changePassword( - PhabricatorUser $user, - PhutilOpaqueEnvelope $envelope) { - - if (!$user->getID()) { - throw new Exception(pht('User has not been created yet!')); - } - - $user->openTransaction(); - $user->reload(); - - $user->setPassword($envelope); - $user->save(); - - $log = PhabricatorUserLog::initializeNewLog( - $this->requireActor(), - $user->getPHID(), - PhabricatorUserLog::ACTION_CHANGE_PASSWORD); - $log->save(); - - $user->saveTransaction(); - } - - /** * @task edit */ public function changeUsername(PhabricatorUser $user, $username) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } if (!PhabricatorUser::validateUsername($username)) { $valid = PhabricatorUser::describeValidUsername(); throw new Exception(pht('Username is invalid! %s', $valid)); } $old_username = $user->getUsername(); $user->openTransaction(); $user->reload(); $user->setUsername($username); try { $user->save(); } catch (AphrontDuplicateKeyQueryException $ex) { $user->setUsername($old_username); $user->killTransaction(); throw $ex; } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_CHANGE_USERNAME); $log->setOldValue($old_username); $log->setNewValue($username); $log->save(); $user->saveTransaction(); // The SSH key cache currently includes usernames, so dirty it. See T12554 // for discussion. PhabricatorAuthSSHKeyQuery::deleteSSHKeyCache(); $user->sendUsernameChangeEmail($actor, $old_username); } /* -( Editing Roles )------------------------------------------------------ */ /** * @task role */ public function makeAdminUser(PhabricatorUser $user, $admin) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); if ($user->getIsAdmin() == $admin) { $user->endWriteLocking(); $user->killTransaction(); return $this; } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_ADMIN); $log->setOldValue($user->getIsAdmin()); $log->setNewValue($admin); $user->setIsAdmin((int)$admin); $user->save(); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); return $this; } /** * @task role */ public function makeSystemAgentUser(PhabricatorUser $user, $system_agent) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); if ($user->getIsSystemAgent() == $system_agent) { $user->endWriteLocking(); $user->killTransaction(); return $this; } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_SYSTEM_AGENT); $log->setOldValue($user->getIsSystemAgent()); $log->setNewValue($system_agent); $user->setIsSystemAgent((int)$system_agent); $user->save(); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); return $this; } /** * @task role */ public function makeMailingListUser(PhabricatorUser $user, $mailing_list) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); if ($user->getIsMailingList() == $mailing_list) { $user->endWriteLocking(); $user->killTransaction(); return $this; } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_MAILING_LIST); $log->setOldValue($user->getIsMailingList()); $log->setNewValue($mailing_list); $user->setIsMailingList((int)$mailing_list); $user->save(); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); return $this; } /** * @task role */ public function disableUser(PhabricatorUser $user, $disable) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); if ($user->getIsDisabled() == $disable) { $user->endWriteLocking(); $user->killTransaction(); return $this; } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_DISABLE); $log->setOldValue($user->getIsDisabled()); $log->setNewValue($disable); $user->setIsDisabled((int)$disable); $user->save(); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); return $this; } /** * @task role */ public function approveUser(PhabricatorUser $user, $approve) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); if ($user->getIsApproved() == $approve) { $user->endWriteLocking(); $user->killTransaction(); return $this; } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_APPROVE); $log->setOldValue($user->getIsApproved()); $log->setNewValue($approve); $user->setIsApproved($approve); $user->save(); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); return $this; } /* -( Adding, Removing and Changing Email )-------------------------------- */ /** * @task email */ public function addEmail( PhabricatorUser $user, PhabricatorUserEmail $email) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } if ($email->getID()) { throw new Exception(pht('Email has already been created!')); } // Use changePrimaryEmail() to change primary email. $email->setIsPrimary(0); $email->setUserPHID($user->getPHID()); $this->willAddEmail($email); $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); try { $email->save(); } catch (AphrontDuplicateKeyQueryException $ex) { $user->endWriteLocking(); $user->killTransaction(); throw $ex; } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_EMAIL_ADD); $log->setNewValue($email->getAddress()); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); return $this; } /** * @task email */ public function removeEmail( PhabricatorUser $user, PhabricatorUserEmail $email) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } if (!$email->getID()) { throw new Exception(pht('Email has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); $email->reload(); if ($email->getIsPrimary()) { throw new Exception(pht("Can't remove primary email!")); } if ($email->getUserPHID() != $user->getPHID()) { throw new Exception(pht('Email not owned by user!')); } $email->delete(); $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_EMAIL_REMOVE); $log->setOldValue($email->getAddress()); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); $this->revokePasswordResetLinks($user); return $this; } /** * @task email */ public function changePrimaryEmail( PhabricatorUser $user, PhabricatorUserEmail $email) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } if (!$email->getID()) { throw new Exception(pht('Email has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); $email->reload(); if ($email->getUserPHID() != $user->getPHID()) { throw new Exception(pht('User does not own email!')); } if ($email->getIsPrimary()) { throw new Exception(pht('Email is already primary!')); } if (!$email->getIsVerified()) { throw new Exception(pht('Email is not verified!')); } $old_primary = $user->loadPrimaryEmail(); if ($old_primary) { $old_primary->setIsPrimary(0); $old_primary->save(); } $email->setIsPrimary(1); $email->save(); // If the user doesn't have the verified flag set on their account // yet, set it. We've made sure the email is verified above. See // T12635 for discussion. if (!$user->getIsEmailVerified()) { $user->setIsEmailVerified(1); $user->save(); } $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_EMAIL_PRIMARY); $log->setOldValue($old_primary ? $old_primary->getAddress() : null); $log->setNewValue($email->getAddress()); $log->save(); $user->endWriteLocking(); $user->saveTransaction(); if ($old_primary) { $old_primary->sendOldPrimaryEmail($user, $email); } $email->sendNewPrimaryEmail($user); $this->revokePasswordResetLinks($user); return $this; } /** * Verify a user's email address. * * This verifies an individual email address. If the address is the user's * primary address and their account was not previously verified, their * account is marked as email verified. * * @task email */ public function verifyEmail( PhabricatorUser $user, PhabricatorUserEmail $email) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } if (!$email->getID()) { throw new Exception(pht('Email has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); $email->reload(); if ($email->getUserPHID() != $user->getPHID()) { throw new Exception(pht('User does not own email!')); } if (!$email->getIsVerified()) { $email->setIsVerified(1); $email->save(); $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_EMAIL_VERIFY); $log->setNewValue($email->getAddress()); $log->save(); } if (!$user->getIsEmailVerified()) { // If the user just verified their primary email address, mark their // account as email verified. $user_primary = $user->loadPrimaryEmail(); if ($user_primary->getID() == $email->getID()) { $user->setIsEmailVerified(1); $user->save(); } } $user->endWriteLocking(); $user->saveTransaction(); $this->didVerifyEmail($user, $email); } /** * Reassign an unverified email address. */ public function reassignEmail( PhabricatorUser $user, PhabricatorUserEmail $email) { $actor = $this->requireActor(); if (!$user->getID()) { throw new Exception(pht('User has not been created yet!')); } if (!$email->getID()) { throw new Exception(pht('Email has not been created yet!')); } $user->openTransaction(); $user->beginWriteLocking(); $user->reload(); $email->reload(); $old_user = $email->getUserPHID(); if ($old_user != $user->getPHID()) { if ($email->getIsVerified()) { throw new Exception( pht('Verified email addresses can not be reassigned.')); } if ($email->getIsPrimary()) { throw new Exception( pht('Primary email addresses can not be reassigned.')); } $email->setUserPHID($user->getPHID()); $email->save(); $log = PhabricatorUserLog::initializeNewLog( $actor, $user->getPHID(), PhabricatorUserLog::ACTION_EMAIL_REASSIGN); $log->setNewValue($email->getAddress()); $log->save(); } $user->endWriteLocking(); $user->saveTransaction(); } /* -( Internals )---------------------------------------------------------- */ /** * @task internal */ private function willAddEmail(PhabricatorUserEmail $email) { // Hard check before write to prevent creation of disallowed email // addresses. Normally, the application does checks and raises more // user friendly errors for us, but we omit the courtesy checks on some // pathways like administrative scripts for simplicity. if (!PhabricatorUserEmail::isValidAddress($email->getAddress())) { throw new Exception(PhabricatorUserEmail::describeValidAddresses()); } if (!PhabricatorUserEmail::isAllowedAddress($email->getAddress())) { throw new Exception(PhabricatorUserEmail::describeAllowedAddresses()); } $application_email = id(new PhabricatorMetaMTAApplicationEmailQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAddresses(array($email->getAddress())) ->executeOne(); if ($application_email) { throw new Exception($application_email->getInUseMessage()); } } public function revokePasswordResetLinks(PhabricatorUser $user) { // Revoke any outstanding password reset links. If an attacker compromises // an account, changes the email address, and sends themselves a password // reset link, it could otherwise remain live for a short period of time // and allow them to compromise the account again later. PhabricatorAuthTemporaryToken::revokeTokens( $user, array($user->getPHID()), array( PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE, PhabricatorAuthPasswordResetTemporaryTokenType::TOKENTYPE, )); } private function didVerifyEmail( PhabricatorUser $user, PhabricatorUserEmail $email) { $event_type = PhabricatorEventType::TYPE_AUTH_DIDVERIFYEMAIL; $event_data = array( 'user' => $user, 'email' => $email, ); $event = id(new PhabricatorEvent($event_type, $event_data)) ->setUser($user); PhutilEventEngine::dispatchEvent($event); } } diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index 455991ca53..18a3f8a2b7 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -1,1686 +1,1660 @@ isAdmin; case 'isDisabled': return (bool)$this->isDisabled; case 'isSystemAgent': return (bool)$this->isSystemAgent; case 'isMailingList': return (bool)$this->isMailingList; 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->isLoggedIn()) { return false; } if ($this->isOmnipotent()) { return true; } if ($this->getIsDisabled()) { return false; } if (!$this->getIsApproved()) { return false; } if (PhabricatorUserEmail::isEmailVerificationRequired()) { if (!$this->getIsEmailVerified()) { return false; } } return true; } /** * Is this a user who we can reasonably expect to respond to requests? * * This is used to provide a grey "disabled/unresponsive" dot cue when * rendering handles and tags, so it isn't a surprise if you get ignored * when you ask things of users who will not receive notifications or could * not respond to them (because they are disabled, unapproved, do not have * verified email addresses, etc). * * @return bool True if this user can receive and respond to requests from * other humans. */ public function isResponsive() { if (!$this->isUserActivated()) { return false; } if (!$this->getIsEmailVerified()) { return false; } return true; } public function canEstablishWebSessions() { if ($this->getIsMailingList()) { return false; } if ($this->getIsSystemAgent()) { return false; } return true; } public function canEstablishAPISessions() { if ($this->getIsDisabled()) { return false; } // Intracluster requests are permitted even if the user is logged out: // in particular, public users are allowed to issue intracluster requests // when browsing Diffusion. if (PhabricatorEnv::isClusterRemoteAddress()) { if (!$this->isLoggedIn()) { return true; } } if (!$this->isUserActivated()) { return false; } if ($this->getIsMailingList()) { return false; } return true; } public function canEstablishSSHSessions() { if (!$this->isUserActivated()) { return false; } if ($this->getIsMailingList()) { 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); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'userName' => 'sort64', 'realName' => 'text128', 'passwordSalt' => 'text32?', 'passwordHash' => 'text128?', 'profileImagePHID' => 'phid?', 'conduitCertificate' => 'text255', 'isSystemAgent' => 'bool', 'isMailingList' => 'bool', 'isDisabled' => 'bool', 'isAdmin' => 'bool', 'isEmailVerified' => 'uint32', 'isApproved' => 'uint32', 'accountSecret' => 'bytes64', 'isEnrolledInMultiFactor' => 'bool', 'availabilityCache' => 'text255?', 'availabilityCacheTTL' => 'uint32?', 'defaultProfileImagePHID' => 'phid?', 'defaultProfileImageVersion' => 'text64?', ), 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'), ), ), self::CONFIG_NO_MUTATE => array( 'availabilityCache' => true, 'availabilityCacheTTL' => true, ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPeopleUserPHIDType::TYPECONST); } - public function hasPassword() { - return (bool)strlen($this->passwordHash); - } - - public function setPassword(PhutilOpaqueEnvelope $envelope) { - if (!$this->getPHID()) { - throw new Exception( - pht( - '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; - } - public function getMonogram() { return '@'.$this->getUsername(); } 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(); PhabricatorSearchWorker::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); } public function getCSRFToken() { if ($this->isOmnipotent()) { // We may end up here when called from the daemons. The omnipotent user // has no meaningful CSRF token, so just return `null`. return null; } if ($this->csrfSalt === null) { $this->csrfSalt = Filesystem::readRandomCharacters( self::CSRF_SALT_LENGTH); } $salt = $this->csrfSalt; // Generate a token hash to mitigate BREACH attacks against SSL. See // discussion in T3684. $token = $this->getRawCSRFToken(); $hash = PhabricatorHash::weakDigest($token, $salt); return self::CSRF_BREACH_PREFIX.$salt.substr( $hash, 0, self::CSRF_TOKEN_LENGTH); } public function validateCSRFToken($token) { // We expect a BREACH-mitigating token. See T3684. $breach_prefix = self::CSRF_BREACH_PREFIX; $breach_prelen = strlen($breach_prefix); if (strncmp($token, $breach_prefix, $breach_prelen) !== 0) { return false; } $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_CYCLE_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); $digest = PhabricatorHash::weakDigest($valid, $salt); $digest = substr($digest, 0, self::CSRF_TOKEN_LENGTH); if (phutil_hashes_are_identical($digest, $token)) { return true; } } 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::weakDigest($vec), 0, $len); } public function getUserProfile() { return $this->assertAttached($this->profile); } 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) { $this->profile = PhabricatorUserProfile::initializeNewProfile($this); } return $this->profile; } public function loadPrimaryEmailAddress() { $email = $this->loadPrimaryEmail(); if (!$email) { throw new Exception(pht('User has no primary email address!')); } return $email->getAddress(); } public function loadPrimaryEmail() { return $this->loadOneRelative( new PhabricatorUserEmail(), 'userPHID', 'getPHID', '(isPrimary = 1)'); } /* -( Settings )----------------------------------------------------------- */ public function getUserSetting($key) { // NOTE: We store available keys and cached values separately to make it // faster to check for `null` in the cache, which is common. if (isset($this->settingCacheKeys[$key])) { return $this->settingCache[$key]; } $settings_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES; if ($this->getPHID()) { $settings = $this->requireCacheData($settings_key); } else { $settings = $this->loadGlobalSettings(); } if (array_key_exists($key, $settings)) { $value = $settings[$key]; return $this->writeUserSettingCache($key, $value); } $cache = PhabricatorCaches::getRuntimeCache(); $cache_key = "settings.defaults({$key})"; $cache_map = $cache->getKeys(array($cache_key)); if ($cache_map) { $value = $cache_map[$cache_key]; } else { $defaults = PhabricatorSetting::getAllSettings(); if (isset($defaults[$key])) { $value = id(clone $defaults[$key]) ->setViewer($this) ->getSettingDefaultValue(); } else { $value = null; } $cache->setKey($cache_key, $value); } return $this->writeUserSettingCache($key, $value); } /** * Test if a given setting is set to a particular value. * * @param const Setting key. * @param wild Value to compare. * @return bool True if the setting has the specified value. * @task settings */ public function compareUserSetting($key, $value) { $actual = $this->getUserSetting($key); return ($actual == $value); } private function writeUserSettingCache($key, $value) { $this->settingCacheKeys[$key] = true; $this->settingCache[$key] = $value; return $value; } public function getTranslation() { return $this->getUserSetting(PhabricatorTranslationSetting::SETTINGKEY); } public function getTimezoneIdentifier() { return $this->getUserSetting(PhabricatorTimezoneSetting::SETTINGKEY); } public static function getGlobalSettingsCacheKey() { return 'user.settings.globals.v1'; } private function loadGlobalSettings() { $cache_key = self::getGlobalSettingsCacheKey(); $cache = PhabricatorCaches::getMutableStructureCache(); $settings = $cache->getKey($cache_key); if (!$settings) { $preferences = PhabricatorUserPreferences::loadGlobalPreferences($this); $settings = $preferences->getPreferences(); $cache->setKey($cache_key, $settings); } return $settings; } /** * Override the user's timezone identifier. * * This is primarily useful for unit tests. * * @param string New timezone identifier. * @return this * @task settings */ public function overrideTimezoneIdentifier($identifier) { $timezone_key = PhabricatorTimezoneSetting::SETTINGKEY; $this->settingCacheKeys[$timezone_key] = true; $this->settingCache[$timezone_key] = $identifier; return $this; } public function getGender() { return $this->getUserSetting(PhabricatorPronounSetting::SETTINGKEY); } public function loadEditorLink( $path, $line, PhabricatorRepository $repository = null) { $editor = $this->getUserSetting(PhabricatorEditorSetting::SETTINGKEY); if (is_array($path)) { $multi_key = PhabricatorEditorMultipleSetting::SETTINGKEY; $multiedit = $this->getUserSetting($multi_key); switch ($multiedit) { case PhabricatorEditorMultipleSetting::VALUE_SPACES: $path = implode(' ', $path); break; case PhabricatorEditorMultipleSetting::VALUE_SINGLE: default: return null; } } if (!strlen($editor)) { return null; } if ($repository) { $callsign = $repository->getCallsign(); } else { $callsign = 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) { if (!$this->canEstablishWebSessions()) { throw new Exception( pht( 'Can not send welcome mail to users who can not establish '. 'web sessions!')); } $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 = pht( "Welcome to Phabricator!\n\n". "%s (%s) has created an account for you.\n\n". " Username: %s\n\n". "To login to Phabricator, follow this link and set a password:\n\n". " %s\n\n". "After you have set a password, you can login in the future by ". "going here:\n\n". " %s\n", $admin_username, $admin_realname, $user_username, $uri, $base_uri); if (!$is_serious) { $body .= sprintf( "\n%s\n", pht("Love,\nPhabricator")); } $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($this->getPHID())) ->setForceDelivery(true) ->setSubject(pht('[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 = sprintf( "%s\n\n %s\n\n%s\n", pht( "If you use a password to login, you'll need to reset it ". "before you can login again. You can reset your password by ". "following this link:"), $uri, pht( "And, of course, you'll need to use your new username to login ". "from now on. If you use OAuth to login, nothing should change.")); } $body = sprintf( "%s\n\n %s\n %s\n\n%s", pht( '%s (%s) has changed your Phabricator username.', $admin_username, $admin_realname), pht( 'Old Username: %s', $old_username), pht( 'New Username: %s', $new_username), $password_instructions); $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($this->getPHID())) ->setForceDelivery(true) ->setSubject(pht('[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 getProfileImageURI() { $uri_key = PhabricatorUserProfileImageCacheType::KEY_URI; return $this->requireCacheData($uri_key); } public function getUnreadNotificationCount() { $notification_key = PhabricatorUserNotificationCountCacheType::KEY_COUNT; return $this->requireCacheData($notification_key); } public function getUnreadMessageCount() { $message_key = PhabricatorUserMessageCountCacheType::KEY_COUNT; return $this->requireCacheData($message_key); } public function getRecentBadgeAwards() { $badges_key = PhabricatorUserBadgesCacheType::KEY_BADGES; return $this->requireCacheData($badges_key); } public function getFullName() { if (strlen($this->getRealName())) { return $this->getUsername().' ('.$this->getRealName().')'; } else { return $this->getUsername(); } } public function getTimeZone() { return new DateTimeZone($this->getTimezoneIdentifier()); } public function getTimeZoneOffset() { $timezone = $this->getTimeZone(); $now = new DateTime('@'.PhabricatorTime::getNow()); $offset = $timezone->getOffset($now); // Javascript offsets are in minutes and have the opposite sign. $offset = -(int)($offset / 60); return $offset; } public function getTimeZoneOffsetInHours() { $offset = $this->getTimeZoneOffset(); $offset = (int)round($offset / 60); $offset = -$offset; return $offset; } public function formatShortDateTime($when, $now = null) { if ($now === null) { $now = PhabricatorTime::getNow(); } try { $when = new DateTime('@'.$when); $now = new DateTime('@'.$now); } catch (Exception $ex) { return null; } $zone = $this->getTimeZone(); $when->setTimeZone($zone); $now->setTimeZone($zone); if ($when->format('Y') !== $now->format('Y')) { // Different year, so show "Feb 31 2075". $format = 'M j Y'; } else if ($when->format('Ymd') !== $now->format('Ymd')) { // Same year but different month and day, so show "Feb 31". $format = 'M j'; } else { // Same year, month and day so show a time of day. $pref_time = PhabricatorTimeFormatSetting::SETTINGKEY; $format = $this->getUserSetting($pref_time); } return $when->format($format); } 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()); } public function getDefaultSpacePHID() { // TODO: We might let the user switch which space they're "in" later on; // for now just use the global space if one exists. // If the viewer has access to the default space, use that. $spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces($this); foreach ($spaces as $space) { if ($space->getIsDefaultNamespace()) { return $space->getPHID(); } } // Otherwise, use the space with the lowest ID that they have access to. // This just tends to keep the default stable and predictable over time, // so adding a new space won't change behavior for users. if ($spaces) { $spaces = msort($spaces, 'getID'); return head($spaces)->getPHID(); } return null; } /** * Grant a user a source of authority, to let them bypass policy checks they * could not otherwise. */ public function grantAuthority($authority) { $this->authorities[] = $authority; return $this; } /** * Get authorities granted to the user. */ public function getAuthorities() { return $this->authorities; } public function hasConduitClusterToken() { return ($this->conduitClusterToken !== self::ATTACHABLE); } public function attachConduitClusterToken(PhabricatorConduitToken $token) { $this->conduitClusterToken = $token; return $this; } public function getConduitClusterToken() { return $this->assertAttached($this->conduitClusterToken); } /* -( Availability )------------------------------------------------------- */ /** * @task availability */ public function attachAvailability(array $availability) { $this->availability = $availability; return $this; } /** * Get the timestamp the user is away until, if they are currently away. * * @return int|null Epoch timestamp, or `null` if the user is not away. * @task availability */ public function getAwayUntil() { $availability = $this->availability; $this->assertAttached($availability); if (!$availability) { return null; } return idx($availability, 'until'); } public function getDisplayAvailability() { $availability = $this->availability; $this->assertAttached($availability); if (!$availability) { return null; } $busy = PhabricatorCalendarEventInvitee::AVAILABILITY_BUSY; return idx($availability, 'availability', $busy); } public function getAvailabilityEventPHID() { $availability = $this->availability; $this->assertAttached($availability); if (!$availability) { return null; } return idx($availability, 'eventPHID'); } /** * Get cached availability, if present. * * @return wild|null Cache data, or null if no cache is available. * @task availability */ public function getAvailabilityCache() { $now = PhabricatorTime::getNow(); if ($this->availabilityCacheTTL <= $now) { return null; } try { return phutil_json_decode($this->availabilityCache); } catch (Exception $ex) { return null; } } /** * Write to the availability cache. * * @param wild Availability cache data. * @param int|null Cache TTL. * @return this * @task availability */ public function writeAvailabilityCache(array $availability, $ttl) { if (PhabricatorEnv::isReadOnly()) { return $this; } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); queryfx( $this->establishConnection('w'), 'UPDATE %T SET availabilityCache = %s, availabilityCacheTTL = %nd WHERE id = %d', $this->getTableName(), phutil_json_encode($availability), $ttl, $this->getID()); unset($unguarded); return $this; } /* -( 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; } /** * Get a scalar string identifying this user. * * This is similar to using the PHID, but distinguishes between omnipotent * and public users explicitly. This allows safe construction of cache keys * or cache buckets which do not conflate public and omnipotent users. * * @return string Scalar identifier. */ public function getCacheFragment() { if ($this->isOmnipotent()) { return 'u.omnipotent'; } $phid = $this->getPHID(); if ($phid) { return 'u.'.$phid; } return 'u.public'; } /* -( Managing Handles )--------------------------------------------------- */ /** * Get a @{class:PhabricatorHandleList} which benefits from this viewer's * internal handle pool. * * @param list List of PHIDs to load. * @return PhabricatorHandleList Handle list object. * @task handle */ public function loadHandles(array $phids) { if ($this->handlePool === null) { $this->handlePool = id(new PhabricatorHandlePool()) ->setViewer($this); } return $this->handlePool->newHandleList($phids); } /** * Get a @{class:PHUIHandleView} for a single handle. * * This benefits from the viewer's internal handle pool. * * @param phid PHID to render a handle for. * @return PHUIHandleView View of the handle. * @task handle */ public function renderHandle($phid) { return $this->loadHandles(array($phid))->renderHandle($phid); } /** * Get a @{class:PHUIHandleListView} for a list of handles. * * This benefits from the viewer's internal handle pool. * * @param list List of PHIDs to render. * @return PHUIHandleListView View of the handles. * @task handle */ public function renderHandleList(array $phids) { return $this->loadHandles($phids)->renderList(); } public function attachBadgePHIDs(array $phids) { $this->badgePHIDs = $phids; return $this; } public function getBadgePHIDs() { return $this->assertAttached($this->badgePHIDs); } /* -( 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() || $this->getIsMailingList()) { 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 PhabricatorUserPreferencesQuery()) ->setViewer($engine->getViewer()) ->withUsers(array($this)) ->execute(); foreach ($prefs as $pref) { $engine->destroyObject($pref); } $profiles = id(new PhabricatorUserProfile())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($profiles as $profile) { $profile->delete(); } $keys = id(new PhabricatorAuthSSHKeyQuery()) ->setViewer($engine->getViewer()) ->withObjectPHIDs(array($this->getPHID())) ->execute(); foreach ($keys as $key) { $engine->destroyObject($key); } $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(); } /* -( PhabricatorSSHPublicKeyInterface )----------------------------------- */ public function getSSHPublicKeyManagementURI(PhabricatorUser $viewer) { if ($viewer->getPHID() == $this->getPHID()) { // If the viewer is managing their own keys, take them to the normal // panel. return '/settings/panel/ssh/'; } else { // Otherwise, take them to the administrative panel for this user. return '/settings/user/'.$this->getUsername().'/page/ssh/'; } } public function getSSHKeyDefaultName() { return 'id_rsa_phabricator'; } public function getSSHKeyNotifyPHIDs() { return array( $this->getPHID(), ); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorUserProfileEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorUserTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhabricatorUserFulltextEngine(); } /* -( PhabricatorFerretInterface )----------------------------------------- */ public function newFerretEngine() { return new PhabricatorUserFerretEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('username') ->setType('string') ->setDescription(pht("The user's username.")), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('realName') ->setType('string') ->setDescription(pht("The user's real name.")), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('roles') ->setType('list') ->setDescription(pht('List of account roles.')), ); } public function getFieldValuesForConduit() { $roles = array(); if ($this->getIsDisabled()) { $roles[] = 'disabled'; } if ($this->getIsSystemAgent()) { $roles[] = 'bot'; } if ($this->getIsMailingList()) { $roles[] = 'list'; } if ($this->getIsAdmin()) { $roles[] = 'admin'; } if ($this->getIsEmailVerified()) { $roles[] = 'verified'; } if ($this->getIsApproved()) { $roles[] = 'approved'; } if ($this->isUserActivated()) { $roles[] = 'activated'; } return array( 'username' => $this->getUsername(), 'realName' => $this->getRealName(), 'roles' => $roles, ); } public function getConduitSearchAttachments() { return array(); } /* -( User Cache )--------------------------------------------------------- */ /** * @task cache */ public function attachRawCacheData(array $data) { $this->rawCacheData = $data + $this->rawCacheData; return $this; } public function setAllowInlineCacheGeneration($allow_cache_generation) { $this->allowInlineCacheGeneration = $allow_cache_generation; return $this; } /** * @task cache */ protected function requireCacheData($key) { if (isset($this->usableCacheData[$key])) { return $this->usableCacheData[$key]; } $type = PhabricatorUserCacheType::requireCacheTypeForKey($key); if (isset($this->rawCacheData[$key])) { $raw_value = $this->rawCacheData[$key]; $usable_value = $type->getValueFromStorage($raw_value); $this->usableCacheData[$key] = $usable_value; return $usable_value; } // By default, we throw if a cache isn't available. This is consistent // with the standard `needX()` + `attachX()` + `getX()` interaction. if (!$this->allowInlineCacheGeneration) { throw new PhabricatorDataNotAttachedException($this); } $user_phid = $this->getPHID(); // Try to read the actual cache before we generate a new value. We can // end up here via Conduit, which does not use normal sessions and can // not pick up a free cache load during session identification. if ($user_phid) { $raw_data = PhabricatorUserCache::readCaches( $type, $key, array($user_phid)); if (array_key_exists($user_phid, $raw_data)) { $raw_value = $raw_data[$user_phid]; $usable_value = $type->getValueFromStorage($raw_value); $this->rawCacheData[$key] = $raw_value; $this->usableCacheData[$key] = $usable_value; return $usable_value; } } $usable_value = $type->getDefaultValue(); if ($user_phid) { $map = $type->newValueForUsers($key, array($this)); if (array_key_exists($user_phid, $map)) { $raw_value = $map[$user_phid]; $usable_value = $type->getValueFromStorage($raw_value); $this->rawCacheData[$key] = $raw_value; PhabricatorUserCache::writeCache( $type, $key, $user_phid, $raw_value); } } $this->usableCacheData[$key] = $usable_value; return $usable_value; } /** * @task cache */ public function clearCacheData($key) { unset($this->rawCacheData[$key]); unset($this->usableCacheData[$key]); return $this; } public function getCSSValue($variable_key) { $preference = PhabricatorAccessibilitySetting::SETTINGKEY; $key = $this->getUserSetting($preference); $postprocessor = CelerityPostprocessor::getPostprocessor($key); $variables = $postprocessor->getVariables(); if (!isset($variables[$variable_key])) { throw new Exception( pht( 'Unknown CSS variable "%s"!', $variable_key)); } return $variables[$variable_key]; } /* -( PhabricatorPasswordHashInterface )----------------------------------- */ public function newPasswordDigest( PhutilOpaqueEnvelope $envelope, PhabricatorAuthPassword $password) { // Before passwords are hashed, they are digested. The goal of digestion // is twofold: to reduce the length of very long passwords to something // reasonable; and to salt the password in case the best available hasher // does not include salt automatically. // Users may choose arbitrarily long passwords, and attackers may try to // attack the system by probing it with very long passwords. When large // inputs are passed to hashers -- which are intentionally slow -- it // can result in unacceptably long runtimes. The classic attack here is // to try to log in with a 64MB password and see if that locks up the // machine for the next century. By digesting passwords to a standard // length first, the length of the raw input does not impact the runtime // of the hashing algorithm. // Some hashers like bcrypt are self-salting, while other hashers are not. // Applying salt while digesting passwords ensures that hashes are salted // whether we ultimately select a self-salting hasher or not. // For legacy compatibility reasons, the VCS and Account password digest // algorithms are significantly more complicated than necessary to achieve // these goals. This is because they once used a different hashing and // salting process. When we upgraded to the modern modular hasher // infrastructure, we just bolted it onto the end of the existing pipelines // so that upgrading didn't break all users' credentials. // New implementations can (and, generally, should) safely select the // simple HMAC SHA256 digest at the bottom of the function, which does // everything that a digest callback should without any needless legacy // baggage on top. switch ($password->getPasswordType()) { case PhabricatorAuthPassword::PASSWORD_TYPE_VCS: // VCS passwords use an iterated HMAC SHA1 as a digest algorithm. They // originally used this as a hasher, but it became a digest alorithm // once hashing was upgraded to include bcrypt. $digest = $envelope->openEnvelope(); $salt = $this->getPHID(); for ($ii = 0; $ii < 1000; $ii++) { $digest = PhabricatorHash::weakDigest($digest, $salt); } return new PhutilOpaqueEnvelope($digest); + case PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT: + // Account passwords use this weird mess of salt and do not digest + // the input to a standard length. + + // TODO: We should build a migration pathway forward from this which + // uses a better (HMAC SHA256) digest algorithm. Beyond this being + // a weird special case, there are two actual problems with this, + // although neither are particularly severe: + + // First, because we do not normalize the length of passwords, this + // algorithm may make us vulnerable to DOS attacks where attacker + // attempt to use very long inputs to slow down hashers. + + // Second, because the username is part of the hash algorithm, renaming + // a user breaks their password. This isn't a huge deal but it's pretty + // silly. There's no security justification for this behavior, I just + // didn't think about the implication when I wrote it originally. + + $parts = array( + $this->getUsername(), + $envelope->openEnvelope(), + $this->getPHID(), + $password->getPasswordSalt(), + ); + + return new PhutilOpaqueEnvelope(implode('', $parts)); } // For passwords which do not have some crazy legacy reason to use some // other digest algorithm, HMAC SHA256 is an excellent choice. It satisfies // the digest requirements and is simple. $digest = PhabricatorHash::digestHMACSHA256( $envelope->openEnvelope(), $password->getPasswordSalt()); return new PhutilOpaqueEnvelope($digest); } } diff --git a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php index 3807139fe3..793928d5e2 100644 --- a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php @@ -1,185 +1,201 @@ getUser(); + $content_source = PhabricatorContentSource::newFromRequest($request); $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( $user, $request, '/settings/'); $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; // NOTE: Users can also change passwords through the separate "set/reset" // interface which is reached by logging in with a one-time token after // registration or password reset. If this flow changes, that flow may // also need to change. + $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; + + $password_objects = id(new PhabricatorAuthPasswordQuery()) + ->setViewer($user) + ->withObjectPHIDs(array($user->getPHID())) + ->withPasswordTypes(array($account_type)) + ->withIsRevoked(false) + ->execute(); + if ($password_objects) { + $password_object = head($password_objects); + } else { + $password_object = PhabricatorAuthPassword::initializeNewPassword( + $user, + $account_type); + } + $e_old = true; $e_new = true; $e_conf = true; $errors = array(); if ($request->isFormPost()) { $envelope = new PhutilOpaqueEnvelope($request->getStr('old_pw')); - if (!$user->comparePassword($envelope)) { + + $engine = id(new PhabricatorAuthPasswordEngine()) + ->setViewer($user) + ->setContentSource($content_source) + ->setPasswordType($account_type) + ->setObject($user); + + if (!strlen($envelope->openEnvelope())) { + $errors[] = pht('You must enter your current password.'); + $e_old = pht('Required'); + } else if (!$engine->isValidPassword($envelope)) { $errors[] = pht('The old password you entered is incorrect.'); $e_old = pht('Invalid'); + } else { + $e_old = null; } $pass = $request->getStr('new_pw'); $conf = $request->getStr('conf_pw'); + $password_envelope = new PhutilOpaqueEnvelope($pass); + $confirm_envelope = new PhutilOpaqueEnvelope($conf); - 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.'); + try { + $engine->checkNewPassword($password_envelope, $confirm_envelope); + $e_new = null; + $e_conf = null; + } catch (PhabricatorAuthPasswordException $ex) { + $errors[] = $ex->getMessage(); + $e_new = $ex->getPasswordError(); + $e_conf = $ex->getConfirmError(); } 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); + $password_object + ->setPassword($password_envelope, $user) + ->save(); $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())) { + if ($password_object->getID()) { try { - $can_upgrade = PhabricatorPasswordHasher::canUpgradeHash( - $hash_envelope); + $can_upgrade = $password_object->canUpgrade(); } catch (PhabricatorPasswordHasherUnavailableException $ex) { $can_upgrade = false; $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 = id(new AphrontFormView()) ->setViewer($user) ->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Old Password')) ->setError($e_old) ->setName('old_pw')) ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setLabel(pht('New Password')) ->setError($e_new) ->setName('new_pw')) ->appendChild( id(new AphrontFormPasswordControl()) ->setDisableAutocomplete(true) ->setLabel(pht('Confirm Password')) ->setCaption($len_caption) ->setError($e_conf) ->setName('conf_pw')) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Change Password'))); $properties = id(new PHUIPropertyListView()); $properties->addProperty( pht('Current Algorithm'), PhabricatorPasswordHasher::getCurrentAlgorithmName( - new PhutilOpaqueEnvelope($user->getPasswordHash()))); + $password_object->newPasswordEnvelope())); $properties->addProperty( pht('Best Available Algorithm'), PhabricatorPasswordHasher::getBestAlgorithmName()); $info_view = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) ->appendChild( pht('Changing your password will terminate any other outstanding '. 'login sessions.')); $algo_box = $this->newBox(pht('Password Algorithms'), $properties); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Change Password')) ->setFormSaved($request->getStr('saved')) ->setFormErrors($errors) ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) ->setForm($form); return array( $form_box, $algo_box, $info_view, ); } }