@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
at upstream/main 279 lines 9.7 kB view raw
1<?php 2 3final class PhabricatorAuthOneTimeLoginController 4 extends PhabricatorAuthController { 5 6 public function shouldRequireLogin() { 7 return false; 8 } 9 10 public function handleRequest(AphrontRequest $request) { 11 $viewer = $this->getViewer(); 12 $id = $request->getURIData('id'); 13 $link_type = $request->getURIData('type'); 14 $key = $request->getURIData('key'); 15 $email_id = $request->getURIData('emailID'); 16 17 $target_user = id(new PhabricatorPeopleQuery()) 18 ->setViewer(PhabricatorUser::getOmnipotentUser()) 19 ->withIDs(array($id)) 20 ->executeOne(); 21 if (!$target_user) { 22 return new Aphront404Response(); 23 } 24 25 // NOTE: We allow you to use a one-time login link for your own current 26 // login account. This supports the "Set Password" flow. 27 28 $is_logged_in = false; 29 if ($viewer->isLoggedIn()) { 30 if ($viewer->getPHID() !== $target_user->getPHID()) { 31 return $this->renderError( 32 pht('You are already logged in.')); 33 } else { 34 $is_logged_in = true; 35 } 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 ($email_id) { 62 $target_email = id(new PhabricatorUserEmail())->loadOneWhere( 63 'userPHID = %s AND id = %d', 64 $target_user->getPHID(), 65 $email_id); 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 $key); 76 77 if (!$token) { 78 return $this->newDialog() 79 ->setTitle(pht('Unable to Log In')) 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 (!$target_user->canEstablishWebSessions()) { 96 return $this->newDialog() 97 ->setTitle(pht('Unable to Establish Web Session')) 98 ->setShortTitle(pht('Login Failure')) 99 ->appendParagraph( 100 pht( 101 'You are trying to gain access to an account ("%s") that can not '. 102 'establish a web session.', 103 $target_user->getUsername())) 104 ->appendParagraph( 105 pht( 106 'Special users like daemons and mailing lists are not permitted '. 107 'to log in via the web. Log in as a normal user instead.')) 108 ->addCancelButton('/'); 109 } 110 111 if ($request->isFormPost() || $is_logged_in) { 112 // If we have an email bound into this URI, verify email so that clicking 113 // the link in the "Welcome" email is good enough, without requiring users 114 // to go through a second round of email verification. 115 116 $editor = id(new PhabricatorUserEditor()) 117 ->setActor($target_user); 118 119 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 120 // Nuke the token and all other outstanding password reset tokens. 121 // There is no particular security benefit to destroying them all, but 122 // it should reduce HackerOne reports of nebulous harm. 123 $editor->revokePasswordResetLinks($target_user); 124 125 if ($target_email) { 126 $editor->verifyEmail($target_user, $target_email); 127 } 128 unset($unguarded); 129 130 $next_uri = $this->getNextStepURI($target_user); 131 132 // If the user is already logged in, we're just doing a "password set" 133 // flow. Skip directly to the next step. 134 if ($is_logged_in) { 135 return id(new AphrontRedirectResponse())->setURI($next_uri); 136 } 137 138 PhabricatorCookies::setNextURICookie($request, $next_uri, $force = true); 139 140 $force_full_session = false; 141 if ($link_type === PhabricatorAuthSessionEngine::ONETIME_RECOVER) { 142 $force_full_session = $token->getShouldForceFullSession(); 143 } 144 145 return $this->loginUser($target_user, $force_full_session); 146 } 147 148 // NOTE: We need to CSRF here so attackers can't generate an email link, 149 // then log a user in to an account they control via sneaky invisible 150 // form submissions. 151 152 switch ($link_type) { 153 case PhabricatorAuthSessionEngine::ONETIME_WELCOME: 154 $title = pht( 155 'Welcome to %s', 156 PlatformSymbols::getPlatformServerName()); 157 break; 158 case PhabricatorAuthSessionEngine::ONETIME_RECOVER: 159 $title = pht('Account Recovery'); 160 break; 161 case PhabricatorAuthSessionEngine::ONETIME_USERNAME: 162 case PhabricatorAuthSessionEngine::ONETIME_RESET: 163 default: 164 $title = pht( 165 'Log in to %s', 166 PlatformSymbols::getPlatformServerName()); 167 break; 168 } 169 170 $body = array(); 171 $body[] = pht( 172 'Use the button below to log in as: %s', 173 phutil_tag('strong', array(), $target_user->getUsername())); 174 175 if ($target_email && !$target_email->getIsVerified()) { 176 $body[] = pht( 177 'Logging in will verify %s as an email address you own.', 178 phutil_tag('strong', array(), $target_email->getAddress())); 179 180 } 181 182 $body[] = pht( 183 'After logging in you should set a password for your account, or '. 184 'link your account to an external account that you can use to '. 185 'authenticate in the future.'); 186 187 $dialog = $this->newDialog() 188 ->setTitle($title) 189 ->addSubmitButton(pht('Log In (%s)', $target_user->getUsername())) 190 ->addCancelButton('/'); 191 192 foreach ($body as $paragraph) { 193 $dialog->appendParagraph($paragraph); 194 } 195 196 return id(new AphrontDialogResponse())->setDialog($dialog); 197 } 198 199 private function getNextStepURI(PhabricatorUser $user) { 200 $request = $this->getRequest(); 201 202 // If we have password auth, let the user set or reset their password after 203 // login. 204 $have_passwords = PhabricatorPasswordAuthProvider::getPasswordProvider(); 205 if ($have_passwords) { 206 // We're going to let the user reset their password without knowing 207 // the old one. Generate a one-time token for that. 208 $key = Filesystem::readRandomCharacters(16); 209 $password_type = 210 PhabricatorAuthPasswordResetTemporaryTokenType::TOKENTYPE; 211 212 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 213 id(new PhabricatorAuthTemporaryToken()) 214 ->setTokenResource($user->getPHID()) 215 ->setTokenType($password_type) 216 ->setTokenExpires(time() + phutil_units('1 hour in seconds')) 217 ->setTokenCode(PhabricatorHash::weakDigest($key)) 218 ->save(); 219 unset($unguarded); 220 221 $panel_uri = '/auth/password/'; 222 223 $request->setTemporaryCookie(PhabricatorCookies::COOKIE_HISEC, 'yes'); 224 225 $params = array( 226 'key' => $key, 227 ); 228 229 return (string)new PhutilURI($panel_uri, $params); 230 } 231 232 // Check if the user already has external accounts linked. If they do, 233 // it's not obvious why they aren't using them to log in, but assume they 234 // know what they're doing. We won't send them to the link workflow. 235 $accounts = id(new PhabricatorExternalAccountQuery()) 236 ->setViewer($user) 237 ->withUserPHIDs(array($user->getPHID())) 238 ->execute(); 239 240 $configs = id(new PhabricatorAuthProviderConfigQuery()) 241 ->setViewer($user) 242 ->withIsEnabled(true) 243 ->execute(); 244 245 $linkable = array(); 246 foreach ($configs as $config) { 247 if (!$config->getShouldAllowLink()) { 248 continue; 249 } 250 251 $provider = $config->getProvider(); 252 if (!$provider->isLoginFormAButton()) { 253 continue; 254 } 255 256 $linkable[] = $provider; 257 } 258 259 // If there's at least one linkable provider, and the user doesn't already 260 // have accounts, send the user to the link workflow. 261 if (!$accounts && $linkable) { 262 return '/auth/external/'; 263 } 264 265 // If there are no configured providers and the user is an administrator, 266 // send them to Auth to configure a provider. This is probably where they 267 // want to go. You can end up in this state by accidentally losing your 268 // first session during initial setup, or after restoring exported data 269 // from a hosted instance. 270 if (!$configs && $user->getIsAdmin()) { 271 return '/auth/'; 272 } 273 274 // If we didn't find anywhere better to send them, give up and just send 275 // them to the home page. 276 return '/'; 277 } 278 279}