mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-18 03:20:59 +01:00
5dec03af32
Summary: Ref T7303. Currently, our handling of "scope" is fairly rigid and adheres to the spec, but some of these behaviors don't make much sense in practice. Soften some behaviors and make them more flexible: **Soft Failure on Unknown Permissions**: If a client asks for a permission we don't know about, just warn that we don't recognize it instead of fataling. In particular, I plan to make `offline_access` and `whoami` implicit. Older clients that request these permissions will still work fine as long as we don't hard-fatal. **Move `user.whoami` to ALWAYS scope**: Make `whoami` a default permission. We've already done this, in effect; this just formalizes it. **Tokens no longer expire**: Make `offline_access` (infinite-duration tokens) a default permission. I think the OAuth model doesn't map well to reality. It is common for other providers to issue "temporary" tokens with a duration of multiple years, and the refesh workflow is sort of silly. We can add a "temporary" scope later if we need temporary tokens. This flow was potentially extra silly with the "log out of Phacility" use case, where we might need to have you log in again before we could log you out, which is bizarre and senseless. Avoid this nonsense. **Move away from granular permissions**: Users currently get to pick-and-choose which permissions they grant, but this likely rarely/never works in practice and is fairly hostile since applications can't communicate which permissions they need. Applications which can actually operate with only some subset of permissions can make separate requests (e.g., when you activate "cool feature X", it asks for X permission). I think applications that do this are rare; pretty much everything just asks for tons of permissions and everyone grants them. Making this all-or-nothing is better for well-behaved applications and better for users. It's also slightly better for overzealous applications that ask for more than they need, but whatever. Users can make an informed decision, hopefully, and I plan to let administrators force applications to a subset of permissions once we introduce meaningful scopes. Test Plan: - Generated tokens. - Used tokens. - Authorized an instance. - Faked some bogus scopes, got clean authorization. Reviewers: chad Reviewed By: chad Maniphest Tasks: T7303 Differential Revision: https://secure.phabricator.com/D15621
284 lines
8.1 KiB
PHP
284 lines
8.1 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Implements core OAuth 2.0 Server logic.
|
|
*
|
|
* This class should be used behind business logic that parses input to
|
|
* determine pertinent @{class:PhabricatorUser} $user,
|
|
* @{class:PhabricatorOAuthServerClient} $client(s),
|
|
* @{class:PhabricatorOAuthServerAuthorizationCode} $code(s), and.
|
|
* @{class:PhabricatorOAuthServerAccessToken} $token(s).
|
|
*
|
|
* For an OAuth 2.0 server, there are two main steps:
|
|
*
|
|
* 1) Authorization - the user authorizes a given client to access the data
|
|
* the OAuth 2.0 server protects. Once this is achieved / if it has
|
|
* been achived already, the OAuth server sends the client an authorization
|
|
* code.
|
|
* 2) Access Token - the client should send the authorization code received in
|
|
* step 1 along with its id and secret to the OAuth server to receive an
|
|
* access token. This access token can later be used to access Phabricator
|
|
* data on behalf of the user.
|
|
*
|
|
* @task auth Authorizing @{class:PhabricatorOAuthServerClient}s and
|
|
* generating @{class:PhabricatorOAuthServerAuthorizationCode}s
|
|
* @task token Validating @{class:PhabricatorOAuthServerAuthorizationCode}s
|
|
* and generating @{class:PhabricatorOAuthServerAccessToken}s
|
|
* @task internal Internals
|
|
*/
|
|
final class PhabricatorOAuthServer extends Phobject {
|
|
|
|
const AUTHORIZATION_CODE_TIMEOUT = 300;
|
|
|
|
private $user;
|
|
private $client;
|
|
|
|
private function getUser() {
|
|
if (!$this->user) {
|
|
throw new PhutilInvalidStateException('setUser');
|
|
}
|
|
return $this->user;
|
|
}
|
|
|
|
public function setUser(PhabricatorUser $user) {
|
|
$this->user = $user;
|
|
return $this;
|
|
}
|
|
|
|
private function getClient() {
|
|
if (!$this->client) {
|
|
throw new PhutilInvalidStateException('setClient');
|
|
}
|
|
return $this->client;
|
|
}
|
|
|
|
public function setClient(PhabricatorOAuthServerClient $client) {
|
|
$this->client = $client;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @task auth
|
|
* @return tuple <bool hasAuthorized, ClientAuthorization or null>
|
|
*/
|
|
public function userHasAuthorizedClient(array $scope) {
|
|
|
|
$authorization = id(new PhabricatorOAuthClientAuthorization())
|
|
->loadOneWhere(
|
|
'userPHID = %s AND clientPHID = %s',
|
|
$this->getUser()->getPHID(),
|
|
$this->getClient()->getPHID());
|
|
if (empty($authorization)) {
|
|
return array(false, null);
|
|
}
|
|
|
|
if ($scope) {
|
|
$missing_scope = array_diff_key($scope, $authorization->getScope());
|
|
} else {
|
|
$missing_scope = false;
|
|
}
|
|
|
|
if ($missing_scope) {
|
|
return array(false, $authorization);
|
|
}
|
|
|
|
return array(true, $authorization);
|
|
}
|
|
|
|
/**
|
|
* @task auth
|
|
*/
|
|
public function authorizeClient(array $scope) {
|
|
$authorization = new PhabricatorOAuthClientAuthorization();
|
|
$authorization->setUserPHID($this->getUser()->getPHID());
|
|
$authorization->setClientPHID($this->getClient()->getPHID());
|
|
$authorization->setScope($scope);
|
|
$authorization->save();
|
|
|
|
return $authorization;
|
|
}
|
|
|
|
/**
|
|
* @task auth
|
|
*/
|
|
public function generateAuthorizationCode(PhutilURI $redirect_uri) {
|
|
|
|
$code = Filesystem::readRandomCharacters(32);
|
|
$client = $this->getClient();
|
|
|
|
$authorization_code = new PhabricatorOAuthServerAuthorizationCode();
|
|
$authorization_code->setCode($code);
|
|
$authorization_code->setClientPHID($client->getPHID());
|
|
$authorization_code->setClientSecret($client->getSecret());
|
|
$authorization_code->setUserPHID($this->getUser()->getPHID());
|
|
$authorization_code->setRedirectURI((string)$redirect_uri);
|
|
$authorization_code->save();
|
|
|
|
return $authorization_code;
|
|
}
|
|
|
|
/**
|
|
* @task token
|
|
*/
|
|
public function generateAccessToken() {
|
|
|
|
$token = Filesystem::readRandomCharacters(32);
|
|
|
|
$access_token = new PhabricatorOAuthServerAccessToken();
|
|
$access_token->setToken($token);
|
|
$access_token->setUserPHID($this->getUser()->getPHID());
|
|
$access_token->setClientPHID($this->getClient()->getPHID());
|
|
$access_token->save();
|
|
|
|
return $access_token;
|
|
}
|
|
|
|
/**
|
|
* @task token
|
|
*/
|
|
public function validateAuthorizationCode(
|
|
PhabricatorOAuthServerAuthorizationCode $test_code,
|
|
PhabricatorOAuthServerAuthorizationCode $valid_code) {
|
|
|
|
// check that all the meta data matches
|
|
if ($test_code->getClientPHID() != $valid_code->getClientPHID()) {
|
|
return false;
|
|
}
|
|
if ($test_code->getClientSecret() != $valid_code->getClientSecret()) {
|
|
return false;
|
|
}
|
|
|
|
// check that the authorization code hasn't timed out
|
|
$created_time = $test_code->getDateCreated();
|
|
$must_be_used_by = $created_time + self::AUTHORIZATION_CODE_TIMEOUT;
|
|
return (time() < $must_be_used_by);
|
|
}
|
|
|
|
/**
|
|
* @task token
|
|
*/
|
|
public function authorizeToken(
|
|
PhabricatorOAuthServerAccessToken $token) {
|
|
|
|
$user_phid = $token->getUserPHID();
|
|
$client_phid = $token->getClientPHID();
|
|
|
|
$authorization = id(new PhabricatorOAuthClientAuthorizationQuery())
|
|
->setViewer(PhabricatorUser::getOmnipotentUser())
|
|
->withUserPHIDs(array($user_phid))
|
|
->withClientPHIDs(array($client_phid))
|
|
->executeOne();
|
|
if (!$authorization) {
|
|
return null;
|
|
}
|
|
|
|
$application = $authorization->getClient();
|
|
if ($application->getIsDisabled()) {
|
|
return null;
|
|
}
|
|
|
|
return $authorization;
|
|
}
|
|
|
|
public function validateRedirectURI($uri) {
|
|
try {
|
|
$this->assertValidRedirectURI($uri);
|
|
return true;
|
|
} catch (Exception $ex) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* See http://tools.ietf.org/html/draft-ietf-oauth-v2-23#section-3.1.2
|
|
* for details on what makes a given redirect URI "valid".
|
|
*/
|
|
public function assertValidRedirectURI($raw_uri) {
|
|
// This covers basics like reasonable formatting and the existence of a
|
|
// protocol.
|
|
PhabricatorEnv::requireValidRemoteURIForLink($raw_uri);
|
|
|
|
$uri = new PhutilURI($raw_uri);
|
|
|
|
$fragment = $uri->getFragment();
|
|
if (strlen($fragment)) {
|
|
throw new Exception(
|
|
pht(
|
|
'OAuth application redirect URIs must not contain URI '.
|
|
'fragments, but the URI "%s" has a fragment ("%s").',
|
|
$raw_uri,
|
|
$fragment));
|
|
}
|
|
|
|
$protocol = $uri->getProtocol();
|
|
switch ($protocol) {
|
|
case 'http':
|
|
case 'https':
|
|
break;
|
|
default:
|
|
throw new Exception(
|
|
pht(
|
|
'OAuth application redirect URIs must only use the "http" or '.
|
|
'"https" protocols, but the URI "%s" uses the "%s" protocol.',
|
|
$raw_uri,
|
|
$protocol));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If there's a URI specified in an OAuth request, it must be validated in
|
|
* its own right. Further, it must have the same domain, the same path, the
|
|
* same port, and (at least) the same query parameters as the primary URI.
|
|
*/
|
|
public function validateSecondaryRedirectURI(
|
|
PhutilURI $secondary_uri,
|
|
PhutilURI $primary_uri) {
|
|
|
|
// The secondary URI must be valid.
|
|
if (!$this->validateRedirectURI($secondary_uri)) {
|
|
return false;
|
|
}
|
|
|
|
// Both URIs must point at the same domain.
|
|
if ($secondary_uri->getDomain() != $primary_uri->getDomain()) {
|
|
return false;
|
|
}
|
|
|
|
// Both URIs must have the same path
|
|
if ($secondary_uri->getPath() != $primary_uri->getPath()) {
|
|
return false;
|
|
}
|
|
|
|
// Both URIs must have the same port
|
|
if ($secondary_uri->getPort() != $primary_uri->getPort()) {
|
|
return false;
|
|
}
|
|
|
|
// Any query parameters present in the first URI must be exactly present
|
|
// in the second URI.
|
|
$need_params = $primary_uri->getQueryParams();
|
|
$have_params = $secondary_uri->getQueryParams();
|
|
|
|
foreach ($need_params as $key => $value) {
|
|
if (!array_key_exists($key, $have_params)) {
|
|
return false;
|
|
}
|
|
if ((string)$have_params[$key] != (string)$value) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// If the first URI is HTTPS, the second URI must also be HTTPS. This
|
|
// defuses an attack where a third party with control over the network
|
|
// tricks you into using HTTP to authenticate over a link which is supposed
|
|
// to be HTTPS only and sniffs all your token cookies.
|
|
if (strtolower($primary_uri->getProtocol()) == 'https') {
|
|
if (strtolower($secondary_uri->getProtocol()) != 'https') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
}
|