diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index b18cd32d16..a115a46109 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -815,6 +815,7 @@ phutil_register_library_map(array( 'PhabricatorAuditReplyHandler' => 'applications/audit/mail/PhabricatorAuditReplyHandler.php', 'PhabricatorAuditStatusConstants' => 'applications/audit/constants/PhabricatorAuditStatusConstants.php', 'PhabricatorAuthController' => 'applications/auth/controller/PhabricatorAuthController.php', + 'PhabricatorAuthRegisterController' => 'applications/auth/controller/PhabricatorAuthRegisterController.php', 'PhabricatorAuthenticationConfigOptions' => 'applications/config/option/PhabricatorAuthenticationConfigOptions.php', 'PhabricatorBarePageExample' => 'applications/uiexample/examples/PhabricatorBarePageExample.php', 'PhabricatorBarePageView' => 'view/page/PhabricatorBarePageView.php', @@ -1335,6 +1336,7 @@ phutil_register_library_map(array( 'PhabricatorRecaptchaConfigOptions' => 'applications/config/option/PhabricatorRecaptchaConfigOptions.php', 'PhabricatorRedirectController' => 'applications/base/controller/PhabricatorRedirectController.php', 'PhabricatorRefreshCSRFController' => 'applications/auth/controller/PhabricatorRefreshCSRFController.php', + 'PhabricatorRegistrationProfile' => 'applications/people/storage/PhabricatorRegistrationProfile.php', 'PhabricatorRemarkupControl' => 'view/form/control/PhabricatorRemarkupControl.php', 'PhabricatorRemarkupRuleEmbedFile' => 'applications/files/remarkup/PhabricatorRemarkupRuleEmbedFile.php', 'PhabricatorRemarkupRuleImageMacro' => 'applications/macro/remarkup/PhabricatorRemarkupRuleImageMacro.php', @@ -2667,6 +2669,7 @@ phutil_register_library_map(array( 'PhabricatorAuditPreviewController' => 'PhabricatorAuditController', 'PhabricatorAuditReplyHandler' => 'PhabricatorMailReplyHandler', 'PhabricatorAuthController' => 'PhabricatorController', + 'PhabricatorAuthRegisterController' => 'PhabricatorAuthController', 'PhabricatorAuthenticationConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorBarePageExample' => 'PhabricatorUIExample', 'PhabricatorBarePageView' => 'AphrontPageView', @@ -3182,6 +3185,7 @@ phutil_register_library_map(array( 'PhabricatorRecaptchaConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorRedirectController' => 'PhabricatorController', 'PhabricatorRefreshCSRFController' => 'PhabricatorAuthController', + 'PhabricatorRegistrationProfile' => 'Phobject', 'PhabricatorRemarkupControl' => 'AphrontFormTextAreaControl', 'PhabricatorRemarkupRuleEmbedFile' => 'PhutilRemarkupRule', 'PhabricatorRemarkupRuleImageMacro' => 'PhutilRemarkupRule', diff --git a/src/applications/auth/application/PhabricatorApplicationAuth.php b/src/applications/auth/application/PhabricatorApplicationAuth.php index 37d9a3a82f..6fcce8ac23 100644 --- a/src/applications/auth/application/PhabricatorApplicationAuth.php +++ b/src/applications/auth/application/PhabricatorApplicationAuth.php @@ -29,4 +29,12 @@ final class PhabricatorApplicationAuth extends PhabricatorApplication { return $items; } + public function getRoutes() { + return array( + '/auth/' => array( + 'register/(?P[^/]+)/' => 'PhabricatorAuthRegisterController', + ), + ); + } + } diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php index 19094da148..9f6b76e468 100644 --- a/src/applications/auth/controller/PhabricatorAuthController.php +++ b/src/applications/auth/controller/PhabricatorAuthController.php @@ -14,4 +14,39 @@ abstract class PhabricatorAuthController extends PhabricatorController { return $response->setContent($page->render()); } + protected function renderErrorPage($title, array $messages) { + $view = new AphrontErrorView(); + $view->setTitle($title); + $view->setErrors($messages); + + return $this->buildApplicationPage( + $view, + array( + 'title' => $title, + 'device' => true, + 'dust' => true, + )); + + } + + protected function establishWebSession(PhabricatorUser $user) { + $session_key = $user->establishSession('web'); + + $request = $this->getRequest(); + + // NOTE: We allow disabled users to login and roadblock them later, so + // there's no check for users being disabled here. + + $request->setCookie('phusr', $user->getUsername()); + $request->setCookie('phsid', $session_key); + $request->clearCookie('phreg'); + } + + protected function buildLoginValidateResponse(PhabricatorUser $user) { + $validate_uri = new PhutilURI($this->getApplicationURI('validate/')); + $validate_uri->setQueryParam('phusr', $user->getUsername()); + + return id(new AphrontRedirectResponse())->setURI((string)$validate_uri); + } + } diff --git a/src/applications/auth/controller/PhabricatorAuthRegisterController.php b/src/applications/auth/controller/PhabricatorAuthRegisterController.php new file mode 100644 index 0000000000..74cdb1cfe9 --- /dev/null +++ b/src/applications/auth/controller/PhabricatorAuthRegisterController.php @@ -0,0 +1,483 @@ +accountKey = idx($data, 'akey'); + } + + public function processRequest() { + $request = $this->getRequest(); + + if ($request->getUser()->isLoggedIn()) { + return $this->renderError(pht('You are already logged in.')); + } + + if (strlen($this->accountKey)) { + $response = $this->loadAccount(); + } else { + $response = $this->loadDefaultAccount(); + } + + if ($response) { + return $response; + } + + $account = $this->account; + + $user = new PhabricatorUser(); + + $default_username = $account->getUsername(); + $default_realname = $account->getRealName(); + $default_email = $account->getEmail(); + if ($default_email) { + // If the account source provided an email but it's not allowed by + // the configuration, just pretend we didn't get an email at all. + if (!PhabricatorUserEmail::isAllowedAddress($default_email)) { + $default_email = null; + } + + // If the account source provided an email, but another account already + // has that email, just pretend we didn't get an email. + + // TODO: See T3340. + + if ($default_email) { + $same_email = id(new PhabricatorUserEmail())->loadOneWhere( + 'address = %s', + $default_email); + if ($same_email) { + $default_email = null; + } + } + } + + $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 = $this->provider->shouldRequireRegistrationPassword(); + + $can_edit_anything = $profile->getCanEditAnything() || $must_set_password; + $force_verify = $profile->getShouldVerifyEmail(); + + $value_username = $default_username; + $value_realname = $default_realname; + $value_email = $default_email; + $value_password = null; + + $errors = array(); + + $e_username = strlen($value_username) ? null : true; + $e_realname = strlen($value_realname) ? null : true; + $e_email = strlen($value_email) ? null : true; + $e_password = true; + + $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); + $min_len = (int)$min_len; + + if ($request->isFormPost() || !$can_edit_anything) { + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + + 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 { + $e_password = null; + } + } + + 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::isAllowedAddress($value_email)) { + $e_email = pht('Invalid'); + $errors[] = PhabricatorUserEmail::describeAllowedAddresses(); + } else { + $e_email = null; + } + } + + if ($can_edit_realname) { + $value_realname = $request->getStr('realName'); + if (!strlen($value_realname)) { + $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 { + if ($force_verify) { + $verify_email = true; + } else { + $verify_email = + ($account->getEmailVerified()) && + ($value_email === $default_email); + } + + $email_obj = id(new PhabricatorUserEmail()) + ->setAddress($value_email) + ->setIsVerified((int)$verify_email); + + $user->setUsername($value_username); + $user->setRealname($value_realname); + + $user->openTransaction(); + + $editor = id(new PhabricatorUserEditor()) + ->setActor($user); + + $editor->createNewUser($user, $email_obj); + if ($must_set_password) { + $envelope = new PhutilOpaqueEnvelope($value_password); + $editor->changePassword($user, $envelope); + } + + $account->setUserPHID($user->getPHID()); + $this->provider->willRegisterAccount($account); + $account->save(); + + $user->saveTransaction(); + + $this->establishWebSession($user); + + if (!$email_obj->getIsVerified()) { + $email_obj->sendVerificationEmail($user); + } + + return $this->buildLoginValidateResponse($user); + } catch (AphrontQueryDuplicateKeyException $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); + } + + $error_view = null; + if ($errors) { + $error_view = new AphrontErrorView(); + $error_view->setTitle(pht('Registration Failed')); + $error_view->setErrors($errors); + } + + $form = id(new AphrontFormView()) + ->setUser($request->getUser()) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Username')) + ->setName('username') + ->setValue($value_username) + ->setError($e_username)); + + if ($must_set_password) { + $form->appendChild( + id(new AphrontFormPasswordControl()) + ->setLabel(pht('Password')) + ->setName('password') + ->setError($e_password) + ->setCaption( + $min_len + ? pht('Minimum length of %d characters.', $min_len) + : null)); + $form->appendChild( + id(new AphrontFormPasswordControl()) + ->setLabel(pht('Confirm Password')) + ->setName('confirm') + ->setError($e_password)); + } + + if ($can_edit_email) { + $form->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Email')) + ->setName('email') + ->setValue($value_email) + ->setCaption(PhabricatorUserEmail::describeAllowedAddresses()) + ->setError($e_email)); + } + + if ($can_edit_realname) { + $form->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Real Name')) + ->setName('realName') + ->setValue($value_realname) + ->setError($e_realname)); + } + + $form->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue(pht('Create Account'))); + + $title = pht('Phabricator Registration'); + + $crumbs = $this->buildApplicationCrumbs(); + $crumbs->addCrumb( + id(new PhabricatorCrumbView()) + ->setName(pht('Register'))); + + return $this->buildApplicationPage( + array( + $crumbs, + $error_view, + $form, + ), + array( + 'title' => $title, + 'device' => true, + 'dust' => true, + )); + } + + private function loadAccount() { + $request = $this->getRequest(); + + if (!$this->accountKey) { + return $this->renderError(pht('Request did not include account key.')); + } + + $account = id(new PhabricatorExternalAccount())->loadOneWhere( + 'accountSecret = %s', + $this->accountKey); + + if (!$account) { + return $this->renderError(pht('No registration account.')); + } + + if ($account->getUserPHID()) { + return $this->renderError( + pht( + 'The account you are attempting to register with is already '. + 'registered to another user.')); + } + + $registration_key = $request->getCookie('phreg'); + + // NOTE: This registration key check is not strictly necessary, because + // we're only creating new accounts, not linking existing accounts. It + // might be more hassle than it is worth, especially for email. + // + // The attack this prevents is getting to the registration screen, then + // copy/pasting the URL and getting someone else to click it and complete + // the process. They end up with an account bound to credentials you + // control. This doesn't really let you do anything meaningful, though, + // since you could have simply completed the process yourself. + + if (!$registration_key) { + return $this->renderError( + pht( + 'Your browser did not submit a registration key with the request. '. + 'You must use the same browser to begin and complete registration. '. + 'Check that cookies are enabled and try again.')); + } + + if ($registration_key != $account->getProperty('registrationKey')) { + return $this->renderError( + pht( + 'Your browser submitted a different registration key than the one '. + 'associated with this account. You may need to clear your cookies.')); + } + + $other_account = id(new PhabricatorExternalAccount())->loadAllWhere( + 'accountType = %s AND accountDomain = %s AND accountID = %s + AND id != %d', + $account->getAccountType(), + $account->getAccountDomain(), + $account->getAccountID(), + $account->getID()); + + if ($other_account) { + return $this->renderError( + pht( + 'The account you are attempting to register with already belongs '. + 'to another user.')); + } + + $provider = PhabricatorAuthProvider::getEnabledProviderByKey( + $account->getProviderKey()); + + if (!$provider) { + return $this->renderError( + pht( + 'The account you are attempting to register with uses a nonexistent '. + 'or disabled authentication provider (with key "%s"). An '. + 'administrator may have recently disabled this provider.', + $account->getProviderKey())); + } + + if (!$provider->shouldAllowRegistration()) { + + // 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())); + } + + $this->account = $account; + $this->provider = $provider; + + return null; + } + + private function loadDefaultAccount() { + $providers = PhabricatorAuthProvider::getAllEnabledProviders(); + foreach ($providers as $key => $provider) { + if (!$provider->shouldAllowRegistration()) { + unset($providers[$key]); + continue; + } + if (!$provider->isDefaultRegistrationProvider()) { + unset($providers[$key]); + } + } + + if (!$providers) { + return $this->renderError( + pht( + "There are no configured default registration providers.")); + } else if (count($providers) > 1) { + return $this->renderError( + pht( + "There are too many configured default registration providers.")); + } + + $this->account = $provider->getDefaultExternalAccount(); + $this->provider = $provider; + return null; + } + + 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; + } + + try { + $xformer = new PhabricatorImageTransformer(); + return $xformer->executeProfileTransform( + $file, + $width = 50, + $min_height = 50, + $max_height = 50); + } catch (Exception $ex) { + phlog($ex); + return null; + } + } + + private function renderError($message) { + return $this->renderErrorPage( + pht('Registration Failed'), + array($message)); + } + +} diff --git a/src/applications/people/storage/PhabricatorRegistrationProfile.php b/src/applications/people/storage/PhabricatorRegistrationProfile.php new file mode 100644 index 0000000000..6a901c0b13 --- /dev/null +++ b/src/applications/people/storage/PhabricatorRegistrationProfile.php @@ -0,0 +1,84 @@ +shouldVerifyEmail = $should_verify_email; + return $this; + } + + public function getShouldVerifyEmail() { + return $this->shouldVerifyEmail; + } + + public function setCanEditEmail($can_edit_email) { + $this->canEditEmail = $can_edit_email; + return $this; + } + + public function getCanEditEmail() { + return $this->canEditEmail; + } + + public function setCanEditRealName($can_edit_real_name) { + $this->canEditRealName = $can_edit_real_name; + return $this; + } + + public function getCanEditRealName() { + return $this->canEditRealName; + } + + + public function setCanEditUsername($can_edit_username) { + $this->canEditUsername = $can_edit_username; + return $this; + } + + public function getCanEditUsername() { + return $this->canEditUsername; + } + + public function setDefaultEmail($default_email) { + $this->defaultEmail = $default_email; + return $this; + } + + public function getDefaultEmail() { + return $this->defaultEmail; + } + + public function setDefaultRealName($default_real_name) { + $this->defaultRealName = $default_real_name; + return $this; + } + + public function getDefaultRealName() { + return $this->defaultRealName; + } + + + public function setDefaultUsername($default_username) { + $this->defaultUsername = $default_username; + return $this; + } + + public function getDefaultUsername() { + return $this->defaultUsername; + } + + public function getCanEditAnything() { + return $this->getCanEditUsername() || + $this->getCanEditEmail() || + $this->getCanEditRealName(); + } + +} diff --git a/src/infrastructure/events/constant/PhabricatorEventType.php b/src/infrastructure/events/constant/PhabricatorEventType.php index 3d35e00476..a1b5bb3494 100644 --- a/src/infrastructure/events/constant/PhabricatorEventType.php +++ b/src/infrastructure/events/constant/PhabricatorEventType.php @@ -36,4 +36,6 @@ final class PhabricatorEventType extends PhutilEventType { const TYPE_UI_DIDRENDERHOVERCARD = 'ui.didRenderHovercard'; const TYPE_PEOPLE_DIDRENDERMENU = 'people.didRenderMenu'; + const TYPE_AUTH_WILLREGISTERUSER = 'auth.willRegisterUser'; + }