1<?php 2// This file is part of Moodle - http://moodle.org/ 3// 4// Moodle is free software: you can redistribute it and/or modify 5// it under the terms of the GNU General Public License as published by 6// the Free Software Foundation, either version 3 of the License, or 7// (at your option) any later version. 8// 9// Moodle is distributed in the hope that it will be useful, 10// but WITHOUT ANY WARRANTY; without even the implied warranty of 11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12// GNU General Public License for more details. 13// 14// You should have received a copy of the GNU General Public License 15// along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17/** 18 * Configurable OAuth2 client class. 19 * 20 * @package core_badges 21 * @subpackage badges 22 * @copyright 2020 Tung Thai 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 * @author Tung Thai <Tung.ThaiDuc@nashtechglobal.com> 25 */ 26 27namespace core_badges\oauth2; 28 29defined('MOODLE_INTERNAL') || die(); 30 31require_once($CFG->libdir . '/oauthlib.php'); 32require_once($CFG->libdir . '/filelib.php'); 33require_once('badge_backpack_oauth2.php'); 34 35use moodle_url; 36use moodle_exception; 37use stdClass; 38 39define('BACKPACK_CHALLENGE_METHOD', 'S256'); 40define('BACKPACK_CODE_VERIFIER_TIME', 60); 41 42/** 43 * Configurable OAuth2 client to request authorization and store token. Use the PKCE method to verifier authorization. 44 * 45 * @copyright 2020 Tung Thai 46 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 47 * @author Tung Thai <Tung.ThaiDuc@nashtechglobal.com> 48 */ 49class client extends \core\oauth2\client { 50 51 /** @var \core\oauth2\issuer */ 52 private $issuer; 53 54 /** @var string $clientid client identifier issued to the client */ 55 private $clientid = ''; 56 57 /** @var string $clientsecret The client secret. */ 58 private $clientsecret = ''; 59 60 /** @var moodle_url $returnurl URL to return to after authenticating */ 61 private $returnurl = null; 62 63 /** @var string $grantscope */ 64 protected $grantscope = ''; 65 66 /** @var string $scope */ 67 protected $scope = ''; 68 69 /** @var bool basicauth */ 70 protected $basicauth = true; 71 72 /** @var string|null backpack object */ 73 public $backpack = ''; 74 75 /** 76 * client constructor. 77 * 78 * @param issuer $issuer oauth2 service. 79 * @param string $returnurl return url after login 80 * @param string $additionalscopes the scopes has been granted 81 * @param null $backpack backpack object. 82 * @throws \coding_exception error message. 83 */ 84 public function __construct(\core\oauth2\issuer $issuer, $returnurl = '', $additionalscopes = '', 85 $backpack = null) { 86 $this->issuer = $issuer; 87 $this->clientid = $issuer->get('clientid'); 88 $this->returnurl = $returnurl; 89 $this->clientsecret = $issuer->get('clientsecret'); 90 $this->backpack = $backpack; 91 $this->grantscope = $additionalscopes; 92 $this->scope = $additionalscopes; 93 parent::__construct($issuer, $returnurl, $additionalscopes, false); 94 } 95 96 /** 97 * Get login url. 98 * 99 * @return moodle_url 100 * @throws \coding_exception 101 * @throws moodle_exception 102 */ 103 public function get_login_url() { 104 $callbackurl = self::callback_url(); 105 $scopes = $this->issuer->get('scopessupported'); 106 107 // Removed the scopes does not support in authorization. 108 $excludescopes = ['profile', 'openid']; 109 $arrascopes = explode(' ', $scopes); 110 foreach ($excludescopes as $exscope) { 111 $key = array_search($exscope, $arrascopes); 112 if (isset($key)) { 113 unset($arrascopes[$key]); 114 } 115 } 116 $scopes = implode(' ', $arrascopes); 117 118 $params = array_merge( 119 [ 120 'client_id' => $this->clientid, 121 'response_type' => 'code', 122 'redirect_uri' => $callbackurl->out(false), 123 'state' => $this->returnurl->out_as_local_url(false), 124 'scope' => $scopes, 125 'code_challenge' => $this->code_challenge(), 126 'code_challenge_method' => BACKPACK_CHALLENGE_METHOD, 127 ] 128 ); 129 return new moodle_url($this->auth_url(), $params); 130 } 131 132 /** 133 * Generate code challenge. 134 * 135 * @return string 136 */ 137 public function code_challenge() { 138 $random = bin2hex(openssl_random_pseudo_bytes(43)); 139 $verifier = $this->base64url_encode(pack('H*', $random)); 140 $challenge = $this->base64url_encode(pack('H*', hash('sha256', $verifier))); 141 $_SESSION['SESSION']->code_verifier = $verifier; 142 return $challenge; 143 } 144 145 /** 146 * Get code verifier. 147 * 148 * @return bool 149 */ 150 public function code_verifier() { 151 if (isset($_SESSION['SESSION']) && !empty($_SESSION['SESSION']->code_verifier)) { 152 return $_SESSION['SESSION']->code_verifier; 153 } 154 return false; 155 } 156 157 /** 158 * Generate base64url encode. 159 * 160 * @param string $plaintext text to convert. 161 * @return string 162 */ 163 public function base64url_encode($plaintext) { 164 $base64 = base64_encode($plaintext); 165 $base64 = trim($base64, "="); 166 $base64url = strtr($base64, '+/', '-_'); 167 return ($base64url); 168 } 169 170 /** 171 * Callback url where the request is returned to. 172 * 173 * @return moodle_url url of callback 174 */ 175 public static function callback_url() { 176 return new moodle_url('/admin/oauth2callback.php'); 177 } 178 179 /** 180 * Check and refresh token to keep login on backpack site. 181 * 182 * @return bool 183 * @throws \coding_exception 184 * @throws moodle_exception 185 */ 186 public function is_logged_in() { 187 188 // Has the token expired? 189 if (isset($this->accesstoken->expires) && time() >= $this->accesstoken->expires) { 190 if (isset($this->accesstoken->refreshtoken)) { 191 return $this->upgrade_token($this->accesstoken->refreshtoken, 'refresh_token'); 192 } else { 193 throw new moodle_exception('Could not refresh oauth token, please try again.'); 194 } 195 } 196 197 if (isset($this->accesstoken->token) && isset($this->accesstoken->scope)) { 198 return true; 199 } 200 201 // If we've been passed then authorization code generated by the 202 // authorization server try and upgrade the token to an access token. 203 $code = optional_param('oauth2code', null, PARAM_RAW); 204 // Note - sometimes we may call is_logged_in twice in the same request - we don't want to attempt 205 // to upgrade the same token twice. 206 if ($code && $this->upgrade_token($code, 'authorization_code')) { 207 return true; 208 } 209 210 return false; 211 } 212 213 /** 214 * Request new token. 215 * 216 * @param string $code code verify from Auth site. 217 * @param string $granttype grant type. 218 * @return bool 219 * @throws moodle_exception 220 */ 221 public function upgrade_token($code, $granttype = 'authorization_code') { 222 $callbackurl = self::callback_url(); 223 224 if ($granttype == 'authorization_code') { 225 $this->basicauth = true; 226 $params = array('code' => $code, 227 'grant_type' => $granttype, 228 'redirect_uri' => $callbackurl->out(false), 229 'scope' => $this->get_scopes(), 230 'code_verifier' => $this->code_verifier() 231 ); 232 } else if ($granttype == 'refresh_token') { 233 $this->basicauth = false; 234 $params = array('refresh_token' => $code, 235 'grant_type' => $granttype, 236 'scope' => $this->get_scopes(), 237 ); 238 } 239 if ($this->basicauth) { 240 $idsecret = $this->clientid . ':' . $this->clientsecret; 241 $this->setHeader('Authorization: Basic ' . base64_encode($idsecret)); 242 } else { 243 $params['client_id'] = $this->clientid; 244 $params['client_secret'] = $this->clientsecret; 245 } 246 // Requests can either use http GET or POST. 247 $response = $this->post($this->token_url(), $this->build_post_data($params)); 248 if ($this->info['http_code'] !== 200) { 249 $debuginfo = !empty($this->error) ? $this->error : $response; 250 throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo); 251 } 252 253 $r = json_decode($response); 254 255 if (is_null($r)) { 256 throw new moodle_exception("Could not decode JSON token response"); 257 } 258 259 if (!empty($r->error)) { 260 throw new moodle_exception($r->error . ' ' . $r->error_description); 261 } 262 263 if (!isset($r->access_token)) { 264 return false; 265 } 266 267 // Store the token an expiry time. 268 $accesstoken = new stdClass; 269 $accesstoken->token = $r->access_token; 270 if (isset($r->expires_in)) { 271 // Expires 10 seconds before actual expiry. 272 $accesstoken->expires = (time() + ($r->expires_in - 10)); 273 } 274 if (isset($r->refresh_token)) { 275 $this->refreshtoken = $r->refresh_token; 276 $accesstoken->refreshtoken = $r->refresh_token; 277 } 278 $accesstoken->scope = $r->scope; 279 280 // Also add the scopes. 281 $this->store_token($accesstoken); 282 283 return true; 284 } 285 286 /** 287 * Store a token to verify for send request. 288 * 289 * @param null|stdClass $token 290 */ 291 protected function store_token($token) { 292 global $USER; 293 294 $this->accesstoken = $token; 295 // Create or update a DB record with the new token. 296 $persistedtoken = badge_backpack_oauth2::get_record(['externalbackpackid' => $this->backpack->id, 'userid' => $USER->id]); 297 if ($token !== null) { 298 if (!$persistedtoken) { 299 $persistedtoken = new badge_backpack_oauth2(); 300 $persistedtoken->set('issuerid', $this->backpack->oauth2_issuerid); 301 $persistedtoken->set('externalbackpackid', $this->backpack->id); 302 $persistedtoken->set('userid', $USER->id); 303 } else { 304 $persistedtoken->set('timemodified', time()); 305 } 306 // Update values from $token. Don't use from_record because that would skip validation. 307 $persistedtoken->set('usermodified', $USER->id); 308 $persistedtoken->set('token', $token->token); 309 $persistedtoken->set('refreshtoken', $token->refreshtoken); 310 $persistedtoken->set('expires', $token->expires); 311 $persistedtoken->set('scope', $token->scope); 312 $persistedtoken->save(); 313 } else { 314 if ($persistedtoken) { 315 $persistedtoken->delete(); 316 } 317 } 318 } 319 320 /** 321 * Get token of current user. 322 * 323 * @return stdClass|null token object 324 */ 325 protected function get_stored_token() { 326 global $USER; 327 328 $token = badge_backpack_oauth2::get_record(['externalbackpackid' => $this->backpack->id, 'userid' => $USER->id]); 329 if ($token !== false) { 330 $token = $token->to_record(); 331 return $token; 332 } 333 return null; 334 } 335 336 /** 337 * Get scopes granted. 338 * 339 * @return null|string 340 */ 341 protected function get_scopes() { 342 if (!empty($this->grantscope)) { 343 return $this->grantscope; 344 } 345 $token = $this->get_stored_token(); 346 if ($token) { 347 return $token->scope; 348 } 349 return null; 350 } 351} 352