@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make password reset emails use one-time tokens

Summary:
Ref T4398. This code hadn't been touched in a while and had a few crufty bits.

**One Time Resets**: Currently, password reset (and similar links) are valid for about 48 hours, but we always use one token to generate them (it's bound to the account). This isn't horrible, but it could be better, and it produces a lot of false positives on HackerOne.

Instead, use TemporaryTokens to make each link one-time only and good for no more than 24 hours.

**Coupling of Email Verification and One-Time Login**: Currently, one-time login links ("password reset links") are tightly bound to an email address, and using a link verifies that email address.

This is convenient for "Welcome" emails, so the user doesn't need to go through two rounds of checking email in order to login, then very their email, then actually get access to Phabricator.

However, for other types of these links (like those generated by `bin/auth recover`) there's no need to do any email verification.

Instead, make the email verification part optional, and use it on welcome links but not other types of links.

**Message Customization**: These links can come out of several workflows: welcome, password reset, username change, or `bin/auth recover`. Add a hint to the URI so the text on the page can be customized a bit to help users through the workflow.

**Reset Emails Going to Main Account Email**: Previously, we would send password reset email to the user's primary account email. However, since we verify email coming from reset links this isn't correct and could allow a user to verify an email without actually controlling it.

Since the user needs a real account in the first place this does not seem useful on its own, but might be a component in some other attack. The user might also no longer have access to their primary account, in which case this wouldn't be wrong, but would not be very useful.

Mitigate this in two ways:

- First, send to the actual email address the user entered, not the primary account email address.
- Second, don't let these links verify emails: they're just login links. This primarily makes it more difficult for an attacker to add someone else's email to their account, send them a reset link, get them to login and implicitly verify the email by not reading very carefully, and then figure out something interesting to do (there's currently no followup attack here, but allowing this does seem undesirable).

**Password Reset Without Old Password**: After a user logs in via email, we send them to the password settings panel (if passwords are enabled) with a code that lets them set a new password without knowing the old one.

Previously, this code was static and based on the email address. Instead, issue a one-time code.

**Jump Into Hisec**: Normally, when a user who has multi-factor auth on their account logs in, we prompt them for factors but don't put them in high security. You usually don't want to go do high-security stuff immediately after login, and it would be confusing and annoying if normal logins gave you a "YOU ARE IN HIGH SECURITY" alert bubble.

However, if we're taking you to the password reset screen, we //do// want to put the user in high security, since that screen requires high security. If we don't do this, the user gets two factor prompts in a row.

To accomplish this, we set a cookie when we know we're sending the user into a high security workflow. This cookie makes login finalization upgrade all the way from "partial" to "high security", instead of stopping halfway at "normal". This is safe because the user has just passed a factor check; the only reason we don't normally do this is to reduce annoyance.

**Some UI Cleanup**: Some of this was using really old UI. Modernize it a bit.

Test Plan:
- **One Time Resets**
- Used a reset link.
- Tried to reuse a reset link, got denied.
- Verified each link is different.
- **Coupling of Email Verification and One-Time Login**
- Verified that `bin/auth`, password reset, and username change links do not have an email verifying URI component.
- Tried to tack one on, got denied.
- Used the welcome email link to login + verify.
- Tried to mutate the URI to not verify, or verify something else: got denied.
- **Message Customization**
- Viewed messages on the different workflows. They seemed OK.
- **Reset Emails Going to Main Account Email**
- Sent password reset email to non-primary email.
- Received email at specified address.
- Verified it does not verify the address.
- **Password Reset Without Old Password**
- Reset password without knowledge of old one after email reset.
- Tried to do that without a key, got denied.
- Tried to reuse a key, got denied.
- **Jump Into Hisec**
- Logged in with MFA user, got factor'd, jumped directly into hisec.
- Logged in with non-MFA user, no factors, normal password reset.
- **Some UI Cleanup**
- Viewed new UI.
- **Misc**
- Created accounts, logged in with welcome link, got verified.
- Changed a username, used link to log back in.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T4398

Differential Revision: https://secure.phabricator.com/D9252

+411 -227
+2 -2
src/__phutil_library_map__.php
··· 1249 1249 'PhabricatorAuthNeedsApprovalController' => 'applications/auth/controller/PhabricatorAuthNeedsApprovalController.php', 1250 1250 'PhabricatorAuthNewController' => 'applications/auth/controller/config/PhabricatorAuthNewController.php', 1251 1251 'PhabricatorAuthOldOAuthRedirectController' => 'applications/auth/controller/PhabricatorAuthOldOAuthRedirectController.php', 1252 + 'PhabricatorAuthOneTimeLoginController' => 'applications/auth/controller/PhabricatorAuthOneTimeLoginController.php', 1252 1253 'PhabricatorAuthPHIDTypeAuthFactor' => 'applications/auth/phid/PhabricatorAuthPHIDTypeAuthFactor.php', 1253 1254 'PhabricatorAuthProvider' => 'applications/auth/provider/PhabricatorAuthProvider.php', 1254 1255 'PhabricatorAuthProviderConfig' => 'applications/auth/storage/PhabricatorAuthProviderConfig.php', ··· 1525 1526 'PhabricatorEdgeTestCase' => 'infrastructure/edges/__tests__/PhabricatorEdgeTestCase.php', 1526 1527 'PhabricatorEditor' => 'infrastructure/PhabricatorEditor.php', 1527 1528 'PhabricatorEmailLoginController' => 'applications/auth/controller/PhabricatorEmailLoginController.php', 1528 - 'PhabricatorEmailTokenController' => 'applications/auth/controller/PhabricatorEmailTokenController.php', 1529 1529 'PhabricatorEmailVerificationController' => 'applications/auth/controller/PhabricatorEmailVerificationController.php', 1530 1530 'PhabricatorEmptyQueryException' => 'infrastructure/query/PhabricatorEmptyQueryException.php', 1531 1531 'PhabricatorEnglishTranslation' => 'infrastructure/internationalization/translation/PhabricatorEnglishTranslation.php', ··· 4011 4011 'PhabricatorAuthNeedsApprovalController' => 'PhabricatorAuthController', 4012 4012 'PhabricatorAuthNewController' => 'PhabricatorAuthProviderConfigController', 4013 4013 'PhabricatorAuthOldOAuthRedirectController' => 'PhabricatorAuthController', 4014 + 'PhabricatorAuthOneTimeLoginController' => 'PhabricatorAuthController', 4014 4015 'PhabricatorAuthPHIDTypeAuthFactor' => 'PhabricatorPHIDType', 4015 4016 'PhabricatorAuthProviderConfig' => 4016 4017 array( ··· 4325 4326 'PhabricatorEdgeTestCase' => 'PhabricatorTestCase', 4326 4327 'PhabricatorEditor' => 'Phobject', 4327 4328 'PhabricatorEmailLoginController' => 'PhabricatorAuthController', 4328 - 'PhabricatorEmailTokenController' => 'PhabricatorAuthController', 4329 4329 'PhabricatorEmailVerificationController' => 'PhabricatorAuthController', 4330 4330 'PhabricatorEmptyQueryException' => 'Exception', 4331 4331 'PhabricatorEnglishTranslation' => 'PhabricatorBaseEnglishTranslation',
+5 -1
src/applications/auth/application/PhabricatorApplicationAuth.php
··· 101 101 '/login/' => array( 102 102 '' => 'PhabricatorAuthStartController', 103 103 'email/' => 'PhabricatorEmailLoginController', 104 - 'etoken/(?P<token>\w+)/' => 'PhabricatorEmailTokenController', 104 + 'once/'. 105 + '(?P<type>[^/]+)/'. 106 + '(?P<id>\d+)/'. 107 + '(?P<key>[^/]+)/'. 108 + '(?:(?P<emailID>\d+)/)?' => 'PhabricatorAuthOneTimeLoginController', 105 109 'refresh/' => 'PhabricatorRefreshCSRFController', 106 110 'mustverify/' => 'PhabricatorMustVerifyEmailController', 107 111 ),
+9 -1
src/applications/auth/constants/PhabricatorCookies.php
··· 49 49 const COOKIE_NEXTURI = 'next_uri'; 50 50 51 51 52 + /** 53 + * Stores a hint that the user should be moved directly into high security 54 + * after upgrading a partial login session. This is used during password 55 + * recovery to avoid a double-prompt. 56 + */ 57 + const COOKIE_HISEC = 'jump_to_hisec'; 58 + 59 + 52 60 /* -( Client ID Cookie )--------------------------------------------------- */ 53 61 54 62 ··· 125 133 */ 126 134 public static function getNextURICookie(AphrontRequest $request) { 127 135 $cookie_value = $request->getCookie(self::COOKIE_NEXTURI); 128 - list($set_at, $next_uri) = self::parseNExtURICookie($cookie_value); 136 + list($set_at, $next_uri) = self::parseNextURICookie($cookie_value); 129 137 130 138 return $next_uri; 131 139 }
+10 -1
src/applications/auth/controller/PhabricatorAuthFinishController.php
··· 24 24 25 25 $engine = new PhabricatorAuthSessionEngine(); 26 26 27 + // If this cookie is set, the user is headed into a high security area 28 + // after login (normally because of a password reset) so if they are 29 + // able to pass the checkpoint we just want to put their account directly 30 + // into high security mode, rather than prompt them again for the same 31 + // set of credentials. 32 + $jump_into_hisec = $request->getCookie(PhabricatorCookies::COOKIE_HISEC); 33 + 27 34 try { 28 35 $token = $engine->requireHighSecuritySession( 29 36 $viewer, 30 37 $request, 31 - '/logout/'); 38 + '/logout/', 39 + $jump_into_hisec); 32 40 } catch (PhabricatorAuthHighSecurityRequiredException $ex) { 33 41 $form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm( 34 42 $ex->getFactors(), ··· 60 68 61 69 $next = PhabricatorCookies::getNextURICookie($request); 62 70 $request->clearCookie(PhabricatorCookies::COOKIE_NEXTURI); 71 + $request->clearCookie(PhabricatorCookies::COOKIE_HISEC); 63 72 64 73 if (!PhabricatorEnv::isValidLocalWebResource($next)) { 65 74 $next = '/';
+199
src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthOneTimeLoginController 4 + extends PhabricatorAuthController { 5 + 6 + private $id; 7 + private $key; 8 + private $emailID; 9 + private $linkType; 10 + 11 + public function shouldRequireLogin() { 12 + return false; 13 + } 14 + 15 + public function willProcessRequest(array $data) { 16 + $this->linkType = $data['type']; 17 + $this->id = $data['id']; 18 + $this->key = $data['key']; 19 + $this->emailID = idx($data, 'emailID'); 20 + } 21 + 22 + public function processRequest() { 23 + $request = $this->getRequest(); 24 + 25 + if ($request->getUser()->isLoggedIn()) { 26 + return $this->renderError( 27 + pht('You are already logged in.')); 28 + } 29 + 30 + $target_user = id(new PhabricatorPeopleQuery()) 31 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 32 + ->withIDs(array($this->id)) 33 + ->executeOne(); 34 + if (!$target_user) { 35 + return new Aphront404Response(); 36 + } 37 + 38 + // NOTE: As a convenience to users, these one-time login URIs may also 39 + // be associated with an email address which will be verified when the 40 + // URI is used. 41 + 42 + // This improves the new user experience for users receiving "Welcome" 43 + // emails on installs that require verification: if we did not verify the 44 + // email, they'd immediately get roadblocked with a "Verify Your Email" 45 + // error and have to go back to their email account, wait for a 46 + // "Verification" email, and then click that link to actually get access to 47 + // their account. This is hugely unwieldy, and if the link was only sent 48 + // to the user's email in the first place we can safely verify it as a 49 + // side effect of login. 50 + 51 + // The email hashed into the URI so users can't verify some email they 52 + // do not own by doing this: 53 + // 54 + // - Add some address you do not own; 55 + // - request a password reset; 56 + // - change the URI in the email to the address you don't own; 57 + // - login via the email link; and 58 + // - get a "verified" address you don't control. 59 + 60 + $target_email = null; 61 + if ($this->emailID) { 62 + $target_email = id(new PhabricatorUserEmail())->loadOneWhere( 63 + 'userPHID = %s AND id = %d', 64 + $target_user->getPHID(), 65 + $this->emailID); 66 + if (!$target_email) { 67 + return new Aphront404Response(); 68 + } 69 + } 70 + 71 + $engine = new PhabricatorAuthSessionEngine(); 72 + $token = $engine->loadOneTimeLoginKey( 73 + $target_user, 74 + $target_email, 75 + $this->key); 76 + 77 + if (!$token) { 78 + return $this->newDialog() 79 + ->setTitle(pht('Unable to Login')) 80 + ->setShortTitle(pht('Login Failure')) 81 + ->appendParagraph( 82 + pht( 83 + 'The login link you clicked is invalid, out of date, or has '. 84 + 'already been used.')) 85 + ->appendParagraph( 86 + pht( 87 + 'Make sure you are copy-and-pasting the entire link into '. 88 + 'your browser. Login links are only valid for 24 hours, and '. 89 + 'can only be used once.')) 90 + ->appendParagraph( 91 + pht('You can try again, or request a new link via email.')) 92 + ->addCancelButton('/login/email/', pht('Send Another Email')); 93 + } 94 + 95 + if ($request->isFormPost()) { 96 + // If we have an email bound into this URI, verify email so that clicking 97 + // the link in the "Welcome" email is good enough, without requiring users 98 + // to go through a second round of email verification. 99 + 100 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 101 + // Nuke the token so that this URI is one-time only. 102 + $token->delete(); 103 + 104 + if ($target_email) { 105 + $target_user->openTransaction(); 106 + $target_email->setIsVerified(1); 107 + $target_email->save(); 108 + 109 + // If this was the user's primary email address, also mark their 110 + // account as verified. 111 + $primary_email = $target_user->loadPrimaryEmail(); 112 + if ($primary_email->getID() == $target_email->getID()) { 113 + $target_user->setIsEmailVerified(1); 114 + $target_user->save(); 115 + } 116 + $target_user->saveTransaction(); 117 + } 118 + unset($unguarded); 119 + 120 + $next = '/'; 121 + if (!PhabricatorAuthProviderPassword::getPasswordProvider()) { 122 + $next = '/settings/panel/external/'; 123 + } else if (PhabricatorEnv::getEnvConfig('account.editable')) { 124 + 125 + // We're going to let the user reset their password without knowing 126 + // the old one. Generate a one-time token for that. 127 + $key = Filesystem::readRandomCharacters(16); 128 + 129 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 130 + id(new PhabricatorAuthTemporaryToken()) 131 + ->setObjectPHID($target_user->getPHID()) 132 + ->setTokenType( 133 + PhabricatorAuthSessionEngine::PASSWORD_TEMPORARY_TOKEN_TYPE) 134 + ->setTokenExpires(time() + phutil_units('1 hour in seconds')) 135 + ->setTokenCode(PhabricatorHash::digest($key)) 136 + ->save(); 137 + unset($unguarded); 138 + 139 + $next = (string)id(new PhutilURI('/settings/panel/password/')) 140 + ->setQueryParams( 141 + array( 142 + 'key' => $key, 143 + )); 144 + 145 + $request->setTemporaryCookie(PhabricatorCookies::COOKIE_HISEC, 'yes'); 146 + } 147 + 148 + PhabricatorCookies::setNextURICookie($request, $next, $force = true); 149 + 150 + return $this->loginUser($target_user); 151 + } 152 + 153 + // NOTE: We need to CSRF here so attackers can't generate an email link, 154 + // then log a user in to an account they control via sneaky invisible 155 + // form submissions. 156 + 157 + switch ($this->linkType) { 158 + case PhabricatorAuthSessionEngine::ONETIME_WELCOME: 159 + $title = pht('Welcome to Phabricator'); 160 + break; 161 + case PhabricatorAuthSessionEngine::ONETIME_RECOVER: 162 + $title = pht('Account Recovery'); 163 + break; 164 + case PhabricatorAuthSessionEngine::ONETIME_USERNAME: 165 + case PhabricatorAuthSessionEngine::ONETIME_RESET: 166 + default: 167 + $title = pht('Login to Phabricator'); 168 + break; 169 + } 170 + 171 + $body = array(); 172 + $body[] = pht( 173 + 'Use the button below to log in as: %s', 174 + phutil_tag('strong', array(), $target_user->getUsername())); 175 + 176 + if ($target_email && !$target_email->getIsVerified()) { 177 + $body[] = pht( 178 + 'Logging in will verify %s as an email address you own.', 179 + phutil_tag('strong', array(), $target_email->getAddress())); 180 + 181 + } 182 + 183 + $body[] = pht( 184 + 'After logging in you should set a password for your account, or '. 185 + 'link your account to an external account that you can use to '. 186 + 'authenticate in the future.'); 187 + 188 + $dialog = $this->newDialog() 189 + ->setTitle($title) 190 + ->addSubmitButton(pht('Login (%s)', $target_user->getUsername())) 191 + ->addCancelButton('/'); 192 + 193 + foreach ($body as $paragraph) { 194 + $dialog->appendParagraph($paragraph); 195 + } 196 + 197 + return id(new AphrontDialogResponse())->setDialog($dialog); 198 + } 199 + }
+17 -18
src/applications/auth/controller/PhabricatorEmailLoginController.php
··· 59 59 } 60 60 61 61 if (!$errors) { 62 - $uri = $target_user->getEmailLoginURI($target_email); 62 + $engine = new PhabricatorAuthSessionEngine(); 63 + $uri = $engine->getOneTimeLoginURI( 64 + $target_user, 65 + null, 66 + PhabricatorAuthSessionEngine::ONETIME_RESET); 67 + 63 68 if ($is_serious) { 64 69 $body = <<<EOBODY 65 70 You can use this link to reset your Phabricator password: ··· 87 92 // mail if they have the "don't send me email about my own actions" 88 93 // preference set. 89 94 90 - $mail = new PhabricatorMetaMTAMail(); 91 - $mail->setSubject('[Phabricator] Password Reset'); 92 - $mail->addTos( 93 - array( 94 - $target_user->getPHID(), 95 - )); 96 - $mail->setBody($body); 97 - $mail->saveAndSend(); 95 + $mail = id(new PhabricatorMetaMTAMail()) 96 + ->setSubject(pht('[Phabricator] Password Reset')) 97 + ->addRawTos(array($target_email->getAddress())) 98 + ->setBody($body) 99 + ->saveAndSend(); 98 100 99 - $view = new AphrontRequestFailureView(); 100 - $view->setHeader(pht('Check Your Email')); 101 - $view->appendChild(phutil_tag('p', array(), pht( 102 - 'An email has been sent with a link you can use to login.'))); 103 - return $this->buildStandardPageResponse( 104 - $view, 105 - array( 106 - 'title' => pht('Email Sent'), 107 - )); 101 + return $this->newDialog() 102 + ->setTitle(pht('Check Your Email')) 103 + ->setShortTitle(pht('Email Sent')) 104 + ->appendParagraph( 105 + pht('An email has been sent with a link you can use to login.')) 106 + ->addCancelButton('/', pht('Done')); 108 107 } 109 108 } 110 109
-128
src/applications/auth/controller/PhabricatorEmailTokenController.php
··· 1 - <?php 2 - 3 - final class PhabricatorEmailTokenController 4 - extends PhabricatorAuthController { 5 - 6 - private $token; 7 - 8 - public function shouldRequireLogin() { 9 - return false; 10 - } 11 - 12 - public function willProcessRequest(array $data) { 13 - $this->token = $data['token']; 14 - } 15 - 16 - public function processRequest() { 17 - $request = $this->getRequest(); 18 - 19 - if ($request->getUser()->isLoggedIn()) { 20 - return $this->renderError( 21 - pht('You are already logged in.')); 22 - } 23 - 24 - $token = $this->token; 25 - $email = $request->getStr('email'); 26 - 27 - $target_email = id(new PhabricatorUserEmail())->loadOneWhere( 28 - 'address = %s', 29 - $email); 30 - 31 - $target_user = null; 32 - if ($target_email) { 33 - $target_user = id(new PhabricatorUser())->loadOneWhere( 34 - 'phid = %s', 35 - $target_email->getUserPHID()); 36 - } 37 - 38 - // NOTE: We need to bind verification to **addresses**, not **users**, 39 - // because we verify addresses when they're used to login this way, and if 40 - // we have a user-based verification you can: 41 - // 42 - // - Add some address you do not own; 43 - // - request a password reset; 44 - // - change the URI in the email to the address you don't own; 45 - // - login via the email link; and 46 - // - get a "verified" address you don't control. 47 - 48 - if (!$target_email || 49 - !$target_user || 50 - !$target_user->validateEmailToken($target_email, $token)) { 51 - 52 - $view = new AphrontRequestFailureView(); 53 - $view->setHeader(pht('Unable to Login')); 54 - $view->appendChild(phutil_tag('p', array(), pht( 55 - 'The authentication information in the link you clicked is '. 56 - 'invalid or out of date. Make sure you are copy-and-pasting the '. 57 - 'entire link into your browser. You can try again, or request '. 58 - 'a new email.'))); 59 - $view->appendChild(phutil_tag_div( 60 - 'aphront-failure-continue', 61 - phutil_tag( 62 - 'a', 63 - array('class' => 'button', 'href' => '/login/email/'), 64 - pht('Send Another Email')))); 65 - 66 - return $this->buildStandardPageResponse( 67 - $view, 68 - array( 69 - 'title' => pht('Login Failure'), 70 - )); 71 - } 72 - 73 - if ($request->isFormPost()) { 74 - // Verify email so that clicking the link in the "Welcome" email is good 75 - // enough, without requiring users to go through a second round of email 76 - // verification. 77 - 78 - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 79 - $target_email->setIsVerified(1); 80 - $target_email->save(); 81 - unset($unguarded); 82 - 83 - $next = '/'; 84 - if (!PhabricatorAuthProviderPassword::getPasswordProvider()) { 85 - $next = '/settings/panel/external/'; 86 - } else if (PhabricatorEnv::getEnvConfig('account.editable')) { 87 - $next = (string)id(new PhutilURI('/settings/panel/password/')) 88 - ->setQueryParams( 89 - array( 90 - 'token' => $token, 91 - 'email' => $email, 92 - )); 93 - } 94 - 95 - PhabricatorCookies::setNextURICookie($request, $next, $force = true); 96 - 97 - return $this->loginUser($target_user); 98 - } 99 - 100 - // NOTE: We need to CSRF here so attackers can't generate an email link, 101 - // then log a user in to an account they control via sneaky invisible 102 - // form submissions. 103 - 104 - // TODO: Since users can arrive here either through password reset or 105 - // through welcome emails, it might be nice to include the workflow type 106 - // in the URI or query params so we can tailor the messaging. Right now, 107 - // it has to be generic enough to make sense in either workflow, which 108 - // leaves it feeling a little awkward. 109 - 110 - $dialog = id(new AphrontDialogView()) 111 - ->setUser($request->getUser()) 112 - ->setTitle(pht('Login to Phabricator')) 113 - ->addHiddenInput('email', $email) 114 - ->appendParagraph( 115 - pht( 116 - 'Use the button below to log in as: %s', 117 - phutil_tag('strong', array(), $email))) 118 - ->appendParagraph( 119 - pht( 120 - 'After logging in you should set a password for your account, or '. 121 - 'link your account to an external account that you can use to '. 122 - 'authenticate in the future.')) 123 - ->addSubmitButton(pht('Login (%s)', $email)) 124 - ->addCancelButton('/'); 125 - 126 - return id(new AphrontDialogResponse())->setDialog($dialog); 127 - } 128 - }
+133 -4
src/applications/auth/engine/PhabricatorAuthSessionEngine.php
··· 6 6 * @task new Creating Sessions 7 7 * @task hisec High Security 8 8 * @task partial Partial Sessions 9 + * @task onetime One Time Login URIs 9 10 */ 10 11 final class PhabricatorAuthSessionEngine extends Phobject { 11 12 ··· 36 37 * Session kind isn't known. 37 38 */ 38 39 const KIND_UNKNOWN = '?'; 40 + 41 + 42 + /** 43 + * Temporary tokens for one time logins. 44 + */ 45 + const ONETIME_TEMPORARY_TOKEN_TYPE = 'login:onetime'; 46 + 47 + 48 + /** 49 + * Temporary tokens for password recovery after one time login. 50 + */ 51 + const PASSWORD_TEMPORARY_TOKEN_TYPE = 'login:password'; 52 + 53 + const ONETIME_RECOVER = 'recover'; 54 + const ONETIME_RESET = 'reset'; 55 + const ONETIME_WELCOME = 'welcome'; 56 + const ONETIME_USERNAME = 'rename'; 39 57 40 58 41 59 /** ··· 245 263 * @param PhabricatorUser User whose session needs to be in high security. 246 264 * @param AphrontReqeust Current request. 247 265 * @param string URI to return the user to if they cancel. 266 + * @param bool True to jump partial sessions directly into high 267 + * security instead of just upgrading them to full 268 + * sessions. 248 269 * @return PhabricatorAuthHighSecurityToken Security token. 249 270 * @task hisec 250 271 */ 251 272 public function requireHighSecuritySession( 252 273 PhabricatorUser $viewer, 253 274 AphrontRequest $request, 254 - $cancel_uri) { 275 + $cancel_uri, 276 + $jump_into_hisec = false) { 255 277 256 278 if (!$viewer->hasSession()) { 257 279 throw new Exception( ··· 320 342 new PhabricatorAuthTryFactorAction(), 321 343 -1); 322 344 323 - if ($session->getIsPartial()) { 324 - // If we have a partial session, just issue a token without 325 - // putting it in high security mode. 345 + if ($session->getIsPartial() && !$jump_into_hisec) { 346 + // If we have a partial session and are not jumping directly into 347 + // hisec, just issue a token without putting it in high security 348 + // mode. 326 349 return $this->issueHighSecurityToken($session, true); 327 350 } 328 351 ··· 459 482 * @task partial 460 483 */ 461 484 public function upgradePartialSession(PhabricatorUser $viewer) { 485 + 462 486 if (!$viewer->hasSession()) { 463 487 throw new Exception( 464 488 pht('Upgrading partial session of user with no session!')); ··· 486 510 PhabricatorUserLog::ACTION_LOGIN_FULL); 487 511 $log->save(); 488 512 unset($unguarded); 513 + } 489 514 515 + 516 + /* -( One Time Login URIs )------------------------------------------------ */ 517 + 518 + 519 + /** 520 + * Retrieve a temporary, one-time URI which can log in to an account. 521 + * 522 + * These URIs are used for password recovery and to regain access to accounts 523 + * which users have been locked out of. 524 + * 525 + * @param PhabricatorUser User to generate a URI for. 526 + * @param PhabricatorUserEmail Optionally, email to verify when 527 + * link is used. 528 + * @param string Optional context string for the URI. This is purely cosmetic 529 + * and used only to customize workflow and error messages. 530 + * @return string Login URI. 531 + * @task onetime 532 + */ 533 + public function getOneTimeLoginURI( 534 + PhabricatorUser $user, 535 + PhabricatorUserEmail $email = null, 536 + $type = self::ONETIME_RESET) { 537 + 538 + $key = Filesystem::readRandomCharacters(32); 539 + $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key); 540 + 541 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 542 + id(new PhabricatorAuthTemporaryToken()) 543 + ->setObjectPHID($user->getPHID()) 544 + ->setTokenType(self::ONETIME_TEMPORARY_TOKEN_TYPE) 545 + ->setTokenExpires(time() + phutil_units('1 day in seconds')) 546 + ->setTokenCode($key_hash) 547 + ->save(); 548 + unset($unguarded); 549 + 550 + $uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/'; 551 + if ($email) { 552 + $uri = $uri.$email->getID().'/'; 553 + } 554 + 555 + try { 556 + $uri = PhabricatorEnv::getProductionURI($uri); 557 + } catch (Exception $ex) { 558 + // If a user runs `bin/auth recover` before configuring the base URI, 559 + // just show the path. We don't have any way to figure out the domain. 560 + // See T4132. 561 + } 562 + 563 + return $uri; 564 + } 565 + 566 + 567 + /** 568 + * Load the temporary token associated with a given one-time login key. 569 + * 570 + * @param PhabricatorUser User to load the token for. 571 + * @param PhabricatorUserEmail Optionally, email to verify when 572 + * link is used. 573 + * @param string Key user is presenting as a valid one-time login key. 574 + * @return PhabricatorAuthTemporaryToken|null Token, if one exists. 575 + * @task onetime 576 + */ 577 + public function loadOneTimeLoginKey( 578 + PhabricatorUser $user, 579 + PhabricatorUserEmail $email = null, 580 + $key = null) { 581 + 582 + $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key); 583 + 584 + return id(new PhabricatorAuthTemporaryTokenQuery()) 585 + ->setViewer($user) 586 + ->withObjectPHIDs(array($user->getPHID())) 587 + ->withTokenTypes(array(self::ONETIME_TEMPORARY_TOKEN_TYPE)) 588 + ->withTokenCodes(array($key_hash)) 589 + ->withExpired(false) 590 + ->executeOne(); 591 + } 592 + 593 + 594 + /** 595 + * Hash a one-time login key for storage as a temporary token. 596 + * 597 + * @param PhabricatorUser User this key is for. 598 + * @param PhabricatorUserEmail Optionally, email to verify when 599 + * link is used. 600 + * @param string The one time login key. 601 + * @return string Hash of the key. 602 + * task onetime 603 + */ 604 + private function getOneTimeLoginKeyHash( 605 + PhabricatorUser $user, 606 + PhabricatorUserEmail $email = null, 607 + $key = null) { 608 + 609 + $parts = array( 610 + $key, 611 + $user->getAccountSecret(), 612 + ); 613 + 614 + if ($email) { 615 + $parts[] = $email->getVerificationCode(); 616 + } 617 + 618 + return PhabricatorHash::digest(implode(':', $parts)); 490 619 } 491 620 492 621 }
+7 -1
src/applications/auth/management/PhabricatorAuthManagementRecoverWorkflow.php
··· 69 69 $can_recover)); 70 70 } 71 71 72 + $engine = new PhabricatorAuthSessionEngine(); 73 + $onetime_uri = $engine->getOneTimeLoginURI( 74 + $user, 75 + null, 76 + PhabricatorAuthSessionEngine::ONETIME_RECOVER); 77 + 72 78 $console = PhutilConsole::getConsole(); 73 79 $console->writeOut( 74 80 pht( ··· 76 82 'interface:', 77 83 $username)); 78 84 $console->writeOut("\n\n"); 79 - $console->writeOut(" %s", $user->getEmailLoginURI()); 85 + $console->writeOut(' %s', $onetime_uri); 80 86 $console->writeOut("\n\n"); 81 87 $console->writeOut( 82 88 pht(
+11 -56
src/applications/people/storage/PhabricatorUser.php
··· 342 342 return substr(PhabricatorHash::digest($vec), 0, $len); 343 343 } 344 344 345 - private function generateEmailToken( 346 - PhabricatorUserEmail $email, 347 - $offset = 0) { 348 - 349 - $key = implode( 350 - '-', 351 - array( 352 - PhabricatorEnv::getEnvConfig('phabricator.csrf-key'), 353 - $this->getPHID(), 354 - $email->getVerificationCode(), 355 - )); 356 - 357 - return $this->generateToken( 358 - time() + ($offset * self::EMAIL_CYCLE_FREQUENCY), 359 - self::EMAIL_CYCLE_FREQUENCY, 360 - $key, 361 - self::EMAIL_TOKEN_LENGTH); 362 - } 363 - 364 - public function validateEmailToken( 365 - PhabricatorUserEmail $email, 366 - $token) { 367 - for ($ii = -1; $ii <= 1; $ii++) { 368 - $valid = $this->generateEmailToken($email, $ii); 369 - if ($token == $valid) { 370 - return true; 371 - } 372 - } 373 - return false; 374 - } 375 - 376 - public function getEmailLoginURI(PhabricatorUserEmail $email = null) { 377 - if (!$email) { 378 - $email = $this->loadPrimaryEmail(); 379 - if (!$email) { 380 - throw new Exception("User has no primary email!"); 381 - } 382 - } 383 - $token = $this->generateEmailToken($email); 384 - 385 - $uri = '/login/etoken/'.$token.'/'; 386 - try { 387 - $uri = PhabricatorEnv::getProductionURI($uri); 388 - } catch (Exception $ex) { 389 - // If a user runs `bin/auth recover` before configuring the base URI, 390 - // just show the path. We don't have any way to figure out the domain. 391 - // See T4132. 392 - } 393 - 394 - $uri = new PhutilURI($uri); 395 - 396 - return $uri->alter('email', $email->getAddress()); 397 - } 398 - 399 345 public function attachUserProfile(PhabricatorUserProfile $profile) { 400 346 $this->profile = $profile; 401 347 return $this; ··· 567 513 568 514 $base_uri = PhabricatorEnv::getProductionURI('/'); 569 515 570 - $uri = $this->getEmailLoginURI(); 516 + $engine = new PhabricatorAuthSessionEngine(); 517 + $uri = $engine->getOneTimeLoginURI( 518 + $this, 519 + $this->loadPrimaryEmail(), 520 + PhabricatorAuthSessionEngine::ONETIME_WELCOME); 521 + 571 522 $body = <<<EOBODY 572 523 Welcome to Phabricator! 573 524 ··· 611 562 612 563 $password_instructions = null; 613 564 if (PhabricatorAuthProviderPassword::getPasswordProvider()) { 614 - $uri = $this->getEmailLoginURI(); 565 + $engine = new PhabricatorAuthSessionEngine(); 566 + $uri = $engine->getOneTimeLoginURI( 567 + $this, 568 + null, 569 + PhabricatorAuthSessionEngine::ONETIME_USERNAME); 615 570 $password_instructions = <<<EOTXT 616 571 If you use a password to login, you'll need to reset it before you can login 617 572 again. You can reset your password by following this link:
+18 -15
src/applications/settings/panel/PhabricatorSettingsPanelPassword.php
··· 47 47 // either by providing the old password or by carrying a token to 48 48 // the workflow from a password reset email. 49 49 50 - $token = $request->getStr('token'); 51 - 52 - $valid_token = false; 53 - if ($token) { 54 - $email_address = $request->getStr('email'); 55 - $email = id(new PhabricatorUserEmail())->loadOneWhere( 56 - 'address = %s', 57 - $email_address); 58 - if ($email) { 59 - $valid_token = $user->validateEmailToken($email, $token); 60 - } 50 + $key = $request->getStr('key'); 51 + $token = null; 52 + if ($key) { 53 + $token = id(new PhabricatorAuthTemporaryTokenQuery()) 54 + ->setViewer($user) 55 + ->withObjectPHIDs(array($user->getPHID())) 56 + ->withTokenTypes( 57 + array(PhabricatorAuthSessionEngine::PASSWORD_TEMPORARY_TOKEN_TYPE)) 58 + ->withTokenCodes(array(PhabricatorHash::digest($key))) 59 + ->withExpired(false) 60 + ->executeOne(); 61 61 } 62 62 63 63 $e_old = true; ··· 66 66 67 67 $errors = array(); 68 68 if ($request->isFormPost()) { 69 - if (!$valid_token) { 69 + if (!$token) { 70 70 $envelope = new PhutilOpaqueEnvelope($request->getStr('old_pw')); 71 71 if (!$user->comparePassword($envelope)) { 72 72 $errors[] = pht('The old password you entered is incorrect.'); ··· 105 105 106 106 unset($unguarded); 107 107 108 - if ($valid_token) { 108 + if ($token) { 109 + // Destroy the token. 110 + $token->delete(); 111 + 109 112 // If this is a password set/reset, kick the user to the home page 110 113 // after we update their account. 111 114 $next = '/'; ··· 135 138 $form = new AphrontFormView(); 136 139 $form 137 140 ->setUser($user) 138 - ->addHiddenInput('token', $token); 141 + ->addHiddenInput('key', $key); 139 142 140 - if (!$valid_token) { 143 + if (!$token) { 141 144 $form->appendChild( 142 145 id(new AphrontFormPasswordControl()) 143 146 ->setLabel(pht('Old Password'))