request automatically caches responses by their response "Cache-Control" header! */ namespace Controller\Ccp; use Controller; use Controller\Api as Api; use Model; use Lib; class Sso extends Api\User{ /** * @var int timeout (seconds) for API calls */ const SSO_TIMEOUT = 4; /** * @var int expire time (seconds) for an valid "accessToken" */ const ACCESS_KEY_EXPIRE_TIME = 20 * 60; // SSO specific session keys const SESSION_KEY_SSO = 'SESSION.SSO'; const SESSION_KEY_SSO_ERROR = 'SESSION.SSO.ERROR'; const SESSION_KEY_SSO_STATE = 'SESSION.SSO.STATE'; const SESSION_KEY_SSO_FROM_MAP = 'SESSION.SSO.FROM_MAP'; // error messages const ERROR_CCP_SSO_URL = 'Invalid "ENVIRONMENT.[ENVIRONMENT].CCP_SSO_URL" url. %s'; const ERROR_CCP_CLIENT_ID = 'Missing "ENVIRONMENT.[ENVIRONMENT].CCP_SSO_CLIENT_ID".'; const ERROR_ACCESS_TOKEN = 'Unable to get a valid "access_token. %s'; const ERROR_VERIFY_CHARACTER = 'Unable to verify character data. %s'; const ERROR_LOGIN_FAILED = 'Failed authentication due to technical problems: %s'; const ERROR_CHARACTER_VERIFICATION = 'Character verification failed by SSP SSO'; const ERROR_CHARACTER_FORBIDDEN = 'Character "%s" is not authorized to log in'; const ERROR_SERVICE_TIMEOUT = 'CCP SSO service timeout (%ss). Try again later'; const ERROR_COOKIE_LOGIN = 'Login from Cookie failed. Please retry by CCP SSO'; /** * redirect user to CCP SSO page and request authorization * -> cf. Controller->getCookieCharacters() ( equivalent cookie based login) * @param \Base $f3 */ public function requestAuthorization($f3){ if( !empty($ssoCcpClientId = Controller\Controller::getEnvironmentData('CCP_SSO_CLIENT_ID')) ){ $params = $f3->get('GET'); if( isset($params['characterId']) && ( $activeCharacter = $this->getCharacter(0) ) ){ // authentication restricted to a characterId ----------------------------------------------- // restrict login to this characterId e.g. for character switch on map page $characterId = (int)trim($params['characterId']); /** * @var Model\CharacterModel $character */ $character = Model\BasicModel::getNew('CharacterModel'); $character->getById($characterId, 0); // check if character is valid and exists if( !$character->dry() && $character->hasUserCharacter() && ($activeCharacter->getUser()->_id === $character->getUser()->_id) ){ // requested character belongs to current user // -> update character vom ESI (e.g. corp changed,..) $updateStatus = $character->updateFromESI(); if( empty($updateStatus) ){ // make sure character data is up2date! // -> this is not the case if e.g. userCharacters was removed "ownerHash" changed... $character->getById($character->_id); if( $character->hasUserCharacter() && $character->isAuthorized() ){ $loginCheck = $this->loginByCharacter($character); if($loginCheck){ // set "login" cookie $this->setLoginCookie($character, $this->getRequestedScopeHash()); // -> pass current character data to target page $f3->set(Api\User::SESSION_KEY_TEMP_CHARACTER_ID, $character->_id); // route to "map" $f3->reroute('@map'); } } } } // redirect to map map page on successful login $f3->set(self::SESSION_KEY_SSO_FROM_MAP, true); } // redirect to CCP SSO ---------------------------------------------------------------------- // used for "state" check between request and callback $state = bin2hex( openssl_random_pseudo_bytes(12) ); $f3->set(self::SESSION_KEY_SSO_STATE, $state); $urlParams = [ 'response_type' => 'code', 'redirect_uri' => Controller\Controller::getEnvironmentData('URL') . $f3->build('/sso/callbackAuthorization'), 'client_id' => Controller\Controller::getEnvironmentData('CCP_SSO_CLIENT_ID'), 'scope' => implode(' ', Controller\Controller::getEnvironmentData('CCP_ESI_SCOPES')), 'state' => $state ]; $ssoAuthUrl = self::getAuthorizationEndpoint() . '?' . http_build_query($urlParams, '', '&', PHP_QUERY_RFC3986 ); $f3->status(302); $f3->reroute($ssoAuthUrl); }else{ // SSO clientId missing $f3->set(self::SESSION_KEY_SSO_ERROR, self::ERROR_CCP_CLIENT_ID); self::getSSOLogger()->write(self::ERROR_CCP_CLIENT_ID); $f3->reroute('@login'); } } /** * callback handler for CCP SSO user Auth * -> see requestAuthorization() * @param \Base $f3 */ public function callbackAuthorization($f3){ $getParams = (array)$f3->get('GET'); // users can log in either from @login (new user) or @map (existing user) root alias // -> in case login fails, users should be redirected differently $authFromMapAlias = false; if($f3->exists(self::SESSION_KEY_SSO_STATE)){ // check response and validate 'state' if( isset($getParams['code']) && isset($getParams['state']) && !empty($getParams['code']) && !empty($getParams['state']) && $f3->get(self::SESSION_KEY_SSO_STATE) === $getParams['state'] ){ // check if user came from map (for redirect) if( $f3->get(self::SESSION_KEY_SSO_FROM_MAP) ){ $authFromMapAlias = true; } // clear 'state' for new next login request $f3->clear(self::SESSION_KEY_SSO_STATE); $f3->clear(self::SESSION_KEY_SSO_FROM_MAP); $accessData = $this->getSsoAccessData($getParams['code']); if( isset($accessData->accessToken) && isset($accessData->refreshToken) ){ // login succeeded -> get basic character data for current login $verificationCharacterData = $this->verifyCharacterData($accessData->accessToken); if( !is_null($verificationCharacterData)){ // check if login is restricted to a characterID // verification available data. Data is needed for "ownerHash" check // get character data from ESI $characterData = $this->getCharacterData($verificationCharacterData->CharacterID); if( isset($characterData->character) ){ // add "ownerHash" and SSO tokens $characterData->character['ownerHash'] = $verificationCharacterData->CharacterOwnerHash; $characterData->character['crestAccessToken'] = $accessData->accessToken; $characterData->character['crestRefreshToken'] = $accessData->refreshToken; // add/update static character data $characterModel = $this->updateCharacter($characterData); if( !is_null($characterModel) ){ // check if character is authorized to log in if($characterModel->isAuthorized()){ // character is authorized to log in // -> update character log (current location,...) $characterModel = $characterModel->updateLog(); // connect character with current user if( is_null($user = $this->getUser()) ){ // connect character with existing user (no changes) if( is_null( $user = $characterModel->getUser()) ){ // no user found (new character) -> create new user and connect to character /** * @var $user Model\UserModel */ $user = Model\BasicModel::getNew('UserModel'); $user->name = $characterModel->name; $user->save(); } } /** * @var $userCharactersModel Model\UserCharacterModel */ if( is_null($userCharactersModel = $characterModel->userCharacter) ){ $userCharactersModel = Model\BasicModel::getNew('UserCharacterModel'); $userCharactersModel->characterId = $characterModel; } // user might have changed $userCharactersModel->userId = $user; $userCharactersModel->save(); // get updated character model $characterModel = $userCharactersModel->getCharacter(); // login by character $loginCheck = $this->loginByCharacter($characterModel); if($loginCheck){ // set "login" cookie $this->setLoginCookie($characterModel, $this->getRequestedScopeHash()); // -> pass current character data to target page $f3->set(Api\User::SESSION_KEY_TEMP_CHARACTER_ID, $characterModel->_id); // route to "map" $f3->reroute('@map'); }else{ $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_LOGIN_FAILED, $characterModel->name)); } }else{ // character is not authorized to log in $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_CHARACTER_FORBIDDEN, $characterModel->name)); } } } }else{ // failed to verify character by CCP SSO $f3->set(self::SESSION_KEY_SSO_ERROR, self::ERROR_CHARACTER_VERIFICATION); } }else{ // SSO "accessData" missing (e.g. timeout) $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_SERVICE_TIMEOUT, self::SSO_TIMEOUT)); } }else{ // invalid SSO response $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_LOGIN_FAILED, 'Invalid response')); } } if($authFromMapAlias){ // on error -> route back to map $f3->reroute('@map'); }else{ // on error -> route back to login form $f3->reroute('@login'); } } /** * login by cookie name * @param \Base $f3 */ public function login(\Base $f3){ $data = (array)$f3->get('GET'); $cookieName = empty($data['cookie']) ? '' : $data['cookie']; $character = null; if( !empty($cookieName) ){ if( !empty($cookieData = $this->getCookieByName($cookieName) )){ // cookie data is valid -> validate data against DB (security check!) if( !empty($characters = $this->getCookieCharacters(array_slice($cookieData, 0, 1, true))) ){ // character is valid and allowed to login $character = $characters[$cookieName]; } } } if( is_object($character)){ // login by character $loginCheck = $this->loginByCharacter($character); if($loginCheck){ // set character id // -> pass current character data to target page $f3->set(Api\User::SESSION_KEY_TEMP_CHARACTER_ID, $character->_id); // route to "map" $f3->reroute('@map'); } } // on error -> route back to login form $f3->set(self::SESSION_KEY_SSO_ERROR, self::ERROR_COOKIE_LOGIN); $f3->reroute('@login'); } /** * get a valid "access_token" for oAuth 2.0 verification * -> if $authCode is set -> request NEW "access_token" * -> else check for existing (not expired) "access_token" * -> else try to refresh auth and get fresh "access_token" * @param bool $authCode * @return null|\stdClass */ public function getSsoAccessData($authCode){ $accessData = null; if( !empty($authCode) ){ // Authentication Code is set -> request new "accessToken" $accessData = $this->verifyAuthorizationCode($authCode); }else{ // Unable to get Token -> trigger error self::getSSOLogger()->write(sprintf(self::ERROR_ACCESS_TOKEN, $authCode)); } return $accessData; } /** * verify authorization code, and get an "access_token" data * @param $authCode * @return \stdClass */ protected function verifyAuthorizationCode($authCode){ $requestParams = [ 'grant_type' => 'authorization_code', 'code' => $authCode ]; return $this->requestAccessData($requestParams); } /** * get new "access_token" by an existing "refresh_token" * -> if "access_token" is expired, this function gets a fresh one * @param $refreshToken * @return \stdClass */ public function refreshAccessToken($refreshToken){ $requestParams = [ 'grant_type' => 'refresh_token', 'refresh_token' => $refreshToken ]; return $this->requestAccessData($requestParams); } /** * request an "access_token" AND "refresh_token" data * -> this can either be done by sending a valid "authorization code" * OR by providing a valid "refresh_token" * @param $requestParams * @return \stdClass */ protected function requestAccessData($requestParams){ $verifyAuthCodeUrl = self::getVerifyAuthorizationCodeEndpoint(); $verifyAuthCodeUrlParts = parse_url($verifyAuthCodeUrl); $accessData = (object) []; $accessData->accessToken = null; $accessData->refreshToken = null; if($verifyAuthCodeUrlParts){ $contentType = 'application/x-www-form-urlencoded'; $requestOptions = [ 'timeout' => self::SSO_TIMEOUT, 'method' => 'POST', 'user_agent' => $this->getUserAgent(), 'header' => [ 'Authorization: Basic ' . $this->getAuthorizationHeader(), 'Content-Type: ' . $contentType, 'Host: ' . $verifyAuthCodeUrlParts['host'] ] ]; // content (parameters to send with) $requestOptions['content'] = http_build_query($requestParams); $apiResponse = Lib\Web::instance()->request($verifyAuthCodeUrl, $requestOptions); if($apiResponse['body']){ $authCodeRequestData = json_decode($apiResponse['body'], true); if( !empty($authCodeRequestData) ){ if( isset($authCodeRequestData['access_token']) ){ // this token is required for endpoints that require Auth $accessData->accessToken = $authCodeRequestData['access_token']; } if(isset($authCodeRequestData['refresh_token'])){ // this token is used to refresh/get a new access_token when expires $accessData->refreshToken = $authCodeRequestData['refresh_token']; } } }else{ self::getSSOLogger()->write( sprintf( self::ERROR_ACCESS_TOKEN, print_r($requestParams, true) ) ); } }else{ self::getSSOLogger()->write( sprintf(self::ERROR_CCP_SSO_URL, __METHOD__) ); } return $accessData; } /** * 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 * @param $accessToken * @return mixed|null */ public function verifyCharacterData($accessToken){ $verifyUserUrl = self::getVerifyUserEndpoint(); $verifyUrlParts = parse_url($verifyUserUrl); $characterData = null; if($verifyUrlParts){ $requestOptions = [ 'timeout' => self::SSO_TIMEOUT, 'method' => 'GET', 'user_agent' => $this->getUserAgent(), 'header' => [ 'Authorization: Bearer ' . $accessToken, 'Host: ' . $verifyUrlParts['host'] ] ]; $apiResponse = Lib\Web::instance()->request($verifyUserUrl, $requestOptions); if($apiResponse['body']){ $characterData = json_decode($apiResponse['body']); }else{ self::getSSOLogger()->write(sprintf(self::ERROR_VERIFY_CHARACTER, __METHOD__)); } }else{ self::getSSOLogger()->write(sprintf(self::ERROR_CCP_SSO_URL, __METHOD__)); } return $characterData; } /** * get character data * @param int $characterId * @return object */ public function getCharacterData($characterId){ $characterData = (object) []; $characterDataBasic = $this->getF3()->ccpClient->getCharacterData($characterId); if( !empty($characterDataBasic) ){ // remove some "unwanted" data -> not relevant for Pathfinder $characterData->character = array_filter($characterDataBasic, function($key){ return in_array($key, ['id', 'name', 'securityStatus']); }, ARRAY_FILTER_USE_KEY); $characterData->corporation = null; $characterData->alliance = null; if(isset($characterDataBasic['corporation'])){ $corporationId = (int)$characterDataBasic['corporation']['id']; /** * @var Model\CorporationModel $corporationModel */ $corporationModel = Model\BasicModel::getNew('CorporationModel'); $corporationModel->getById($corporationId, 0); if($corporationModel->dry()){ // request corporation data $corporationData = $this->getF3()->ccpClient->getCorporationData($corporationId); if( !empty($corporationData) ){ // check for NPC corporation $corporationData['isNPC'] = $this->getF3()->ccpClient->isNpcCorporation($corporationId); $corporationModel->copyfrom($corporationData, ['id', 'name', 'isNPC']); $characterData->corporation = $corporationModel->save(); } }else{ $characterData->corporation = $corporationModel; } } if(isset($characterDataBasic['alliance'])){ $allianceId = (int)$characterDataBasic['alliance']['id']; /** * @var Model\AllianceModel $allianceModel */ $allianceModel = Model\BasicModel::getNew('AllianceModel'); $allianceModel->getById($allianceId, 0); if($allianceModel->dry()){ // request alliance data $allianceData = $this->getF3()->ccpClient->getAllianceData($allianceId); if( !empty($allianceData) ){ $allianceModel->copyfrom($allianceData, ['id', 'name']); $characterData->alliance = $allianceModel->save(); } }else{ $characterData->alliance = $allianceModel; } } } return $characterData; } /** * update character * @param $characterData * @return \Model\CharacterModel * @throws \Exception */ protected function updateCharacter($characterData){ $characterModel = null; if( !empty($characterData->character) ){ /** * @var Model\CharacterModel $characterModel */ $characterModel = Model\BasicModel::getNew('CharacterModel'); $characterModel->getById((int)$characterData->character['id'], 0); $characterModel->copyfrom($characterData->character, ['id', 'name', 'ownerHash', 'crestAccessToken', 'crestRefreshToken', 'securityStatus']); $characterModel->corporationId = $characterData->corporation; $characterModel->allianceId = $characterData->alliance; $characterModel = $characterModel->save(); } return $characterModel; } /** * get "Authorization:" Header data * -> This header is required for any Auth-required endpoints! * @return string */ protected function getAuthorizationHeader(){ return base64_encode( Controller\Controller::getEnvironmentData('CCP_SSO_CLIENT_ID') . ':' . Controller\Controller::getEnvironmentData('CCP_SSO_SECRET_KEY') ); } /** * get CCP SSO url from configuration file * -> throw error if url is broken/missing * @return string */ static function getSsoUrlRoot(){ $url = ''; if( \Audit::instance()->url(self::getEnvironmentData('CCP_SSO_URL')) ){ $url = self::getEnvironmentData('CCP_SSO_URL'); }else{ $error = sprintf(self::ERROR_CCP_SSO_URL, __METHOD__); self::getSSOLogger()->write($error); \Base::instance()->error(502, $error); } return $url; } static function getAuthorizationEndpoint(){ return self::getSsoUrlRoot() . '/oauth/authorize'; } static function getVerifyAuthorizationCodeEndpoint(){ return self::getSsoUrlRoot() . '/oauth/token'; } static function getVerifyUserEndpoint(){ return self::getSsoUrlRoot() . '/oauth/verify'; } /** * get logger for SSO logging * @return \Log */ static function getSSOLogger(){ return parent::getLogger('SSO'); } }