@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
3abstract class PhabricatorAuthProvider extends Phobject {
4
5 private $providerConfig;
6
7 public function attachProviderConfig(PhabricatorAuthProviderConfig $config) {
8 $this->providerConfig = $config;
9 return $this;
10 }
11
12 public function hasProviderConfig() {
13 return (bool)$this->providerConfig;
14 }
15
16 public function getProviderConfig() {
17 if ($this->providerConfig === null) {
18 throw new PhutilInvalidStateException('attachProviderConfig');
19 }
20 return $this->providerConfig;
21 }
22
23 public function getProviderConfigPHID() {
24 return $this->getProviderConfig()->getPHID();
25 }
26
27 public function getConfigurationHelp() {
28 return null;
29 }
30
31 public function getDefaultProviderConfig() {
32 return id(new PhabricatorAuthProviderConfig())
33 ->setProviderClass(get_class($this))
34 ->setIsEnabled(1)
35 ->setShouldAllowLogin(1)
36 ->setShouldAllowRegistration(1)
37 ->setShouldAllowLink(1)
38 ->setShouldAllowUnlink(1);
39 }
40
41 public function getNameForCreate() {
42 return $this->getProviderName();
43 }
44
45 public function getDescriptionForCreate() {
46 return null;
47 }
48
49 public function getProviderKey() {
50 return $this->getAdapter()->getAdapterKey();
51 }
52
53 public function getProviderType() {
54 return $this->getAdapter()->getAdapterType();
55 }
56
57 public function getProviderDomain() {
58 return $this->getAdapter()->getAdapterDomain();
59 }
60
61 public static function getAllBaseProviders() {
62 return id(new PhutilClassMapQuery())
63 ->setAncestorClass(self::class)
64 ->execute();
65 }
66
67 /**
68 * @return array<static>
69 */
70 public static function getAllProviders() {
71 static $providers;
72
73 if ($providers === null) {
74 $objects = self::getAllBaseProviders();
75
76 $configs = id(new PhabricatorAuthProviderConfigQuery())
77 ->setViewer(PhabricatorUser::getOmnipotentUser())
78 ->execute();
79
80 $providers = array();
81 foreach ($configs as $config) {
82 if (!isset($objects[$config->getProviderClass()])) {
83 // This configuration is for a provider which is not installed.
84 continue;
85 }
86
87 $object = clone $objects[$config->getProviderClass()];
88 $object->attachProviderConfig($config);
89
90 $key = $object->getProviderKey();
91 if (isset($providers[$key])) {
92 throw new Exception(
93 pht(
94 "Two authentication providers use the same provider key ".
95 "('%s'). Each provider must be identified by a unique key.",
96 $key));
97 }
98 $providers[$key] = $object;
99 }
100 }
101
102 return $providers;
103 }
104
105 /**
106 * @return array<static>
107 */
108 public static function getAllEnabledProviders() {
109 $providers = self::getAllProviders();
110 foreach ($providers as $key => $provider) {
111 if (!$provider->isEnabled()) {
112 unset($providers[$key]);
113 }
114 }
115 return $providers;
116 }
117
118 public static function getEnabledProviderByKey($provider_key) {
119 return idx(self::getAllEnabledProviders(), $provider_key);
120 }
121
122 abstract public function getProviderName();
123 abstract public function getAdapter();
124
125 public function isEnabled() {
126 return $this->getProviderConfig()->getIsEnabled();
127 }
128
129 public function shouldAllowLogin() {
130 return $this->getProviderConfig()->getShouldAllowLogin();
131 }
132
133 public function shouldAllowRegistration() {
134 if (!$this->shouldAllowLogin()) {
135 return false;
136 }
137
138 return $this->getProviderConfig()->getShouldAllowRegistration();
139 }
140
141 public function shouldAllowAccountLink() {
142 return $this->getProviderConfig()->getShouldAllowLink();
143 }
144
145 public function shouldAllowAccountUnlink() {
146 return $this->getProviderConfig()->getShouldAllowUnlink();
147 }
148
149 public function shouldTrustEmails() {
150 return $this->shouldAllowEmailTrustConfiguration() &&
151 $this->getProviderConfig()->getShouldTrustEmails();
152 }
153
154 /**
155 * Should we allow the adapter to be marked as "trusted". This is true for
156 * all adapters except those that allow the user to type in emails (see
157 * @{class:PhabricatorPasswordAuthProvider}).
158 */
159 public function shouldAllowEmailTrustConfiguration() {
160 return true;
161 }
162
163 public function buildLoginForm(PhabricatorAuthStartController $controller) {
164 return $this->renderLoginForm($controller->getRequest(), $mode = 'start');
165 }
166
167 public function buildInviteForm(PhabricatorAuthStartController $controller) {
168 return $this->renderLoginForm($controller->getRequest(), $mode = 'invite');
169 }
170
171 abstract public function processLoginRequest(
172 PhabricatorAuthLoginController $controller);
173
174 public function buildLinkForm($controller) {
175 return $this->renderLoginForm($controller->getRequest(), $mode = 'link');
176 }
177
178 public function shouldAllowAccountRefresh() {
179 return true;
180 }
181
182 public function buildRefreshForm(
183 PhabricatorAuthLinkController $controller) {
184 return $this->renderLoginForm($controller->getRequest(), $mode = 'refresh');
185 }
186
187 protected function renderLoginForm(AphrontRequest $request, $mode) {
188 throw new PhutilMethodNotImplementedException();
189 }
190
191 public function createProviders() {
192 return array($this);
193 }
194
195 protected function willSaveAccount(PhabricatorExternalAccount $account) {
196 return;
197 }
198
199 /**
200 * @param array<PhabricatorExternalAccountIdentifier> $identifiers
201 */
202 final protected function newExternalAccountForIdentifiers(
203 array $identifiers) {
204
205 assert_instances_of($identifiers,
206 PhabricatorExternalAccountIdentifier::class);
207
208 if (!$identifiers) {
209 throw new Exception(
210 pht(
211 'Authentication provider (of class "%s") is attempting to '.
212 'load or create an external account, but provided no account '.
213 'identifiers.',
214 get_class($this)));
215 }
216
217 $config = $this->getProviderConfig();
218 $viewer = PhabricatorUser::getOmnipotentUser();
219
220 $raw_identifiers = mpull($identifiers, 'getIdentifierRaw');
221
222 $accounts = id(new PhabricatorExternalAccountQuery())
223 ->setViewer($viewer)
224 ->withProviderConfigPHIDs(array($config->getPHID()))
225 ->withRawAccountIdentifiers($raw_identifiers)
226 ->needAccountIdentifiers(true)
227 ->execute();
228 if (!$accounts) {
229 $account = $this->newExternalAccount();
230 } else if (count($accounts) === 1) {
231 $account = head($accounts);
232 } else {
233 throw new Exception(
234 pht(
235 'Authentication provider (of class "%s") is attempting to load '.
236 'or create an external account, but provided a list of '.
237 'account identifiers which map to more than one account: %s.',
238 get_class($this),
239 implode(', ', $raw_identifiers)));
240 }
241
242 // See T13493. Add all the identifiers to the account. In the case where
243 // an account initially has a lower-quality identifier (like an email
244 // address) and later adds a higher-quality identifier (like a GUID), this
245 // allows us to automatically upgrade toward the higher-quality identifier
246 // and survive API changes which remove the lower-quality identifier more
247 // gracefully.
248
249 foreach ($identifiers as $identifier) {
250 $account->appendIdentifier($identifier);
251 }
252
253 return $this->didUpdateAccount($account);
254 }
255
256 final protected function newExternalAccountForUser(PhabricatorUser $user) {
257 $config = $this->getProviderConfig();
258
259 // When a user logs in with a provider like username/password, they
260 // always already have a Phabricator account (since there's no way they
261 // could have a username otherwise).
262
263 // These users should never go to registration, so we're building a
264 // dummy "external account" which just links directly back to their
265 // internal account.
266
267 $account = id(new PhabricatorExternalAccountQuery())
268 ->setViewer($user)
269 ->withProviderConfigPHIDs(array($config->getPHID()))
270 ->withUserPHIDs(array($user->getPHID()))
271 ->executeOne();
272 if (!$account) {
273 $account = $this->newExternalAccount()
274 ->setUserPHID($user->getPHID());
275 }
276
277 return $this->didUpdateAccount($account);
278 }
279
280 /**
281 * @return PhabricatorExternalAccount
282 */
283 private function didUpdateAccount(PhabricatorExternalAccount $account) {
284 $adapter = $this->getAdapter();
285
286 $account->setUsername($adapter->getAccountName());
287 $account->setRealName($adapter->getAccountRealName());
288 $account->setEmail($adapter->getAccountEmail());
289 $account->setAccountURI($adapter->getAccountURI());
290
291 $account->setProfileImagePHID(null);
292 $image_uri = $adapter->getAccountImageURI();
293 if ($image_uri) {
294 try {
295 $name = PhabricatorSlug::normalize($this->getProviderName());
296 $name = $name.'-profile.jpg';
297
298 // TODO: If the image has not changed, we do not need to make a new
299 // file entry for it, but there's no convenient way to do this with
300 // PhabricatorFile right now. The storage will get shared, so the impact
301 // here is negligible.
302
303 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
304 $image_file = PhabricatorFile::newFromFileDownload(
305 $image_uri,
306 array(
307 'name' => $name,
308 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE,
309 ));
310 if ($image_file->isViewableImage()) {
311 $image_file
312 ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy())
313 ->setCanCDN(true)
314 ->save();
315 $account->setProfileImagePHID($image_file->getPHID());
316 } else {
317 $image_file->delete();
318 }
319 unset($unguarded);
320
321 } catch (Exception $ex) {
322 // Log this but proceed, it's not especially important that we
323 // be able to pull profile images.
324 phlog($ex);
325 }
326 }
327
328 $this->willSaveAccount($account);
329
330 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
331 $account->save();
332 unset($unguarded);
333
334 return $account;
335 }
336
337 public function getLoginURI() {
338 $app = PhabricatorApplication::getByClass(
339 PhabricatorAuthApplication::class);
340 return $app->getApplicationURI('/login/'.$this->getProviderKey().'/');
341 }
342
343 public function getSettingsURI() {
344 return '/settings/panel/external/';
345 }
346
347 public function getStartURI() {
348 $app = PhabricatorApplication::getByClass(
349 PhabricatorAuthApplication::class);
350 $uri = $app->getApplicationURI('/start/');
351 return $uri;
352 }
353
354 public function isDefaultRegistrationProvider() {
355 return false;
356 }
357
358 public function shouldRequireRegistrationPassword() {
359 return false;
360 }
361
362 public function newDefaultExternalAccount() {
363 return $this->newExternalAccount();
364 }
365
366 protected function newExternalAccount() {
367 $config = $this->getProviderConfig();
368 $adapter = $this->getAdapter();
369
370 $account = id(new PhabricatorExternalAccount())
371 ->setProviderConfigPHID($config->getPHID())
372 ->attachAccountIdentifiers(array());
373
374 // TODO: Remove this when these columns are removed. They no longer have
375 // readers or writers (other than this callsite).
376
377 $account
378 ->setAccountType($adapter->getAdapterType())
379 ->setAccountDomain($adapter->getAdapterDomain());
380
381 // TODO: Remove this when "accountID" is removed; the column is not
382 // nullable.
383
384 $account->setAccountID('');
385
386 return $account;
387 }
388
389 public function getLoginOrder() {
390 return '500-'.$this->getProviderName();
391 }
392
393 /**
394 * @return string Name of the icon of the auth provider
395 */
396 protected function getLoginIcon() {
397 return 'Generic';
398 }
399
400 /**
401 * @return PHUIIconView Icon of the auth provider
402 */
403 public function newIconView() {
404 return id(new PHUIIconView())
405 ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN)
406 ->setSpriteIcon($this->getLoginIcon());
407 }
408
409 public function isLoginFormAButton() {
410 return false;
411 }
412
413 public function renderConfigPropertyTransactionTitle(
414 PhabricatorAuthProviderConfigTransaction $xaction) {
415
416 return null;
417 }
418
419 public function readFormValuesFromProvider() {
420 return array();
421 }
422
423 public function readFormValuesFromRequest(AphrontRequest $request) {
424 return array();
425 }
426
427 public function processEditForm(
428 AphrontRequest $request,
429 array $values) {
430
431 $errors = array();
432 $issues = array();
433
434 return array($errors, $issues, $values);
435 }
436
437 public function extendEditForm(
438 AphrontRequest $request,
439 AphrontFormView $form,
440 array $values,
441 array $issues) {
442
443 return;
444 }
445
446 public function willRenderLinkedAccount(
447 PhabricatorUser $viewer,
448 PHUIObjectItemView $item,
449 PhabricatorExternalAccount $account) {
450
451 $account_view = id(new PhabricatorAuthAccountView())
452 ->setExternalAccount($account)
453 ->setAuthProvider($this);
454
455 $item->appendChild(
456 phutil_tag(
457 'div',
458 array(
459 'class' => 'mmr mml mst mmb',
460 ),
461 $account_view));
462 }
463
464 /**
465 * Return true to use a two-step configuration (setup, configure) instead of
466 * the default single-step configuration. In practice, this means that
467 * creating a new provider instance will redirect back to the edit page
468 * instead of the provider list.
469 *
470 * @return bool True if this provider uses two-step configuration.
471 */
472 public function hasSetupStep() {
473 return false;
474 }
475
476 /**
477 * Render a standard login/register button element.
478 *
479 * The `$attributes` parameter takes these keys:
480 *
481 * - `uri`: URI the button should take the user to when clicked.
482 * - `method`: Optional HTTP method the button should use, defaults to GET.
483 *
484 * @param AphrontRequest $request HTTP request.
485 * @param string $mode Request mode string.
486 * @param map $attributes (optional) Additional parameters, see
487 * above.
488 * @return PhutilSafeHTML Log in button.
489 */
490 protected function renderStandardLoginButton(
491 AphrontRequest $request,
492 $mode,
493 array $attributes = array()) {
494
495 PhutilTypeSpec::checkMap(
496 $attributes,
497 array(
498 'method' => 'optional string',
499 'uri' => 'string',
500 'sigil' => 'optional string',
501 ));
502
503 $viewer = $request->getUser();
504 $adapter = $this->getAdapter();
505
506 if ($mode == 'link') {
507 $button_text = pht('Link External Account');
508 } else if ($mode == 'refresh') {
509 $button_text = pht('Refresh Account Link');
510 } else if ($mode == 'invite') {
511 $button_text = pht('Register Account');
512 } else if ($this->shouldAllowRegistration()) {
513 $button_text = pht('Log In or Register');
514 } else {
515 $button_text = pht('Log In');
516 }
517
518 $icon = id(new PHUIIconView())
519 ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN)
520 ->setSpriteIcon($this->getLoginIcon());
521
522 $button = id(new PHUIButtonView())
523 ->setSize(PHUIButtonView::BIG)
524 ->setColor(PHUIButtonView::GREY)
525 ->setIcon($icon)
526 ->setText($button_text)
527 ->setSubtext($this->getProviderName());
528
529 $uri = $attributes['uri'];
530 $uri = new PhutilURI($uri);
531 $params = $uri->getQueryParamsAsPairList();
532 $uri->removeAllQueryParams();
533
534 $content = array($button);
535
536 foreach ($params as $pair) {
537 list($key, $value) = $pair;
538 $content[] = phutil_tag(
539 'input',
540 array(
541 'type' => 'hidden',
542 'name' => $key,
543 'value' => $value,
544 ));
545 }
546
547 $static_response = CelerityAPI::getStaticResourceResponse();
548 $static_response->addContentSecurityPolicyURI('form-action', (string)$uri);
549
550 foreach ($this->getContentSecurityPolicyFormActions() as $csp_uri) {
551 $static_response->addContentSecurityPolicyURI('form-action', $csp_uri);
552 }
553
554 return phabricator_form(
555 $viewer,
556 array(
557 'method' => idx($attributes, 'method', 'GET'),
558 'action' => (string)$uri,
559 'sigil' => idx($attributes, 'sigil'),
560 ),
561 $content);
562 }
563
564 public function renderConfigurationFooter() {
565 return null;
566 }
567
568 public function getAuthCSRFCode(AphrontRequest $request) {
569 $phcid = $request->getCookie(PhabricatorCookies::COOKIE_CLIENTID);
570 if (!phutil_nonempty_string($phcid)) {
571 throw new AphrontMalformedRequestException(
572 pht('Missing Client ID Cookie'),
573 pht(
574 'Your browser did not submit a "%s" cookie with client state '.
575 'information in the request. Check that cookies are enabled. '.
576 'If this problem persists, you may need to clear your cookies.',
577 PhabricatorCookies::COOKIE_CLIENTID),
578 true);
579 }
580
581 return PhabricatorHash::weakDigest($phcid);
582 }
583
584 protected function verifyAuthCSRFCode(AphrontRequest $request, $actual) {
585 $expect = $this->getAuthCSRFCode($request);
586
587 if (!phutil_nonempty_string($actual)) {
588 throw new Exception(
589 pht(
590 'The authentication provider did not return a client state '.
591 'parameter in its response, but one was expected. If this '.
592 'problem persists, you may need to clear your cookies.'));
593 }
594
595 if (!phutil_hashes_are_identical($actual, $expect)) {
596 throw new Exception(
597 pht(
598 'The authentication provider did not return the correct client '.
599 'state parameter in its response. If this problem persists, you may '.
600 'need to clear your cookies.'));
601 }
602 }
603
604 public function supportsAutoLogin() {
605 return false;
606 }
607
608 public function getAutoLoginURI(AphrontRequest $request) {
609 throw new PhutilMethodNotImplementedException();
610 }
611
612 protected function getContentSecurityPolicyFormActions() {
613 return array();
614 }
615
616}