@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 318 lines 11 kB view raw
1<?php 2 3final class PhabricatorOAuthServerAuthController 4 extends PhabricatorOAuthServerController { 5 6 protected function buildApplicationCrumbs() { 7 // We're specifically not putting an "OAuth Server" application crumb 8 // on the auth pages because it doesn't make sense to send users there. 9 return new PHUICrumbsView(); 10 } 11 12 public function handleRequest(AphrontRequest $request) { 13 $viewer = $this->getViewer(); 14 15 $server = new PhabricatorOAuthServer(); 16 $client_phid = $request->getStr('client_id'); 17 $redirect_uri = $request->getStr('redirect_uri'); 18 $response_type = $request->getStr('response_type'); 19 20 // state is an opaque value the client sent us for their own purposes 21 // we just need to send it right back to them in the response! 22 $state = $request->getStr('state'); 23 24 if (!$client_phid) { 25 return $this->buildErrorResponse( 26 'invalid_request', 27 pht('Malformed Request'), 28 pht( 29 'Required parameter %s was not present in the request.', 30 phutil_tag('strong', array(), 'client_id'))); 31 } 32 33 // We require that users must be able to see an OAuth application 34 // in order to authorize it. This allows an application's visibility 35 // policy to be used to restrict authorized users. 36 try { 37 $client = id(new PhabricatorOAuthServerClientQuery()) 38 ->setViewer($viewer) 39 ->withPHIDs(array($client_phid)) 40 ->executeOne(); 41 } catch (PhabricatorPolicyException $ex) { 42 $ex->setContext(self::CONTEXT_AUTHORIZE); 43 throw $ex; 44 } 45 46 $server->setUser($viewer); 47 $is_authorized = false; 48 $authorization = null; 49 $uri = null; 50 $name = null; 51 52 // one giant try / catch around all the exciting database stuff so we 53 // can return a 'server_error' response if something goes wrong! 54 try { 55 if (!$client) { 56 return $this->buildErrorResponse( 57 'invalid_request', 58 pht('Invalid Client Application'), 59 pht( 60 'Request parameter %s does not specify a valid client application.', 61 phutil_tag('strong', array(), 'client_id'))); 62 } 63 64 if ($client->getIsDisabled()) { 65 return $this->buildErrorResponse( 66 'invalid_request', 67 pht('Application Disabled'), 68 pht( 69 'The %s OAuth application has been disabled.', 70 phutil_tag('strong', array(), 'client_id'))); 71 } 72 73 $name = $client->getName(); 74 $server->setClient($client); 75 if ($redirect_uri) { 76 $client_uri = new PhutilURI($client->getRedirectURI()); 77 $redirect_uri = new PhutilURI($redirect_uri); 78 if (!($server->validateSecondaryRedirectURI($redirect_uri, 79 $client_uri))) { 80 return $this->buildErrorResponse( 81 'invalid_request', 82 pht('Invalid Redirect URI'), 83 pht( 84 'Request parameter %s specifies an invalid redirect URI. '. 85 'The redirect URI must be a fully-qualified domain with no '. 86 'fragments, and must have the same domain and at least '. 87 'the same query parameters as the redirect URI the client '. 88 'registered.', 89 phutil_tag('strong', array(), 'redirect_uri'))); 90 } 91 $uri = $redirect_uri; 92 } else { 93 $uri = new PhutilURI($client->getRedirectURI()); 94 } 95 96 if (empty($response_type)) { 97 return $this->buildErrorResponse( 98 'invalid_request', 99 pht('Invalid Response Type'), 100 pht( 101 'Required request parameter %s is missing.', 102 phutil_tag('strong', array(), 'response_type'))); 103 } 104 105 if ($response_type != 'code') { 106 return $this->buildErrorResponse( 107 'unsupported_response_type', 108 pht('Unsupported Response Type'), 109 pht( 110 'Request parameter %s specifies an unsupported response type. '. 111 'Valid response types are: %s.', 112 phutil_tag('strong', array(), 'response_type'), 113 implode(', ', array('code')))); 114 } 115 116 117 $requested_scope = $request->getStrList('scope'); 118 $requested_scope = array_fuse($requested_scope); 119 120 $scope = PhabricatorOAuthServerScope::filterScope($requested_scope); 121 122 // NOTE: We're always requiring a confirmation dialog to redirect. 123 // Partly this is a general defense against redirect attacks, and 124 // partly this shakes off anchors in the URI (which are not shaken 125 // by 302'ing). 126 127 $auth_info = $server->userHasAuthorizedClient($scope); 128 list($is_authorized, $authorization) = $auth_info; 129 130 if ($request->isFormPost()) { 131 if ($authorization) { 132 $authorization->setScope($scope)->save(); 133 } else { 134 $authorization = $server->authorizeClient($scope); 135 } 136 137 $is_authorized = true; 138 } 139 } catch (Exception $e) { 140 return $this->buildErrorResponse( 141 'server_error', 142 pht('Server Error'), 143 pht( 144 'The authorization server encountered an unexpected condition '. 145 'which prevented it from fulfilling the request.')); 146 } 147 148 // When we reach this part of the controller, we can be in two states: 149 // 150 // 1. The user has not authorized the application yet. We want to 151 // give them an "Authorize this application?" dialog. 152 // 2. The user has authorized the application. We want to give them 153 // a "Confirm Login" dialog. 154 155 if ($is_authorized) { 156 157 // The second case is simpler, so handle it first. The user either 158 // authorized the application previously, or has just authorized the 159 // application. Show them a confirm dialog with a normal link back to 160 // the application. This shakes anchors from the URI. 161 162 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 163 $auth_code = $server->generateAuthorizationCode($uri); 164 unset($unguarded); 165 166 $full_uri = $this->addQueryParams( 167 $uri, 168 array( 169 'code' => $auth_code->getCode(), 170 'scope' => $authorization->getScopeString(), 171 'state' => $state, 172 )); 173 174 if ($client->getIsTrusted()) { 175 // NOTE: See T13099. We currently emit a "Content-Security-Policy" 176 // which includes a narrow "form-action". At the time of writing, 177 // Chrome applies "form-action" to redirects following form submission. 178 179 // This can lead to a situation where a user enters the OAuth workflow 180 // and is prompted for MFA. When they submit an MFA response, the form 181 // can redirect here, and Chrome will block the "Location" redirect. 182 183 // To avoid this, render an interstitial. We only actually need to do 184 // this in Chrome (but do it everywhere for consistency) and only need 185 // to do it if the request is a redirect after a form submission (but 186 // we can't tell if it is or not). 187 188 Javelin::initBehavior( 189 'redirect', 190 array( 191 'uri' => (string)$full_uri, 192 )); 193 194 return $this->newDialog() 195 ->setTitle(pht('Authenticate: %s', $name)) 196 ->appendParagraph( 197 pht( 198 'Authorization for "%s" confirmed, redirecting...', 199 phutil_tag('strong', array(), $name))) 200 ->addCancelButton((string)$full_uri, pht('Continue')); 201 } 202 203 // TODO: It would be nice to give the user more options here, like 204 // reviewing permissions, canceling the authorization, or aborting 205 // the workflow. 206 207 $dialog = id(new AphrontDialogView()) 208 ->setUser($viewer) 209 ->setTitle(pht('Authenticate: %s', $name)) 210 ->appendParagraph( 211 pht( 212 'This application ("%s") is authorized to use your %s '. 213 'credentials. Continue to complete the authentication workflow.', 214 phutil_tag('strong', array(), $name), 215 PlatformSymbols::getPlatformServerName())) 216 ->addCancelButton((string)$full_uri, pht('Continue to Application')); 217 218 return id(new AphrontDialogResponse())->setDialog($dialog); 219 } 220 221 // Here, we're confirming authorization for the application. 222 if ($authorization) { 223 $missing_scope = array_diff_key($scope, $authorization->getScope()); 224 } else { 225 $missing_scope = $scope; 226 } 227 228 $form = id(new AphrontFormView()) 229 ->addHiddenInput('client_id', $client_phid) 230 ->addHiddenInput('redirect_uri', $redirect_uri) 231 ->addHiddenInput('response_type', $response_type) 232 ->addHiddenInput('state', $state) 233 ->addHiddenInput('scope', $request->getStr('scope')) 234 ->setUser($viewer); 235 236 $cancel_msg = pht('The user declined to authorize this application.'); 237 $cancel_uri = $this->addQueryParams( 238 $uri, 239 array( 240 'error' => 'access_denied', 241 'error_description' => $cancel_msg, 242 )); 243 244 $dialog = $this->newDialog() 245 ->setShortTitle(pht('Authorize Access')) 246 ->setTitle(pht('Authorize "%s"?', $name)) 247 ->setSubmitURI($request->getRequestURI()->getPath()) 248 ->setWidth(AphrontDialogView::WIDTH_FORM) 249 ->appendParagraph( 250 pht( 251 'Do you want to authorize the external application "%s" to '. 252 'access your %s account data, including your primary '. 253 'email address?', 254 phutil_tag('strong', array(), $name), 255 PlatformSymbols::getPlatformServerName())) 256 ->appendForm($form) 257 ->addSubmitButton(pht('Authorize Access')) 258 ->addCancelButton((string)$cancel_uri, pht('Do Not Authorize')); 259 260 if ($missing_scope) { 261 $dialog->appendParagraph( 262 pht( 263 'This application has requested these additional permissions. '. 264 'Authorizing it will grant it the permissions it requests:')); 265 foreach ($missing_scope as $scope_key => $ignored) { 266 // TODO: Once we introduce more scopes, explain them here. 267 } 268 } 269 270 $unknown_scope = array_diff_key($requested_scope, $scope); 271 if ($unknown_scope) { 272 $dialog->appendParagraph( 273 pht( 274 'This application also requested additional unrecognized '. 275 'permissions. These permissions may have existed in an older '. 276 'version of the software, or may be from a future version of '. 277 'the software. They will not be granted.')); 278 279 $unknown_form = id(new AphrontFormView()) 280 ->setViewer($viewer) 281 ->appendChild( 282 id(new AphrontFormTextControl()) 283 ->setLabel(pht('Unknown Scope')) 284 ->setValue(implode(', ', array_keys($unknown_scope))) 285 ->setDisabled(true)); 286 287 $dialog->appendForm($unknown_form); 288 } 289 290 return $dialog; 291 } 292 293 294 private function buildErrorResponse($code, $title, $message) { 295 $viewer = $this->getRequest()->getUser(); 296 297 return $this->newDialog() 298 ->setTitle(pht('OAuth: %s', $title)) 299 ->appendParagraph($message) 300 ->appendParagraph( 301 pht('OAuth Error Code: %s', phutil_tag('tt', array(), $code))) 302 ->addCancelButton('/', pht('Alas!')); 303 } 304 305 306 private function addQueryParams(PhutilURI $uri, array $params) { 307 $full_uri = clone $uri; 308 309 foreach ($params as $key => $value) { 310 if (strlen($value)) { 311 $full_uri->replaceQueryParam($key, $value); 312 } 313 } 314 315 return $full_uri; 316 } 317 318}