网络知识 娱乐 golang 苹果登录,服务端验证identityToken(真实有效)

golang 苹果登录,服务端验证identityToken(真实有效)

介绍

2019年之后,对于Apple App来说,如果要支持第三方登录,则必须同时支持苹果的第三方登录,即Sign in With Apple, 本文主要介绍如何使用Go语言实现Sign in With Apple时服务端的验证, 即Generate and Validate Tokens。或者不支持第三方登录, 直接使用电话号码或者账号密码的方式进行注册以及登录。

登录流程

流程大概可以描述为:

  1. app请求通过Apple进行第三方登录,此时,客户端将会获得包括用户唯一凭证UserID(与微信的OpenId类似), 用户全名Full Name, 验证用的Code(IdentityCode)以及验证用的Token(IdentityToken)。

  2. 客户端将获得的数据发送给服务器,由服务器通过IdentityCode或者IdentityToken来验证此次登录是否有效。

  3. 如果验证通过, 服务端处理完自己内部的登录流程后, 将对应的登录结果(状态)返回给客户端。

在第二步服务器的验证过程中,服务器只需要选择Code或者Token中的任意一种进行验证即可:

  1. IdentityToken: 根据Apple官方文档, Token验证方式为JSON WEB Token(JWT), 按照对应的方式进行验证即可。
  2. IdentityCode: 根据Apple官方文档, 通过Code验证需要Apple开发者对该App进行配置的额外client_id, client_secret以及redirect_uri三个参数。

IdentityToken验证

此种验证方法为传统的JWT验证, Token由Header, Payload以及Signature三部分组成, 通过JSON序列化每一部分,然后使用Base64URL编码后通过.拼接起来的字符串。

  1. Header: 包括的字段如下,

    • kid: 表示用于验证签名的Apple公钥
    • alg: 表示用于签名的算法
  2. Payload: 包括的字段有如下,

    • iss(string): 表示Token签发机构, 值固定为: https://appleid.apple.com
    • aud(string): 表示Apple App的ID
    • exp(int64): 表示Token的过期时间, 时间戳
    • iat(int64): 表示client_secret生成时间,时间戳
    • sub(string): 表示用户唯一标识
    • c_hash(string): 文档中没看到这个字段, 作用未知
    • auth_time(int64): 表示签名生成时间
    • email(string): 表示用户邮箱, 可能是真实的也可能Apple处理过的密文邮件地址,取决于用户登录时是否选择了隐藏邮箱
    • email_verified(bool): 表示用户邮箱是否已验证, 由于Apple总是返回已验证了的邮箱, 所以这个字段的值总是为true, 但是需要注意的是, Apple返回的true, 可能是字符串也可能是bool类型, 需要自己处理一下。
    • nonce(string): 只有当发起登录请求的时候传递了此参数, 在验证的时候才会返回,目的是为了降低被攻击的可能性
    • nonce_supported(bool): 表示是否支持nonce, 如果为true, 则需要判断nonce字段值是否正确
    • is_private_email(bool): 表示用户提供的邮箱地址是否是Apple处理了的代理邮箱地址
    • real_user_status(int): 表示用户是否是真实用户: 0(Unsupported: 表示当前系统版本不支持该字段的值, 只有在IOS 14及以上版本, macOS 11及以上版本, watchOS 7及以上版本才支持), 1(Unknown: 系统无法识别是否是真实用户), 2(LikelyReal: 几乎可以确定为真实用户)
  3. Signature: 表示签名字段,用Base64URL对Header和Payload分别编码,然后用.拼接, 最后使用RSA以及SHA256进行签名得到的结果

一个Header和Payload的例子为:

{
    "alg": "RS256",
    "kid": "ABC123DEFG"
}
{
    "iss": "DEF123GHIJ",
    "iat": 1437179036,
    "exp": 1493298100,
    "aud": "https://appleid.apple.com",
    "sub": "com.mytest.app"
}


一个IdentityToken例子如下:
 

eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmZ1bi5BcHBsZUxvZ2luIiwiZXhwIjoxNTY4NzIxNzY5LCJpYXQiOjE1Njg3MjExNjksInN1YiI6IjAwMDU4MC4wODdjNTU0ZGNlMzU0NjZmYTg1YzVhNWQ1OTRkNTI4YS4wODAxIiwiY19oYXNoIjoiel9KY0RscFczQjJwN3ExR0Nna1JaUSIsImF1dGhfdGltZSI6MTU2ODcyMTE2OX0.WmSa4LzOzYsdwTqAJ_8mub4Ls3eyFkxZoGLoy-U7DatsTd_JEwAs3_OtV4ucmj6ENT3153iCpYY6vBxSQromOMcXsN74IrUQew24y_zflN2g4yU8ZVvBCbTrR_6p9f2fbeWjZiyNcbPCha0dv45E3vBjyHhmffWnk3vyndBBiwwuqod4pyCZ3UECf6Vu-o7dygKFpMHPS1ma60fEswY5d-_TJAFk1HaiOfFo0XbL6kwqAGvx8HnraIxyd0n8SbBVxV_KDxf15hdotUizJDW7N2XMdOGQpNFJim9SrEeBhn9741LWqkWCgkobcvYBZsrvnUW6jZ87SLi15rvIpq8_fw

根据上面可以得出验证IdentityToken的步骤为:

  1. .为分隔点, 将IdentityToken分隔为三部分, 第三部分为签名, 留着用于验证

  2. 使用Base64URL解码对应的Header和Payload, 并JSON反序列化为对应的结构体(或者键值对), 并且对Payload中相应对值进行验证,如exp, sub, iat, aud

  3. 通过接口从Apple Server获取RSA公钥,接口地址https://appleid.apple.com/auth/keys, 这里需要注意, 获取到的结果通常为两个,需要用选择与Header中的kid值匹配的那个Key

  4. 步骤3返回的Key中包含了RSA公钥中的NE的值,同样是用Base64URL编码后的值, 需要解码, 然后再构造RSA公钥

  5. 得到公钥后,将步骤1中得到的Base64URL编码的Header和Payload再次拼接起来,然后调用rsa.VerifyPKCS1v15()方法进行签名验证, 注意这里的Hash类型为SHA256

验证代码如下:

package goSignInWithApple

import (
	"account-api/pkg/http"
	"account-api/pkg/oauth2/errors"
	"crypto/rsa"
	"encoding/base64"
	"encoding/json"
	"github.com/dgrijalva/jwt-go"
	"io/ioutil"
	"math/big"
	"strings"
)

const (
	GetApplePublicKeys = "https://appleid.apple.com/auth/keys"
	AppleUrl           = "https://appleid.apple.com"
	ClientId           = "com.xxx.xxx" //这个为app端ios的包名,问ios的同学就可以知道
)

type (
	JwtClaims struct {
		CHash          string `json:"c_hash"`
		Email          string `json:"email"`
		EmailVerified  string `json:"email_verified"`
		AuthTime       int    `json:"auth_time"`
		NonceSupported bool   `json:"nonce_supported"`
		jwt.StandardClaims 
        // jwt中clamis的基础字段,上面几个为苹果官方自定义的字段,很多人不知
        // 道除基础字段以外的第三方自定义字段如何接受,只需要像上面一样在基础字段同
        // 级定义就行
	}

	JwtHeader struct {
		Kid string `json:"kid"`
		Alg string `json:"alg"`
	}

	JwtKeys struct {
		Kty string `json:"kty"`
		Kid string `json:"kid"`
		Use string `json:"use"`
		Alg string `json:"alg"`
		N   string `json:"n"`
		E   string `json:"e"`
	}
)

// VerifyIdentityToken 认证客户端传递过来的token是否有效
func VerifyIdentityToken(cliToken string, cliUserID string) (error, *JwtClaims) {
	// 数据由 头部、载荷、签名 三部分组成
	cliTokenArr := strings.Split(cliToken, ".")
	if len(cliTokenArr) < 3 {
		return errors.New("cliToken Split err"), nil
	}

	// 解析cliToken的header获取kid
	cliHeader, err := jwt.DecodeSegment(cliTokenArr[0])
	if err != nil {
		return err, nil
	}

	var jHeader JwtHeader
	err = json.Unmarshal(cliHeader, &jHeader)
	if err != nil {
		return err, nil
	}

	// 效验pubKey 及 token
	token, err := jwt.ParseWithClaims(cliToken, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) {
		return GetRSAPublicKey(jHeader.Kid), nil
	})

	if err != nil {
		return err, nil
	}

	// 信息验证
	if claims, ok := token.Claims.(*JwtClaims); ok && token.Valid {
		if claims.StandardClaims.Issuer != AppleUrl || claims.StandardClaims.Audience != ClientId || claims.StandardClaims.Subject != cliUserID {
			return errors.New("verify token info fail, info is not match"), nil
		}

		return nil, claims
	}

	return errors.New("token claims parse fail"), nil
}

/*
 GetRSAPublicKey 向苹果服务器获取解密signature所需要用的publicKey,苹果官方
返回的公钥不止一个,可能有多个,只需要像下面一样通过和identifyToken的header里的kid
比对,找匹配到的那一个使用就行。jwt总共分三段,前两段其实只需要通过base64直接反解就可
以获取到内容了,这个也是很多同学不知道的
*/
func GetRSAPublicKey(kid string) *rsa.PublicKey {
	response, err := http.Get(GetApplePublicKeys, nil, nil)
	if err != nil {
		return nil
	}

	body, err := ioutil.ReadAll(response.Body)
	if err != nil {
		return nil
	}

	var jKeys map[string][]JwtKeys
	err = json.Unmarshal(body, &jKeys)
	if err != nil {
		return nil
	}

	// 获取验证所需的公钥
	var pubKey rsa.PublicKey
	// 通过cliHeader的kid比对获取n和e值 构造公钥
	for _, data := range jKeys {
		for _, val := range data {
			if val.Kid == kid {
				nByte, _ := base64.RawURLEncoding.DecodeString(val.N)
				nData := new(big.Int).SetBytes(nByte)

				eByte, _ := base64.RawURLEncoding.DecodeString(val.E)
				eData := new(big.Int).SetBytes(eByte)

				pubKey.N = nData
				pubKey.E = int(eData.Uint64())
				break
			}
		}
	}

	if pubKey.E <= 0 {
		return nil
	}

	return &pubKey
}