<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.


defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir.'/filelib.php');

/**
 * OAuth helper class
 *
 * 1. You can extends oauth_helper to add specific functions, such as twitter extends oauth_helper
 * 2. Call request_token method to get oauth_token and oauth_token_secret, and redirect user to authorize_url,
 *    developer needs to store oauth_token and oauth_token_secret somewhere, we will use them to request
 *    access token later on
 * 3. User approved the request, and get back to moodle
 * 4. Call get_access_token, it takes previous oauth_token and oauth_token_secret as arguments, oauth_token
 *    will be used in OAuth request, oauth_token_secret will be used to bulid signature, this method will
 *    return access_token and access_secret, store these two values in database or session
 * 5. Now you can access oauth protected resources by access_token and access_secret using oauth_helper::request
 *    method (or get() post())
 *
 * Note:
 * 1. This class only support HMAC-SHA1
 * 2. oauth_helper class don't store tokens and secrets, you must store them manually
 * 3. Some functions are based on http://code.google.com/p/oauth/
 *
 * @package    moodlecore
 * @copyright  2010 Dongsheng Cai <dongsheng@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

class oauth_helper {
    /** @var string consumer key, issued by oauth provider*/
    protected $consumer_key;
    /** @var string consumer secret, issued by oauth provider*/
    protected $consumer_secret;
    /** @var string oauth root*/
    protected $api_root;
    /** @var string request token url*/
    protected $request_token_api;
    /** @var string authorize url*/
    protected $authorize_url;
    protected $http_method;
    /** @var string */
    protected $access_token_api;
    /** @var curl */
    protected $http;
    /** @var array options to pass to the next curl request */
    protected $http_options;

    /**
     * Contructor for oauth_helper.
     * Subclass can override construct to build its own $this->http
     *
     * @param array $args requires at least three keys, oauth_consumer_key
     *                    oauth_consumer_secret and api_root, oauth_helper will
     *                    guess request_token_api, authrize_url and access_token_api
     *                    based on api_root, but it not always works
     */
    function __construct($args) {
        if (!empty($args['api_root'])) {
            $this->api_root = $args['api_root'];
        } else {
            $this->api_root = '';
        }
        $this->consumer_key = $args['oauth_consumer_key'];
        $this->consumer_secret = $args['oauth_consumer_secret'];

        if (empty($args['request_token_api'])) {
            $this->request_token_api = $this->api_root . '/request_token';
        } else {
            $this->request_token_api = $args['request_token_api'];
        }

        if (empty($args['authorize_url'])) {
            $this->authorize_url = $this->api_root . '/authorize';
        } else {
            $this->authorize_url = $args['authorize_url'];
        }

        if (empty($args['access_token_api'])) {
            $this->access_token_api = $this->api_root . '/access_token';
        } else {
            $this->access_token_api = $args['access_token_api'];
        }

        if (!empty($args['oauth_callback'])) {
            $this->oauth_callback = new moodle_url($args['oauth_callback']);
        }
        if (!empty($args['access_token'])) {
            $this->access_token = $args['access_token'];
        }
        if (!empty($args['access_token_secret'])) {
            $this->access_token_secret = $args['access_token_secret'];
        }
        $this->http = new curl(array('debug'=>false));
        if (!empty($args['http_options'])) {
            $this->http_options = $args['http_options'];
        } else {
            $this->http_options = array();
        }
    }

    /**
     * Build parameters list:
     *    oauth_consumer_key="0685bd9184jfhq22",
     *    oauth_nonce="4572616e48616d6d65724c61686176",
     *    oauth_token="ad180jjd733klru7",
     *    oauth_signature_method="HMAC-SHA1",
     *    oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D",
     *    oauth_timestamp="137131200",
     *    oauth_version="1.0"
     *    oauth_verifier="1.0"
     * @param array $param
     * @return string
     */
    function get_signable_parameters($params){
        $sorted = $params;
        ksort($sorted);

        $total = array();
        foreach ($sorted as $k => $v) {
            if ($k == 'oauth_signature') {
                continue;
            }

            $total[] = rawurlencode($k) . '=' . rawurlencode($v);
        }
        return implode('&', $total);
    }

    /**
     * Create signature for oauth request
     * @param string $url
     * @param string $secret
     * @param array $params
     * @return string
     */
    public function sign($http_method, $url, $params, $secret) {
        $sig = array(
            strtoupper($http_method),
            preg_replace('/%7E/', '~', rawurlencode($url)),
            rawurlencode($this->get_signable_parameters($params)),
        );

        $base_string = implode('&', $sig);
        $sig = base64_encode(hash_hmac('sha1', $base_string, $secret, true));
        return $sig;
    }

    /**
     * Initilize oauth request parameters, including:
     *    oauth_consumer_key="0685bd9184jfhq22",
     *    oauth_token="ad180jjd733klru7",
     *    oauth_signature_method="HMAC-SHA1",
     *    oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D",
     *    oauth_timestamp="137131200",
     *    oauth_nonce="4572616e48616d6d65724c61686176",
     *    oauth_version="1.0"
     * To access protected resources, oauth_token should be defined
     *
     * @param string $url
     * @param string $token
     * @param string $http_method
     * @return array
     */
    public function prepare_oauth_parameters($url, $params, $http_method = 'POST') {
        if (is_array($params)) {
            $oauth_params = $params;
        } else {
            $oauth_params = array();
        }
        $oauth_params['oauth_version']	    = '1.0';
        $oauth_params['oauth_nonce']	    = $this->get_nonce();
        $oauth_params['oauth_timestamp']    = $this->get_timestamp();
        $oauth_params['oauth_consumer_key'] = $this->consumer_key;
        $oauth_params['oauth_signature_method']	= 'HMAC-SHA1';
        $oauth_params['oauth_signature']	= $this->sign($http_method, $url, $oauth_params, $this->sign_secret);
        return $oauth_params;
    }

    public function setup_oauth_http_header($params) {

        $total = array();
        ksort($params);
        foreach ($params as $k => $v) {
            $total[] = rawurlencode($k) . '="' . rawurlencode($v).'"';
        }
        $str = implode(', ', $total);
        $str = 'Authorization: OAuth '.$str;
        $this->http->setHeader('Expect:');
        $this->http->setHeader($str);
    }

    /**
     * Sets the options for the next curl request
     *
     * @param array $options
     */
    public function setup_oauth_http_options($options) {
        $this->http_options = $options;
    }

    /**
     * Request token for authentication
     * This is the first step to use OAuth, it will return oauth_token and oauth_token_secret
     * @return array
     */
    public function request_token() {
        $this->sign_secret = $this->consumer_secret.'&';

        if (empty($this->oauth_callback)) {
            $params = [];
        } else {
            $params = ['oauth_callback' => $this->oauth_callback->out(false)];
        }

        $params = $this->prepare_oauth_parameters($this->request_token_api, $params, 'GET');
        $content = $this->http->get($this->request_token_api, $params, $this->http_options);
        // Including:
        //     oauth_token
        //     oauth_token_secret
        $result = $this->parse_result($content);
        if (empty($result['oauth_token'])) {
            throw new moodle_exception('oauth1requesttoken', 'core_error', '', null, $content);
        }
        // Build oauth authorize url.
        $result['authorize_url'] = $this->authorize_url . '?oauth_token='.$result['oauth_token'];

        return $result;
    }

    /**
     * Set oauth access token for oauth request
     * @param string $token
     * @param string $secret
     */
    public function set_access_token($token, $secret) {
        $this->access_token = $token;
        $this->access_token_secret = $secret;
    }

    /**
     * Request oauth access token from server
     * @param string $method
     * @param string $url
     * @param string $token
     * @param string $secret
     */
    public function get_access_token($token, $secret, $verifier='') {
        $this->sign_secret = $this->consumer_secret.'&'.$secret;
        $params = $this->prepare_oauth_parameters($this->access_token_api, array('oauth_token'=>$token, 'oauth_verifier'=>$verifier), 'POST');
        $this->setup_oauth_http_header($params);
        // Should never send the callback in this request.
        unset($params['oauth_callback']);
        $content = $this->http->post($this->access_token_api, $params, $this->http_options);
        $keys = $this->parse_result($content);

        if (empty($keys['oauth_token']) || empty($keys['oauth_token_secret'])) {
            throw new moodle_exception('oauth1accesstoken', 'core_error', '', null, $content);
        }

        $this->set_access_token($keys['oauth_token'], $keys['oauth_token_secret']);
        return $keys;
    }

    /**
     * Request oauth protected resources
     * @param string $method
     * @param string $url
     * @param string $token
     * @param string $secret
     */
    public function request($method, $url, $params=array(), $token='', $secret='') {
        if (empty($token)) {
            $token = $this->access_token;
        }
        if (empty($secret)) {
            $secret = $this->access_token_secret;
        }
        // to access protected resource, sign_secret will alwasy be consumer_secret+token_secret
        $this->sign_secret = $this->consumer_secret.'&'.$secret;
        if (strtolower($method) === 'post' && !empty($params)) {
            $oauth_params = $this->prepare_oauth_parameters($url, array('oauth_token'=>$token) + $params, $method);
        } else {
            $oauth_params = $this->prepare_oauth_parameters($url, array('oauth_token'=>$token), $method);
        }
        $this->setup_oauth_http_header($oauth_params);
        $content = call_user_func_array(array($this->http, strtolower($method)), array($url, $params, $this->http_options));
        // reset http header and options to prepare for the next request
        $this->http->resetHeader();
        // return request return value
        return $content;
    }

    /**
     * shortcut to start http get request
     */
    public function get($url, $params=array(), $token='', $secret='') {
        return $this->request('GET', $url, $params, $token, $secret);
    }

    /**
     * shortcut to start http post request
     */
    public function post($url, $params=array(), $token='', $secret='') {
        return $this->request('POST', $url, $params, $token, $secret);
    }

    /**
     * A method to parse oauth response to get oauth_token and oauth_token_secret
     * @param string $str
     * @return array
     */
    public function parse_result($str) {
        if (empty($str)) {
            throw new moodle_exception('error');
        }
        $parts = explode('&', $str);
        $result = array();
        foreach ($parts as $part){
            list($k, $v) = explode('=', $part, 2);
            $result[urldecode($k)] = urldecode($v);
        }
        if (empty($result)) {
            throw new moodle_exception('error');
        }
        return $result;
    }

    /**
     * Set nonce
     */
    function set_nonce($str) {
        $this->nonce = $str;
    }
    /**
     * Set timestamp
     */
    function set_timestamp($time) {
        $this->timestamp = $time;
    }
    /**
     * Generate timestamp
     */
    function get_timestamp() {
        if (!empty($this->timestamp)) {
            $timestamp = $this->timestamp;
            unset($this->timestamp);
            return $timestamp;
        }
        return time();
    }
    /**
     * Generate nonce for oauth request
     */
    function get_nonce() {
        if (!empty($this->nonce)) {
            $nonce = $this->nonce;
            unset($this->nonce);
            return $nonce;
        }
        $mt = microtime();
        $rand = mt_rand();

        return md5($mt . $rand);
    }
}

/**
 * OAuth 2.0 Client for using web access tokens.
 *
 * http://tools.ietf.org/html/draft-ietf-oauth-v2-22
 *
 * @package   core
 * @copyright Dan Poltawski <talktodan@gmail.com>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
abstract class oauth2_client extends curl {
    /** @var string $clientid client identifier issued to the client */
    private $clientid = '';
    /** @var string $clientsecret The client secret. */
    private $clientsecret = '';
    /** @var moodle_url $returnurl URL to return to after authenticating */
    private $returnurl = null;
    /** @var string $scope of the authentication request */
    protected $scope = '';
    /** @var stdClass $accesstoken access token object */
    protected $accesstoken = null;
    /** @var string $refreshtoken refresh token string */
    protected $refreshtoken = '';
    /** @var string $mocknextresponse string */
    private $mocknextresponse = '';
    /** @var array $upgradedcodes list of upgraded codes in this request */
    private static $upgradedcodes = [];
    /** @var bool basicauth */
    protected $basicauth = false;

    /**
     * Returns the auth url for OAuth 2.0 request
     * @return string the auth url
     */
    abstract protected function auth_url();

    /**
     * Returns the token url for OAuth 2.0 request
     * @return string the auth url
     */
    abstract protected function token_url();

    /**
     * Constructor.
     *
     * @param string $clientid
     * @param string $clientsecret
     * @param moodle_url $returnurl
     * @param string $scope
     */
    public function __construct($clientid, $clientsecret, moodle_url $returnurl, $scope) {
        parent::__construct();
        $this->clientid = $clientid;
        $this->clientsecret = $clientsecret;
        $this->returnurl = $returnurl;
        $this->scope = $scope;
        $this->accesstoken = $this->get_stored_token();
    }

    /**
     * Is the user logged in? Note that if this is called
     * after the first part of the authorisation flow the token
     * is upgraded to an accesstoken.
     *
     * @return boolean true if logged in
     */
    public function is_logged_in() {
        // Has the token expired?
        if (isset($this->accesstoken->expires) && time() >= $this->accesstoken->expires) {
            $this->store_token(null);
            return false;
        }

        // We have a token so we are logged in.
        if (isset($this->accesstoken->token)) {
            // Check that the access token has all the requested scopes.
            $scopemissing = false;
            $scopecheck = ' ' . $this->accesstoken->scope . ' ';

            $requiredscopes = explode(' ', $this->scope);
            foreach ($requiredscopes as $requiredscope) {
                if (strpos($scopecheck, ' ' . $requiredscope . ' ') === false) {
                    $scopemissing = true;
                    break;
                }
            }
            if (!$scopemissing) {
                return true;
            }
        }

        // If we've been passed then authorization code generated by the
        // authorization server try and upgrade the token to an access token.
        $code = optional_param('oauth2code', null, PARAM_RAW);
        // Note - sometimes we may call is_logged_in twice in the same request - we don't want to attempt
        // to upgrade the same token twice.
        if ($code && !in_array($code, self::$upgradedcodes) && $this->upgrade_token($code)) {
            return true;
        }

        return false;
    }

    /**
     * Callback url where the request is returned to.
     *
     * @return moodle_url url of callback
     */
    public static function callback_url() {
        global $CFG;

        return new moodle_url('/admin/oauth2callback.php');
    }

    /**
     * An additional array of url params to pass with a login request.
     *
     * @return array of name value pairs.
     */
    public function get_additional_login_parameters() {
        return [];
    }

    /**
     * Returns the login link for this oauth request
     *
     * @return moodle_url login url
     */
    public function get_login_url() {

        $callbackurl = self::callback_url();
        $defaultparams = [
            'client_id' => $this->clientid,
            'response_type' => 'code',
            'redirect_uri' => $callbackurl->out(false),
            'state' => $this->returnurl->out_as_local_url(false),

        ];
        if (!empty($this->scope)) {
            // The scope should only be included if a value is set.
            // If none provided, the server MUST process the request and provide an appropriate documented response.
            // See spec https://tools.ietf.org/html/rfc6749#section-3.3
            $defaultparams['scope'] = $this->scope;
        }

        $params = array_merge(
            $defaultparams,
            $this->get_additional_login_parameters()
        );

        return new moodle_url($this->auth_url(), $params);
    }

    /**
     * Given an array of name value pairs - build a valid HTTP POST application/x-www-form-urlencoded string.
     *
     * @param array $params Name / value pairs.
     * @return string POST data.
     */
    public function build_post_data($params) {
        $result = [];
        foreach ($params as $name => $value) {
            $result[] = urlencode($name) . '=' . urlencode($value);
        }
        return implode('&', $result);
    }

    /**
     * Upgrade a authorization token from oauth 2.0 to an access token
     *
     * @param string $code the code returned from the oauth authenticaiton
     * @return boolean true if token is upgraded succesfully
     */
    public function upgrade_token($code) {
        $callbackurl = self::callback_url();
        $params = array('code' => $code,
            'grant_type' => 'authorization_code',
            'redirect_uri' => $callbackurl->out(false),
        );

        if ($this->basicauth) {
            $idsecret = urlencode($this->clientid) . ':' . urlencode($this->clientsecret);
            $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
        } else {
            $params['client_id'] = $this->clientid;
            $params['client_secret'] = $this->clientsecret;
        }

        // Requests can either use http GET or POST.
        if ($this->use_http_get()) {
            $response = $this->get($this->token_url(), $params);
        } else {
            $response = $this->post($this->token_url(), $this->build_post_data($params));
        }

        if ($this->info['http_code'] !== 200) {
            $debuginfo = !empty($this->error) ? $this->error : $response;
            throw new moodle_exception('oauth2upgradetokenerror', 'core_error', '', $this->info['http_code'], $debuginfo);
        }

        $r = json_decode($response);

        if (is_null($r)) {
            throw new moodle_exception("Could not decode JSON token response");
        }

        if (!empty($r->error)) {
            throw new moodle_exception($r->error . ' ' . $r->error_description);
        }

        if (!isset($r->access_token)) {
            return false;
        }

        if (isset($r->refresh_token)) {
            $this->refreshtoken = $r->refresh_token;
        }

        // Store the token an expiry time.
        $accesstoken = new stdClass;
        $accesstoken->token = $r->access_token;
        if (isset($r->expires_in)) {
            // Expires 10 seconds before actual expiry.
            $accesstoken->expires = (time() + ($r->expires_in - 10));
        }
        $accesstoken->scope = $this->scope;
        // Also add the scopes.
        self::$upgradedcodes[] = $code;
        $this->store_token($accesstoken);

        return true;
    }

    /**
     * Logs out of a oauth request, clearing any stored tokens
     */
    public function log_out() {
        $this->store_token(null);
    }

    /**
     * Make a HTTP request, adding the access token we have
     *
     * @param string $url The URL to request
     * @param array $options
     * @param mixed $acceptheader mimetype (as string) or false to skip sending an accept header.
     * @return bool
     */
    protected function request($url, $options = array(), $acceptheader = 'application/json') {
        $murl = new moodle_url($url);

        if ($this->accesstoken) {
            if ($this->use_http_get()) {
                // If using HTTP GET add as a parameter.
                $murl->param('access_token', $this->accesstoken->token);
            } else {
                $this->setHeader('Authorization: Bearer '.$this->accesstoken->token);
            }
        }

        if ($acceptheader) {
            $this->setHeader('Accept: ' . $acceptheader);
        }

        $response = parent::request($murl->out(false), $options);

        $this->resetHeader();

        return $response;
    }

    /**
     * Multiple HTTP Requests
     * This function could run multi-requests in parallel.
     *
     * @param array $requests An array of files to request
     * @param array $options An array of options to set
     * @return array An array of results
     */
    protected function multi($requests, $options = array()) {
        if ($this->accesstoken) {
            $this->setHeader('Authorization: Bearer '.$this->accesstoken->token);
        }
        return parent::multi($requests, $options);
    }

    /**
     * Returns the tokenname for the access_token to be stored
     * through multiple requests.
     *
     * The default implentation is to use the classname combiend
     * with the scope.
     *
     * @return string tokenname for prefernce storage
     */
    protected function get_tokenname() {
        // This is unusual but should work for most purposes.
        return get_class($this).'-'.md5($this->scope);
    }

    /**
     * Store a token between requests. Currently uses
     * session named by get_tokenname
     *
     * @param stdClass|null $token token object to store or null to clear
     */
    protected function store_token($token) {
        global $SESSION;

        $this->accesstoken = $token;
        $name = $this->get_tokenname();

        if ($token !== null) {
            $SESSION->{$name} = $token;
        } else {
            unset($SESSION->{$name});
        }
    }

    /**
     * Get a refresh token!!!
     *
     * @return string
     */
    public function get_refresh_token() {
        return $this->refreshtoken;
    }

    /**
     * Retrieve a token stored.
     *
     * @return stdClass|null token object
     */
    protected function get_stored_token() {
        global $SESSION;

        $name = $this->get_tokenname();

        if (isset($SESSION->{$name})) {
            return $SESSION->{$name};
        }

        return null;
    }

    /**
     * Get access token.
     *
     * This is just a getter to read the private property.
     *
     * @return string
     */
    public function get_accesstoken() {
        return $this->accesstoken;
    }

    /**
     * Get the client ID.
     *
     * This is just a getter to read the private property.
     *
     * @return string
     */
    public function get_clientid() {
        return $this->clientid;
    }

    /**
     * Get the client secret.
     *
     * This is just a getter to read the private property.
     *
     * @return string
     */
    public function get_clientsecret() {
        return $this->clientsecret;
    }

    /**
     * Should HTTP GET be used instead of POST?
     * Some APIs do not support POST and want oauth to use
     * GET instead (with the auth_token passed as a GET param).
     *
     * @return bool true if GET should be used
     */
    protected function use_http_get() {
        return false;
    }
}
