@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<?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}