Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to authorize google-api-php-client with a slim 3 Rest API?

I'm trying to create a web-based email client which gets all email data from google mail API. I'm using Slim3 for creating a restful API interface. To access google APIs, I'm using Google-API-PHP-Client (Google does have a rest API access and I really like it but I still haven't figured out how the authorization would work without using the PHP-client-library).

My main problem is how do I structure authentication part of it as google uses Oauth2 for login which gives a code. I can use a simple token based auth in Slim but then how do I achieve the following:

  1. Authentication/Authorization with google.
  2. Identifying new vs returning users.
  3. Maintaining & Retaining both access and refresh tokens from google and local APIs
  4. Since the API will be used on both mobile clients and web-browser, I can't use PHP's default sessions - I'm relying on database driven custom tokens.

How do I structure the APIs?

One way was to use google's token as the only token in the app - but it keeps changing every hour so How do I identify the user from token - calling google API for every incoming call doesn't seem like a graceful solution.

Any leads/links would be really helpful.

Thanks in advance

like image 803
Himanshu Vaishnav Avatar asked May 15 '17 07:05

Himanshu Vaishnav


1 Answers

Note that there are 2 parts:

  1. Authorization
  2. Authentication

I recently created this very lightweight class for Authorization using Google, accessing its REST API. It is self-explanatory with the comments.

/**
 * Class \Aptic\Login\OpenID\Google
 * @package Aptic\Login\OpenID
 * @author Nick de Jong, Aptic
 *
 * Very lightweight class used to login using Google accounts.
 *
 * One-time configuration:
 *  1. Define what the inpoint redirect URIs will be where Google will redirect to upon succesfull login. It must
 *     be static without wildcards; but can be multiple as long as each on is statically defined.
 *  2. Define what payload-data this URI could use. For example, the final URI to return to (the caller).
 *  3. Create a Google Project through https://console.developers.google.com/projectselector/apis/credentials
 *  4. Create a Client ID OAth 2.0 with type 'webapp' through https://console.developers.google.com/projectselector/apis/credentials
 *  5. Store the 'Client ID', 'Client Secret' and defined 'Redirect URIs' (the latter one as defined in Step 1).
 *
 * Usage to login and obtain user data:
 *  1. Instantiate a class using your stored Client ID, Client Secret and a Redirect URI.
 *  2. To login, create a button or link with the result of ->getGoogleLoginPageURI() as target. You can insert
 *     an array of payload data in one of the parameters your app wants to know upon returning from Google.
 *  3. At the Redirect URI, invoke ->getDataFromLoginRedirect(). It will return null on failure,
 *     or an array on success. The array contains:
 *       - sub             string  Google ID. Technically an email is not unique within Google's realm, a sub is.
 *       - email           string
 *       - name            string
 *       - given_name      string
 *       - family_name     string
 *       - locale          string
 *       - picture         string  URI
 *       - hdomain         string  GSuite domain, if applicable.
 *     Additionally, the inpoint can recognize a Google redirect by having the first 6 characters of the 'state' GET
 *     parameter to be 'google'. This way, multiple login mechanisms can use the same redirect inpoint.
 */
class Google {
  protected $clientID       = '';
  protected $clientSecret   = '';
  protected $redirectURI    = '';

  public function __construct($vClientID, $vClientSecret, $vRedirectURI) {
    $this->clientID = $vClientID;
    $this->clientSecret = $vClientSecret;
    $this->redirectURI = $vRedirectURI;
    if (substr($vRedirectURI, 0, 7) != 'http://' && substr($vRedirectURI, 0, 8) != 'https://') $this->redirectURI = 'https://'.$this->redirectURI;
  }

  /**
   * @param string $vSuggestedEmail
   * @param string $vHostedDomain   Either a GSuite hosted domain, * to only allow GSuite domains but accept all, or null to allow any login.
   * @param array $aPayload         Payload data to be returned in getDataFromLoginRedirect() result-data on succesfull login. Keys are not stored, only values. Example usage: Final URI to return to after succesfull login (some frontend).
   * @return string
   */
  public function getGoogleLoginPageURI($vSuggestedEmail = null, $vHostedDomain = '*', $aPayload = []) {
    $vLoginEndpoint  = 'https://accounts.google.com/o/oauth2/v2/auth';
    $vLoginEndpoint .= '?state=google-'.self::encodePayload($aPayload);
    $vLoginEndpoint .= '&prompt=consent'; // or: select_account
    $vLoginEndpoint .= '&response_type=code';
    $vLoginEndpoint .= '&scope=openid+email+profile';
    $vLoginEndpoint .= '&access_type=offline';
    $vLoginEndpoint .= '&client_id='.$this->clientID;
    $vLoginEndpoint .= '&redirect_uri='.$this->redirectURI;

    if ($vSuggestedEmail) $vLoginEndpoint .= '&login_hint='.$vSuggestedEmail;
    if ($vHostedDomain)   $vLoginEndpoint .= '&hd='.$vHostedDomain;
    return($vLoginEndpoint);
  }

  /**
   * Call this function directly from the redirect URI, which is invoked after a call to getGoogleLoginPageURL().
   * You can either provide the code/state GET parameters manually, otherwise it will be retrieved from GET automatically.
   * Returns an array with:
   *  - sub             string  Google ID. Technically an email is not unique within Google's realm, a sub is.
   *  - email           string
   *  - name            string
   *  - given_name      string
   *  - family_name     string
   *  - locale          string
   *  - picture         string  URI
   *  - hdomain         string  G Suite domain
   *  - payload         array   The payload originally provided to ->getGoogleLoginPageURI()
   * @param null|string $vCode
   * @param null|string $vState
   * @return null|array
   */
  public function getDataFromLoginRedirect($vCode = null, $vState = null) {
    $vTokenEndpoint = 'https://www.googleapis.com/oauth2/v4/token';
    if ($vCode === null)  $vCode  = $_GET['code'];
    if ($vState === null) $vState = $_GET['state'];
    if (substr($vState, 0, 7) !== 'google-') {
      trigger_error('Invalid state-parameter from redirect-URI. Softfail on login.', E_USER_WARNING);
      return(null);
    }
    $aPostData = [
        'code' => $vCode,
        'client_id' => $this->clientID,
        'client_secret' => $this->clientSecret,
        'redirect_uri' => $this->redirectURI,
        'grant_type' => 'authorization_code'
    ];
    curl_setopt_array($hConn = curl_init($vTokenEndpoint), [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER         => false,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_USERAGENT      => defined('PROJECT_ID') && defined('API_CUR_VERSION') ? PROJECT_ID.' '.API_CUR_VERSION : 'Aptic\Login\OpenID\Google PHP-class',
        CURLOPT_AUTOREFERER    => true,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_POST           => 1
    ]);
    curl_setopt($hConn, CURLOPT_POSTFIELDS, http_build_query($aPostData));
    $aResult = json_decode(curl_exec($hConn), true);
    curl_close($hConn);
    if (is_array($aResult) && array_key_exists('access_token', $aResult) && array_key_exists('refresh_token', $aResult) && array_key_exists('expires_in', $aResult)) {
      $aUserData = explode('.', $aResult['id_token']); // Split JWT-token
      $aUserData = json_decode(base64_decode($aUserData[1]), true); // Decode JWT-claims from part-1 (without verification by part-0).
      if ($aUserData['exp'] < time()) {
        trigger_error('Received an expired ID-token. Softfail on login.', E_USER_WARNING);
        return(null);
      }
      $aRet = [
          // 'access_token'  => $aResult['access_token'],
          // 'expires_in'    => $aResult['expires_in'],
          // 'refresh_token' => $aResult['refresh_token'],
          'sub'           => array_key_exists('sub',          $aUserData) ? $aUserData['sub']         : '',
          'email'         => array_key_exists('email',        $aUserData) ? $aUserData['email']       : '',
          'name'          => array_key_exists('name',         $aUserData) ? $aUserData['name']        : '',
          'given_name'    => array_key_exists('given_name',   $aUserData) ? $aUserData['given_name']  : '',
          'family_name'   => array_key_exists('family_name',  $aUserData) ? $aUserData['family_name'] : '',
          'locale'        => array_key_exists('locale',       $aUserData) ? $aUserData['locale']      : '',
          'picture'       => array_key_exists('picture',      $aUserData) ? $aUserData['picture']     : '',
          'hdomain'       => array_key_exists('hd',           $aUserData) ? $aUserData['hd']          : '',
          'payload'       => self::decodePayload($vState)
      ];

      return($aRet);
    } else {
      trigger_error('OpenID Connect Login failed.', E_USER_WARNING);
      return(null);
    }
  }

  protected static function encodePayload($aPayload) {
    $aPayloadHEX = [];
    foreach($aPayload as $vPayloadEntry) $aPayloadHEX[] = bin2hex($vPayloadEntry);
    return(implode('-', $aPayloadHEX));
  }

  /**
   * You generally do not need to call this method from outside this class; only if you
   * need your payload *before* calling ->getDataFromLoginRedirect().
   * @param string $vStateParameter
   * @return array
   */
  public static function decodePayload($vStateParameter) {
    $aPayload = explode('-', $vStateParameter);
    $aRetPayload = [];
    for($i=1; $i<count($aPayload); $i++) $aRetPayload[] = hex2bin($aPayload[$i]);
    return($aRetPayload);
  }

}

As soon as the function getDataFromLoginRedirect does return user data, your user is Authorized. This means you can now issue your own internal authentication token.

So, for Authentication, maintain your own data table of users with either sub or email as primary identifier and issue tokens for them, with appropriate expire mechanisms. The Google tokens themselves do not necessarily be stored, as they are only required for subsequent Google API calls; which depend on your use case. For your own application though, your own token mechanism will suffice for authentication.

To get back to your questions:

Authentication/Authorization with google.

Described above.

Identifying new vs returning users.

Can be determined by the existence of the user in your data table.

Maintaining & Retaining both access and refresh tokens from google and local APIs

Ask yourself the question whether you really need to. If so, you could either refresh upon every x requests, or refresh once the expiry time is in less than x minutes (i.e. this will be your application's timeout in that case). If you really require your tokens to remain valid, you should setup a daemon-mechanism that periodically refreshes your users tokens.

like image 122
TicTac Avatar answered Oct 07 '22 12:10

TicTac