Skip to content

Commit

Permalink
v2.1.4: Merge SSO changes to upgrade to Oauth2.0 (#43)
Browse files Browse the repository at this point in the history
* Bumps version to 2.1.4
* Updates Pathfinder Database schema to store new AccessTokens
* Updates SSO login flow to work with JWT Access Tokens
* Updates ESI API client dependency to use goryn-clade/pathfinder_esi:v2.1.2
  • Loading branch information
samoneilll authored Oct 25, 2021
1 parent 0673759 commit 740aacb
Show file tree
Hide file tree
Showing 210 changed files with 267 additions and 91 deletions.
83 changes: 72 additions & 11 deletions app/Controller/Ccp/Sso.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Exodus4D\Pathfinder\Controller\Api as Api;
use Exodus4D\Pathfinder\Model\Pathfinder;
use Exodus4D\Pathfinder\Lib;
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

class Sso extends Api\User{

Expand All @@ -42,6 +44,8 @@ class Sso extends Api\User{
const ERROR_CHARACTER_FORBIDDEN = 'Character "%s" is not authorized to log in. Reason: %s';
const ERROR_SERVICE_TIMEOUT = 'CCP SSO service timeout (%ss). Try again later';
const ERROR_COOKIE_LOGIN = 'Login from Cookie failed (data not found). Please retry by CCP SSO';
const ERROR_CCP_JWK_CLAIM = 'Invalid "ENVIRONMENT.[ENVIRONMENT].CCP_SSO_JWK_CLAIM" url. %s';
const ERROR_TOKEN_VERIFICATION = 'Could not validate the authenticity of the Access Token';

/**
* redirect user to CCP SSO page and request authorization
Expand Down Expand Up @@ -187,6 +191,7 @@ public function callbackAuthorization($f3){

if(isset($accessData->accessToken, $accessData->esiAccessTokenExpires, $accessData->refreshToken)){
// login succeeded -> get basic character data for current login

$verificationCharacterData = $this->verifyCharacterData($accessData->accessToken);

if( !empty($verificationCharacterData) ){
Expand All @@ -196,15 +201,15 @@ public function callbackAuthorization($f3){
// verification available data. Data is needed for "ownerHash" check

// get character data from ESI
$characterData = $this->getCharacterData((int)$verificationCharacterData['characterId']);
$characterData = $this->getCharacterData((int)$verificationCharacterData->characterId);

if( isset($characterData->character) ){
// add "ownerHash" and SSO tokens
$characterData->character['ownerHash'] = $verificationCharacterData['characterOwnerHash'];
$characterData->character['ownerHash'] = $verificationCharacterData->owner;
$characterData->character['esiAccessToken'] = $accessData->accessToken;
$characterData->character['esiAccessTokenExpires'] = $accessData->esiAccessTokenExpires;
$characterData->character['esiRefreshToken'] = $accessData->refreshToken;
$characterData->character['esiScopes'] = $verificationCharacterData['scopes'];
$characterData->character['esiScopes'] = $verificationCharacterData->scp;

// add/update static character data
$characterModel = $this->updateCharacter($characterData);
Expand Down Expand Up @@ -422,25 +427,64 @@ protected function requestAccessData(array $requestParams) : \stdClass {
}

/**
* verify character data by "access_token"
* -> get some basic information (like character id)
* -> if more character information is required, use ESI "characters" endpoints request instead
* verify character data by decloding JWT "access_token"
* -> verify against CCP JWK
* -> get some basic information (like character id)
* @param string $accessToken
* @return array
* @return object
*/
public function verifyCharacterData(string $accessToken) : array {
$characterData = $this->getF3()->ssoClient()->send('getVerifyCharacter', $accessToken);
public function verifyCharacterData(string $accessToken) : object {
$characterData = $this->verifyJwtAccessToken($accessToken);

if( !empty($characterData) ){
// convert string with scopes to array
$characterData['scopes'] = Lib\Util::convertScopesString($characterData['scopes']);
$characterData->characterId = (int)explode(':',$characterData->sub)[2];
}else{
self::getSSOLogger()->write(sprintf(self::ERROR_VERIFY_CHARACTER, __METHOD__));
}

return $characterData;
}

/**
* verify JWT by comparing to CCP public JWK
* -> get Ccp JWKs
* -> decode accessToken using JWKs
* -> Verify token claim is correct
* @param string $accessToken
* @return object
*/
public function verifyJwtAccessToken(string $accessToken) : object {
$ccpJwks = $this->getCcpJwkData();
// set $leeway in seconds to 10, since sometimes there can be verification errors due server clock skew resulting
// in tokens that look like they were issued 1 second in the future.
JWT::$leeway = 10;
// map list of algs from CCP JWK
$supportedAlgs = array_column($ccpJwks['keys'], 'alg');
// get decoded JWT using ccp supplied JWK
$decodedJwt = JWT::decode($accessToken, JWK::parseKeySet($ccpJwks), $supportedAlgs);
// check if issuer matches correct ccp supplied claim values
if (strpos($decodedJwt->iss, $this->getSsoJwkClaim()) !== true) {
self::getSSOLogger()->write(sprintf(self::ERROR_TOKEN_VERIFICATION, __METHOD__));
}
return $decodedJwt;
}

/**
* get JWK from CCP and return decoded json object
* @return array
*/
protected function getCcpJwkData() : array {
$jwkJson = $this->getF3()->ssoClient()->send('getJWKS');

if( !empty($jwkJson) ){
// ensure items in 'keys' are arrays and not objects
array_walk($jwkJson['keys'], function(&$item){$item = (array) $item;});
return $jwkJson;
}else{
self::getSSOLogger()->write(sprintf(self::ERROR_LOGIN_FAILED, __METHOD__));
}
}

/**
* get character data
* @param int $characterId
Expand Down Expand Up @@ -546,6 +590,23 @@ static function getSsoUrlRoot() : string {
return $url;
}

/**
* get CCP SSO JWK CLAIM from configuration file
* -> throw error if string is missing
* @return string
*/
static function getSsoJwkClaim() : string {
$str = self::getEnvironmentData('CCP_SSO_JWK_CLAIM');

if( empty($str)){
$error = sprintf(self::ERROR_CCP_JWK_CLAIM, __METHOD__);
self::getSSOLogger()->write($error);
\Base::instance()->error(502, $error);
}

return $str;
}

/**
* get logger for SSO logging
* @return \Log
Expand Down
8 changes: 4 additions & 4 deletions app/Model/Pathfinder/CharacterModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class CharacterModel extends AbstractPathfinderModel {
'default' => ''
],
'esiAccessToken' => [
'type' => Schema::DT_VARCHAR256
'type' => Schema::DT_TEXT
],
'esiAccessTokenExpires' => [
'type' => Schema::DT_TIMESTAMP,
Expand Down Expand Up @@ -1186,13 +1186,13 @@ public function updateFromESI() : array {
$ssoController = new Sso();
if(
!empty( $verificationCharacterData = $ssoController->verifyCharacterData($accessToken) ) &&
$verificationCharacterData['characterId'] === $this->_id
$verificationCharacterData->characterId === $this->_id
){
// get character data from API
$characterData = $ssoController->getCharacterData($this->_id);
if( !empty($characterData->character) ){
$characterData->character['ownerHash'] = $verificationCharacterData['characterOwnerHash'];
$characterData->character['esiScopes'] = $verificationCharacterData['scopes'];
$characterData->character['ownerHash'] = $verificationCharacterData->owner;
$characterData->character['esiScopes'] = $verificationCharacterData->scp;

$this->copyfrom($characterData->character, ['ownerHash', 'esiScopes', 'securityStatus']);
$this->corporationId = $characterData->corporation;
Expand Down
2 changes: 2 additions & 0 deletions app/environment.ini
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ DB_UNIVERSE_PASS =
CCP_SSO_URL = https://sisilogin.testeveonline.com
CCP_SSO_CLIENT_ID =
CCP_SSO_SECRET_KEY =
CCP_SSO_JWK_CLAIM = login.eveonline.com
CCP_SSO_DOWNTIME = 11:00

; CCP ESI API
Expand Down Expand Up @@ -83,6 +84,7 @@ DB_CCP_PASS =
CCP_SSO_URL = https://login.eveonline.com
CCP_SSO_CLIENT_ID =
CCP_SSO_SECRET_KEY =
CCP_SSO_JWK_CLAIM = login.eveonline.com
CCP_SSO_DOWNTIME = 11:00

; CCP ESI API
Expand Down
4 changes: 2 additions & 2 deletions app/pathfinder.ini
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ NAME = Pathfinder
; Version is used for CSS/JS cache busting and is part of the URL for static resources:
; e.g. public/js/vX.X.X/app.js
; Syntax: String (current version)
; Default: v2.1.3
VERSION = v2.1.3
; Default: v2.1.4
VERSION = v2.1.4

; Contact information [optional]
; Shown on 'licence', 'contact' page.
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@
"cache/namespaced-cache": "1.1.*",
"react/socket": "1.3.*",
"react/promise-stream": "1.2.*",
"clue/ndjson-react": "1.2.*",
"tyrheimdaleve/pathfinder_esi": "2.1.1"
"clue/ndjson-react": "1.2.*",
"firebase/php-jwt": "^5.4",
"goryn-clade/pathfinder_esi": "2.1.2"
},
"suggest": {
"ext-redis": "Redis can be used as cache backend."
Expand Down
Loading

0 comments on commit 740aacb

Please sign in to comment.