­

蘋果登錄服務端JWT演算法驗證-PHP

  • 2020 年 4 月 27 日
  • 筆記

驗證參數

可用的驗證參數有 userID、authorizationCode、identityToken,需要iOS客戶端傳過來

驗證方式

蘋果登錄驗證可以選擇兩種驗證方式

具體可參考這篇文章 //juejin.im/post/5e21c212f265da3e0640bf49

我們採用JWT演算法校驗 identityToken 的方式來驗證

JWT演算法原理

客戶端傳過來的userID示例  000327.cd00e3974ea8402dbe3a33e6867f1ee6.1006 

identityToken 示例

eyJraWQiOiJlWGF1bm1MIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnl3c3kuaW9zLmRlbW8iLCJleHAiOjE1ODY5NDY5NzAsImlhdCI6MTU4Njk0NjM3MCwic3ViIjoiMDAwMzI3LmNkMDBlMzk3NGVhODQwMmRiZTNhMzNlNjg2N2YxZWU2LjEwMDYiLCJjX2hhc2giOiJsQTFkcDlZMnZBVzlFQXlkSWw2MVh3IiwiZW1haWwiOiI5ZXpyMmszaDZzQHByaXZhdGVyZWxheS5hcHBsZWlkLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjoidHJ1ZSIsImlzX3ByaXZhdGVfZW1haWwiOiJ0cnVlIiwiYXV0aF90aW1lIjoxNTg2OTQ2MzcwLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.GqZFKMQ3KTG42x2-W7r69nnYqqoBHszI4LBI7m7ysyqBRyt1XSGDPy440F153C8x05VgZgkYi0mIZheCIenIMl5R0unOKrXhvHihgwIKtuvPClRQmAyZYxOWct8xGoPvrRpZr4AkJwxauUxaY8NIoV8-UrNduQcjW8-63-wF9B0F-2p61WZuOEmCoULj2aW7fBoRgFylGbQpXAU_8t32fj1JG3OJzErDJsi1P1CJyKaamd-UpVmgwyaCl0nXMnX0CB0ERqb76M67BHY0ji3VBuIp3uZczEEJMzFtgAevOfgoNYRFicVBr25XoyaWYPxZgYnI-AeUQgvnwHaacx4bkg

使用JWT演算法做驗證,不需要authorizationCode。校驗演算法是對identityToken做處理的。

把identityToken 用 . 點號分割得到三個部分,前兩個部分可以用base64_decode分別得到兩串JSON資訊。

第一段稱為 header,描述了這段消息的加密方式

{"kid":"eXaunmL","alg":"RS256"}

第二段稱為 payload,是消息的具體內容

{
    "iss":"//appleid.apple.com",
    "aud":"com.ywsy.ios.demo",
    "exp":1586946970,
    "iat":1586946370,
    "sub":"000327.cd00e3974ea8402dbe3a33e6867f1ee6.1006",
    "c_hash":"lA1dp9Y2vAW9EAydIl61Xw",
    "email":"9ezr2k3h6s@privaterelay.appleid.com",
    "email_verified":"true",
    "is_private_email":"true",
    "auth_time":1586946370,
    "nonce_supported":true
}

校驗流程

1、解析出identityToken的第二段資訊,即payload;

2、檢查userID與payload中的sub欄位是否一致;

3、檢查payload中的exp欄位,有效期時間戳是否已過期;

4、從蘋果伺服器讀取公鑰;

5、蘋果公鑰轉為pem格式;

6、使用pem公鑰校驗identityToken;

其中第4步,從蘋果伺服器讀取公鑰 //appleid.apple.com/auth/keys 得到一串JSON

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "86D88Kf",
      "use": "sig",
      "alg": "RS256",
      "n": "iGaLqP6y-SJCCBq5Hv6pGDbG_SQ11MNjH7rWHcCFYz4hGwHC4lcSurTlV8u3avoVNM8jXevG1Iu1SY11qInqUvjJur--hghr1b56OPJu6H1iKulSxGjEIyDP6c5BdE1uwprYyr4IO9th8fOwCPygjLFrh44XEGbDIFeImwvBAGOhmMB2AD1n1KviyNsH0bEB7phQtiLk-ILjv1bORSRl8AK677-1T8isGfHKXGZ_ZGtStDe7Lu0Ihp8zoUt59kx2o9uWpROkzF56ypresiIl4WprClRCjz8x6cPZXU2qNWhu71TQvUFwvIvbkE1oYaJMb0jcOTmBRZA2QuYw-zHLwQ",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "eXaunmL",
      "use": "sig",
      "alg": "RS256",
      "n": "4dGQ7bQK8LgILOdLsYzfZjkEAoQeVC_aqyc8GC6RX7dq_KvRAQAWPvkam8VQv4GK5T4ogklEKEvj5ISBamdDNq1n52TpxQwI2EqxSk7I9fKPKhRt4F8-2yETlYvye-2s6NeWJim0KBtOVrk0gWvEDgd6WOqJl_yt5WBISvILNyVg1qAAM8JeX6dRPosahRVDjA52G2X-Tip84wqwyRpUlq2ybzcLh3zyhCitBOebiRWDQfG26EH9lTlJhll-p_Dg8vAXxJLIJ4SNLcqgFeZe4OfHLgdzMvxXZJnPp_VgmkcpUdRotazKZumj6dBPcXI_XID4Z4Z3OM1KrZPJNdUhxw",
      "e": "AQAB"
    }
  ]
}

關鍵步驟要做的就是把 「kid」:”eXaunmL” 的這部分JSON拿出來,用其n值和e值構造出蘋果pem格式的公鑰

JWT演算法函數,涉及密碼數學知識,不詳解

<?php
static function createPemFromModulusAndExponent($n, $e)
static function urlsafeB64Decode($input)
static function encodeLength($length)

 

核心程式碼

<?php
class Common_Apple{

    protected static $supported_algs = array(
        'HS256' => array('hash_hmac', 'SHA256'),
        'HS512' => array('hash_hmac', 'SHA512'),
        'HS384' => array('hash_hmac', 'SHA384'),
        'RS256' => array('openssl', 'SHA256'),
        'RS384' => array('openssl', 'SHA384'),
        'RS512' => array('openssl', 'SHA512'),
    );

    function __construct($game_id){}

    function get_login_info($userID, $authorizationCode, $identityToken){
        /*{{{*/
        $token = explode('.', $identityToken);
        $jwt_header = json_decode( base64_decode($token[0]), TRUE);
        $jwt_data = json_decode( base64_decode($token[1]), TRUE);
        $jwt_sign = $token[2];
//        var_dump($jwt_header);
//        var_dump($jwt_data);
//        var_dump($jwt_sign);
        if( $userID !== $jwt_data['sub']){
            return fail('用戶ID與token不對應');
        }
        if( PRODUCTION_ENV && $jwt_data['exp'] < time() ){
            return fail('token已過期,請重新登錄');
        }
        
        $applekeys = Common_Http::get_https_content('//appleid.apple.com/auth/keys');
        $applekeys = json_decode($applekeys, TRUE);
//        var_dump($applekeys);
        if( !$applekeys ){
            return fail('請求蘋果伺服器失敗');
        }
        
        $the_apple_key = [];
        foreach($applekeys['keys'] as $key){
            if($key['kid'] == $jwt_header['kid'] ){
                $the_apple_key = $key;
            }
        }unset($key);
//        var_dump($the_apple_key);
        
        $pem = self::createPemFromModulusAndExponent($the_apple_key['n'], $the_apple_key['e']);
        $pKey = openssl_pkey_get_public($pem);
//        var_dump($pKey);
        if( $pKey === FALSE ){
            return fail('生成蘋果pem失敗');
        }
        $publicKeyDetails = openssl_pkey_get_details($pKey);
//        var_dump($publicKeyDetails);
        
        $pub_key = $publicKeyDetails['key'];
        $alg = $jwt_header['alg'];

        $ok = self::verify("$token[0].$token[1]", static::urlsafeB64Decode($jwt_sign), $pub_key, $alg);
//        var_dump($ok);
        if( !$ok ){
            return fail('蘋果登錄簽名校驗失敗');
        }
        
        return success([]);
        /*}}}*/
    }


    /**
     *
     * Create a public key represented in PEM format from RSA modulus and exponent information
     *
     * @param string $n the RSA modulus encoded in Base64
     * @param string $e the RSA exponent encoded in Base64
     * @return string the RSA public key represented in PEM format
     */
    protected static function createPemFromModulusAndExponent($n, $e)
    {
        $modulus = static::urlsafeB64Decode($n);
        $publicExponent = static::urlsafeB64Decode($e);
        
        $components = array(
            'modulus' => pack('Ca*a*', 2, self::encodeLength(strlen($modulus)), $modulus),
            'publicExponent' => pack('Ca*a*', 2, self::encodeLength(strlen($publicExponent)), $publicExponent)
        );

        $RSAPublicKey = pack(
            'Ca*a*a*',
            48,
            self::encodeLength(strlen($components['modulus']) + strlen($components['publicExponent'])),
            $components['modulus'],
            $components['publicExponent']
        );

        // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption.
        $rsaOID = pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA
        $RSAPublicKey = chr(0) . $RSAPublicKey;
        $RSAPublicKey = chr(3) . self::encodeLength(strlen($RSAPublicKey)) . $RSAPublicKey;

        $RSAPublicKey = pack(
            'Ca*a*',
            48,
            self::encodeLength(strlen($rsaOID . $RSAPublicKey)),
            $rsaOID . $RSAPublicKey
        );

        $RSAPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" .
            chunk_split(base64_encode($RSAPublicKey), 64) .
            '-----END PUBLIC KEY-----';

        return $RSAPublicKey;
    }


    /**
     * Decode a string with URL-safe Base64.
     *
     * @param string $input A Base64 encoded string
     *
     * @return string A decoded string
     */
    protected static function urlsafeB64Decode($input)
    {
        $remainder = strlen($input) % 4;
        if ($remainder) {
            $padlen = 4 - $remainder;
            $input .= str_repeat('=', $padlen);
        }
        return base64_decode(strtr($input, '-_', '+/'));
    }

    /**
     * DER-encode the length
     *
     * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4.  See
     * {@link //itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information.
     *
     * @access private
     * @param int $length
     * @return string
     */
    protected static function encodeLength($length)
    {
        if ($length <= 0x7F) {
            return chr($length);
        }

        $temp = ltrim(pack('N', $length), chr(0));
        return pack('Ca*', 0x80 | strlen($temp), $temp);
    }

    /**
     * Get the number of bytes in cryptographic strings.
     *
     * @param string
     *
     * @return int
     */
    protected static function safeStrlen($str)
    {
        if (function_exists('mb_strlen')) {
            return mb_strlen($str, '8bit');
        }
        return strlen($str);
    }

    /**
     * Verify a signature with the message, key and method. Not all methods
     * are symmetric, so we must have a separate verify and sign method.
     *
     * @param string            $msg        The original message (header and body)
     * @param string            $signature  The original signature
     * @param string|resource   $key        For HS*, a string key works. for RS*, must be a resource of an openssl public key
     * @param string            $alg        The algorithm
     *
     * @return bool
     *
     * @throws DomainException Invalid Algorithm or OpenSSL failure
     */
    protected static function verify($msg, $signature, $key, $alg)
    {
        if (empty(static::$supported_algs[$alg])) {
            throw new DomainException('Algorithm not supported');
        }

        list($function, $algorithm) = static::$supported_algs[$alg];
        switch($function) {
            case 'openssl':
                $success = openssl_verify($msg, $signature, $key, $algorithm);
                if ($success === 1) {
                    return true;
                } elseif ($success === 0) {
                    return false;
                }
                // returns 1 on success, 0 on failure, -1 on error.
                throw new DomainException(
                    'OpenSSL error: ' . openssl_error_string()
                );
            case 'hash_hmac':
            default:
                $hash = hash_hmac($algorithm, $msg, $key, true);
                if (function_exists('hash_equals')) {
                    return hash_equals($signature, $hash);
                }
                $len = min(static::safeStrlen($signature), static::safeStrlen($hash));

                $status = 0;
                for ($i = 0; $i < $len; $i++) {
                    $status |= (ord($signature[$i]) ^ ord($hash[$i]));
                }
                $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash));

                return ($status === 0);
        }
    }


}