<?php namespace OAuth2\GrantTypes;
/**
 * Copyright 2015 OpenStack Foundation
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * http://www.apache.org/licenses/LICENSE-2.0
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 **/
use Exception;
use Illuminate\Support\Facades\Log;
use jwe\IJWE;
use jwk\exceptions\InvalidJWKAlgorithm;
use jwk\exceptions\JWKInvalidSpecException;
use jws\exceptions\JWSInvalidJWKException;
use jws\IJWS;
use OAuth2\Exceptions\AccessDeniedException;
use OAuth2\Exceptions\ConsentRequiredException;
use OAuth2\Exceptions\InteractionRequiredException;
use OAuth2\Exceptions\InvalidApplicationType;
use OAuth2\Exceptions\InvalidClientException;
use OAuth2\Exceptions\InvalidClientType;
use OAuth2\Exceptions\InvalidLoginHint;
use OAuth2\Exceptions\InvalidOAuth2Request;
use OAuth2\Exceptions\LockedClientException;
use OAuth2\Exceptions\LoginRequiredException;
use OAuth2\Exceptions\OAuth2GenericException;
use OAuth2\Exceptions\RecipientKeyNotFoundException;
use OAuth2\Exceptions\ScopeNotAllowedException;
use OAuth2\Exceptions\ServerKeyNotFoundException;
use OAuth2\Exceptions\UriNotAllowedException;
use OAuth2\Heuristics\ClientSigningKeyFinder;
use OAuth2\Heuristics\ServerEncryptionKeyFinder;
use OAuth2\Heuristics\ServerSigningKeyFinder;
use OAuth2\Models\IClient;
use OAuth2\Repositories\IClientRepository;
use OAuth2\Services\ITokenService;
use OAuth2\OAuth2Protocol;
use OAuth2\Repositories\IServerPrivateKeyRepository;
use OAuth2\Requests\OAuth2AuthenticationRequest;
use OAuth2\Requests\OAuth2AuthorizationRequest;
use OAuth2\Requests\OAuth2Request;
use OAuth2\Responses\OAuth2Response;
use OAuth2\Services\IApiScopeService;
use OAuth2\Services\IClientJWKSetReader;
use OAuth2\Services\IClientService;
use OAuth2\Services\IMementoOAuth2SerializerService;
use OAuth2\Services\IPrincipalService;
use OAuth2\Services\ISecurityContextService;
use OAuth2\Services\IUserConsentService;
use OAuth2\Strategies\IOAuth2AuthenticationStrategy;
use utils\exceptions\InvalidCompactSerializationException;
use utils\factories\BasicJWTFactory;
use Utils\Services\IAuthService;
use Utils\Services\ILogService;
use phpseclib\Crypt\Random;
/**
 * Class InteractiveGrantType
 * @package OAuth2\GrantTypes
 */
abstract class InteractiveGrantType extends AbstractGrantType
{
    /**
     * @var ISecurityContextService
     */
    protected $security_context_service;

    /**
     * @var IAuthService
     */
    protected $auth_service;

    /**
     * @var IPrincipalService
     */
    protected $principal_service;

    /**
     * @var IOAuth2AuthenticationStrategy
     */
    protected $auth_strategy;
    /**
     * @var IApiScopeService
     */

    protected $scope_service;

    /**
     * @var IUserConsentService
     */
    protected $user_consent_service;

    /**
     * @var IMementoOAuth2SerializerService
     */
    protected $memento_service;

    /**
     * @var IServerPrivateKeyRepository
     */
    private $server_private_key_repository;

    /**
     * @var IClientJWKSetReader
     */
    private $jwk_set_reader_service;

    /**
     * InteractiveGrantType constructor.
     * @param IClientService $client_service
     * @param IClientRepository $client_repository
     * @param ITokenService $token_service
     * @param ILogService $log_service
     * @param ISecurityContextService $security_context_service
     * @param IPrincipalService $principal_service
     * @param IAuthService $auth_service
     * @param IUserConsentService $user_consent_service
     * @param IApiScopeService $scope_service
     * @param IOAuth2AuthenticationStrategy $auth_strategy
     * @param IMementoOAuth2SerializerService $memento_service
     * @param IServerPrivateKeyRepository $server_private_key_repository
     * @param IClientJWKSetReader $jwk_set_reader_service
     */
    public function __construct
    (
        IClientService                  $client_service,
        IClientRepository               $client_repository,
        ITokenService                   $token_service,
        ILogService                     $log_service,
        ISecurityContextService         $security_context_service,
        IPrincipalService               $principal_service,
        IAuthService                    $auth_service,
        IUserConsentService             $user_consent_service,
        IApiScopeService                $scope_service,
        IOAuth2AuthenticationStrategy   $auth_strategy,
        IMementoOAuth2SerializerService $memento_service,
        IServerPrivateKeyRepository     $server_private_key_repository,
        IClientJWKSetReader             $jwk_set_reader_service
    )
    {
        parent::__construct($client_service, $client_repository, $token_service, $log_service);

        $this->security_context_service      = $security_context_service;
        $this->principal_service             = $principal_service;
        $this->auth_service                  = $auth_service;
        $this->user_consent_service          = $user_consent_service;
        $this->scope_service                 = $scope_service;
        $this->auth_strategy                 = $auth_strategy;
        $this->memento_service               = $memento_service;
        $this->server_private_key_repository = $server_private_key_repository;
        $this->jwk_set_reader_service        = $jwk_set_reader_service;
    }

    /**
     * @param OAuth2Request $request
     * @return OAuth2Response
     * @throws Exception
     */
    public function handle(OAuth2Request $request)
    {
        try
        {

            if (!($request instanceof OAuth2AuthorizationRequest))
            {
                throw new InvalidOAuth2Request;
            }

            $client_id = $request->getClientId();
            $client    = $this->client_repository->getClientById($client_id);

            if (is_null($client)) {
                throw new InvalidClientException
                (
                    sprintf
                    (
                        "client_id %s does not exists!",
                        $client_id
                    )
                );
            }

            if (!$client->isActive() || $client->isLocked()) {
                throw new LockedClientException
                (
                    sprintf
                    (
                        'client id %s is locked',
                        $client_id
                    )
                );
            }

            $this->checkClientTypeAccess($client);

            //check redirect uri
            $redirect_uri = $request->getRedirectUri();

            if (!$client->isUriAllowed($redirect_uri)) {
                throw new UriNotAllowedException
                (
                    $redirect_uri
                );
            }

            //check requested scope
            $scope = $request->getScope();
            $this->log_service->debug_msg(sprintf("scope %s", $scope));
            if (empty($scope) || !$client->isScopeAllowed($scope)) {
                throw new ScopeNotAllowedException($scope);
            }

            $authentication_response = $this->auth_service->getUserAuthenticationResponse();

            // user has cancelled login action
            if ($authentication_response == IAuthService::AuthenticationResponse_Cancel) {
                //clear saved data ...
                $this->memento_service->forget();
                $this->auth_service->clearUserAuthenticationResponse();
                $this->auth_service->clearUserAuthorizationResponse();

                if ($this->shouldPromptLogin($request)) {
                    throw new LoginRequiredException;
                }

                throw new AccessDeniedException;
            }

            //check user logged
            if ($this->mustAuthenticateUser($request, $client)) {
                if (!$this->canInteractWithEndUser($request)) {
                    throw new LoginRequiredException;
                }

                $this->memento_service->serialize($request->getMessage()->createMemento());

                return $this->auth_strategy->doLogin($request);
            }

            $approval_prompt = $request->getApprovalPrompt();
            $user = $this->auth_service->getCurrentUser();

            // check if logged user its the same as login hint
            $requested_user_id = $this->security_context_service->get()->getRequestedUserId();

            if (is_null($user)) {
                throw new OAuth2GenericException("Invalid Current User");
            }

            if (!is_null($requested_user_id) && $requested_user_id !== $user->getId()) {
                $this->auth_service->logout();
                throw new InvalidLoginHint('invalid login hint');
            }

            $authorization_response = $this->auth_service->getUserAuthorizationResponse();

            $this->log_service->debug_msg(sprintf("authorization_response %s", $authorization_response));

            if ($authorization_response == IAuthService::AuthorizationResponse_DenyOnce) {
                if ($this->hadPromptConsent($request)) {
                    throw new ConsentRequiredException('the user denied access to your application');
                }
                throw new AccessDeniedException;
            }

            // check for former user consents
            $former_user_consent   = $user->findFirstConsentByClientAndScopes($client, $scope);
            $auto_approval         = $approval_prompt == OAuth2Protocol::OAuth2Protocol_Approval_Prompt_Auto;
            $has_former_consent    = !is_null($former_user_consent);
            $should_prompt_consent = $this->shouldPromptConsent($request);

            if((!$should_prompt_consent && $has_former_consent && $auto_approval) || $authorization_response == IAuthService::AuthorizationResponse_AllowOnce) {
                // emit response ...
                $this->auth_service->registerRPLogin($client_id);
                //save positive consent
                if (is_null($former_user_consent)) {
                    $this->user_consent_service->addUserConsent($user, $client, $scope);
                }
                $response = $this->buildResponse($request, $has_former_consent);
                // clear save data ...
                $this->auth_service->clearUserAuthorizationResponse();
                $this->memento_service->forget();
                return $response;
            }

            if (!$this->canInteractWithEndUser($request))
                throw new InteractionRequiredException;

            $this->memento_service->serialize($request->getMessage()->createMemento());
            $this->log_service->debug_msg(sprintf("Doing consent ... authorization_response %s should_prompt_consent %s",$authorization_response, $should_prompt_consent));
            return $this->auth_strategy->doConsent($request);
        }
        catch(Exception $ex)
        {
            $this->log_service->warning($ex);
            // clear save data ...
            $this->auth_service->clearUserAuthorizationResponse();
            $this->memento_service->forget();
            throw $ex;
        }
    }

    /**
     * @param string $origin
     * @param string $client_id
     * @param string $session_id
     * @return string
     */
    public function getSessionState($origin, $client_id, $session_id)
    {

        $this->log_service->debug_msg(sprintf(
            "InteractiveGrantType::getSessionState origin %s client_id %s session_id %s",
            $origin,
            $client_id,
            $session_id
        ));

        $salt    = bin2hex(Random::string(16));
        $message = "{$client_id}{$origin}{$session_id}{$salt}";
        $this->log_service->debug_msg(sprintf(
            "InteractiveGrantType::getSessionState message %s",
            $message
        ));
        $hash = hash('sha256', $message);
        $this->log_service->debug_msg(sprintf(
            "InteractiveGrantType::getSessionState hash %s",
            $hash
        ));
        $session_state = $hash. '.' . $salt;
        $this->log_service->debug_msg(sprintf(
            "InteractiveGrantType::getSessionState session_state %s",
            $session_state
        ));

        return $session_state;
    }

    /**
     * @param string $url
     * @return string
     */
    static public function getOrigin($url)
    {
        $url_parts = @parse_url($url);
        return sprintf("%s://%s%s", $url_parts['scheme'], $url_parts['host'], isset($url_parts['port']) ? ':' . $url_parts['port'] : '');
    }

    /**
     * @param OAuth2AuthorizationRequest $request
     * @param  bool $has_former_consent
     * @return OAuth2Response
     */
    abstract protected function buildResponse(OAuth2AuthorizationRequest $request, $has_former_consent);

    /**
     * @param IClient $client
     * @throws InvalidApplicationType
     * @throws InvalidClientType
     * @return void
     */
    abstract protected function checkClientTypeAccess(IClient $client);

    /**
     * @param OAuth2AuthorizationRequest $request
     * @return bool
     */
    protected function canInteractWithEndUser(OAuth2AuthorizationRequest $request)
    {
        if($request instanceof OAuth2AuthenticationRequest && in_array(OAuth2Protocol::OAuth2Protocol_Prompt_None, $request->getPrompt()))
        {
            return false;
        }
        return true;
    }

    /**
     * @param OAuth2AuthenticationRequest $request
     * @throws InteractionRequiredException
     * @throws InvalidClientException
     * @throws InvalidLoginHint
     * @throws InvalidJWKAlgorithm
     * @throws InvalidClientType
     * @throws ServerKeyNotFoundException
     * @throws InvalidCompactSerializationException
     */
    protected function processUserHint(OAuth2AuthenticationRequest $request)
    {
        $login_hint = $request->getLoginHint();
        $token_hint = $request->getIdTokenHint();
        $this->log_service->debug_msg("InteractiveGrantType::processUserHint");
        // process login hint
        $user = null;

        if(!empty ($login_hint))
        {

            if (filter_var($login_hint, FILTER_VALIDATE_EMAIL))
            {
                $user = $this->auth_service->getUserByUsername($login_hint);
            }
            else
            {
                $user_id = $this->auth_service->unwrapUserId($login_hint);
                $user    = $this->auth_service->getUserById($user_id);
            }
        }
        else if(!empty($token_hint))
        {
            $this->log_service->debug_msg("InteractiveGrantType::processUserHint has token hint");
            $client_id = $request->getClientId();
            $client    = $this->client_repository->getClientById($client_id);

            if (is_null($client))
            {
                throw new InvalidClientException
                (
                    sprintf
                    (
                        "client_id %s does not exists!",
                        $client_id
                    )
                );
            }

            $jwt = BasicJWTFactory::build($token_hint);

            if($jwt instanceof IJWE)
            {
                $this->log_service->debug_msg("InteractiveGrantType::processUserHint token hint is IJWE");
                // decrypt using server key
                $heuristic              = new ServerEncryptionKeyFinder($this->server_private_key_repository);
                $server_enc_private_key = $heuristic->find
                (
                    $client,
                    $client->getIdTokenResponseInfo()->getEncryptionKeyAlgorithm()
                );

                $jwt->setRecipientKey($server_enc_private_key);

                $payload = $jwt->getPlainText();
                $jwt     = BasicJWTFactory::build($payload);
            }
            if($jwt instanceof IJWS)
            {
                $this->log_service->debug_msg("InteractiveGrantType::processUserHint token hint is IJWS");
                // signed by client ?
                try
                {
                    $heuristic = new ClientSigningKeyFinder($this->jwk_set_reader_service);
                    $client_public_sig_key = $heuristic->find
                    (
                        $client,
                        $client->getIdTokenResponseInfo()->getSigningAlgorithm()
                    );

                    $jwt->setKey($client_public_sig_key);
                }
                catch(RecipientKeyNotFoundException $ex)
                {
                    // try to find the server signing key used ...
                    $this->log_service->debug_msg("InteractiveGrantType::processUserHint token hint is IJWS -> RecipientKeyNotFoundException");
                    $heuristic = new ServerSigningKeyFinder($this->server_private_key_repository);
                    $server_private_sig_key = $heuristic->find
                    (
                        $client,
                        $client->getIdTokenResponseInfo()->getSigningAlgorithm(),
                        $jwt->getJOSEHeader()->getKeyID()->getValue()
                    );
                    $jwt->setKey($server_private_sig_key);
                }

                $verified = $jwt->verify($jwt->getJOSEHeader()->getAlgorithm()->getString());

                if(!$verified)
                    throw new InvalidLoginHint('invalid id_token_hint');
            }

            $sub     = $jwt->getClaimSet()->getSubject();
            $user_id = $this->auth_service->unwrapUserId($sub->getString());
            $user    = $this->auth_service->getUserById($user_id);

            $jti = $jwt->getClaimSet()->getJWTID();
            if(is_null($jti)) throw new InvalidLoginHint('invalid jti!');

            $this->auth_service->reloadSession($jti->getValue());

        }

        if(!is_null($user))
        {
            $this->log_service->debug_msg("InteractiveGrantType::processUserHint: checking principal");
            $logged_user = $this->auth_service->getCurrentUser();
            if
            (
                !is_null($logged_user) &&
                $logged_user->getId() !== $user->getId()
            )
            {
                $this->auth_service->logout();

                if(!$this->canInteractWithEndUser($request)) {
                    $this->log_service->debug_msg("InteractiveGrantType::processUserHint: cant interact with user");
                    throw new InteractionRequiredException;
                }
            }

            $this->security_context_service->save
            (
                $this->security_context_service->get()->setRequestedUserId
                (
                    $user->getId()
                )
            );
        }
    }

    /**
     * @param OAuth2AuthorizationRequest $request
     * @return bool
     */
    protected function shouldPromptLogin(OAuth2AuthorizationRequest $request)
    {
        if
        (
            $request instanceof OAuth2AuthenticationRequest &&
            !$request->isProcessedParam(OAuth2Protocol::OAuth2Protocol_Prompt_Login) &&
            in_array(OAuth2Protocol::OAuth2Protocol_Prompt_Login, $request->getPrompt())
        )
        {
            $request->markParamAsProcessed(OAuth2Protocol::OAuth2Protocol_Prompt_Login);
            return true;
        }
        return false;
    }

    /**
     * @param OAuth2AuthorizationRequest $request
     * @return bool
     */
    protected function shouldPromptConsent(OAuth2AuthorizationRequest $request)
    {
        if
        (
            $request instanceof OAuth2AuthenticationRequest &&
            !$request->isProcessedParam(OAuth2Protocol::OAuth2Protocol_Prompt_Consent) &&
            in_array(OAuth2Protocol::OAuth2Protocol_Prompt_Consent, $request->getPrompt())
        )
        {
            $request->markParamAsProcessed(OAuth2Protocol::OAuth2Protocol_Prompt_Consent);
            return true;
        }
        return false;
    }

    /**
     * @param OAuth2AuthorizationRequest $request
     * @return bool
     */
    protected function hadPromptConsent(OAuth2AuthorizationRequest $request)
    {
        if
        (
            $request instanceof OAuth2AuthenticationRequest &&
            in_array(OAuth2Protocol::OAuth2Protocol_Prompt_Consent, $request->getPrompt())
        )
        {
            return true;
        }
        return false;
    }

    /**
     * @param OAuth2AuthorizationRequest $request
     * @param IClient $client
     * @return bool
     * @throws InteractionRequiredException
     */
    protected function shouldForceReLogin(OAuth2AuthorizationRequest $request, IClient $client)
    {
        $now       = time();
        $principal = $this->principal_service->get();

        if($request instanceof OAuth2AuthenticationRequest)
        {

            $max_age         = $request->getMaxAge();
            $default_max_age = $client->getDefaultMaxAge();

            if(is_null($max_age) && $default_max_age > 0)
                $max_age = $default_max_age;

            if(!is_null($max_age) && $max_age > 0)
            {
                // must required teh auth_time claim
                $this->security_context_service->save
                (
                    $this->security_context_service->get()->setAuthTimeRequired(true)
                );

                if
                (
                    !is_null($principal) &&
                    !is_null($principal->getAuthTime()) &&
                    ($now - $principal->getAuthTime()) > $max_age
                )
                {
                    if (!$this->canInteractWithEndUser($request))
                    {
                        throw new InteractionRequiredException;
                    }

                    return true;
                }
            }
        }
        return false;
    }

    /**
     * @param OAuth2AuthorizationRequest $request
     * @param IClient $client
     * @return bool
     * @throws LoginRequiredException
     */
    protected function mustAuthenticateUser(OAuth2AuthorizationRequest $request, IClient $client)
    {

        if($request instanceof OAuth2AuthenticationRequest)
        {
            try {
                $this->processUserHint($request);
            }
            catch (JWSInvalidJWKException $ex){
                Log::warning($ex);
                throw $ex;
            }
            catch (JWKInvalidSpecException $ex){
                Log::warning($ex);
                throw $ex;
            }
            catch (Exception $ex){
                Log::warning($ex);
                return true;
            }
        }

        if($this->shouldPromptLogin($request))
        {
            $this->auth_service->logout();
            return true;
        }

        if($this->shouldForceReLogin($request, $client))
        {
            $this->auth_service->logout();
            return true;
        }

        if (!$this->auth_service->isUserLogged())
            return true;

        return false;
    }
}