Sign in With Apple之服务端验证

介绍

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

验证代码如下:

func (v *Validator) CheckIdentityToken(token string) (JWTToken, error) {
    if token == "" {
        return nil, ErrInvalidIdentityToken
    }
    appleToken, err := parseToken(token)
    if err != nil {
        return nil, err
    }
    key, err := fetchKeysFromApple(appleToken.header.Kid)
    if err != nil {
        return nil, err
    }
    if key == nil {
        return nil, ErrFetchKeysFail
    }
    
    pubKey, err := generatePubKey(key.N, key.E)
    if err != nil {
        return nil, err
    }
    
    //利用获取到的公钥解密token中的签名数据
    sig, err := decodeSegment(appleToken.sign)
    if err != nil {
        return nil, err
    }
    
    //苹果使用的是SHA256
    var h hash.Hash
    switch appleToken.header.Alg {
    case "RS256":
        h = crypto.SHA256.New()
    case "RS384":
        h = crypto.SHA384.New()
    case "RS512":
        h = crypto.SHA512.New()
    }
    if h == nil {
        return nil, ErrInvalidHashType
    }
    
    h.Write([]byte(appleToken.headerStr + "." + appleToken.claimsStr))
    
    return appleToken, rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, h.Sum(nil), sig)
}

IdentityCode验证

按照官方文档, IdentityCode的验证相对来说限制要高一点,没有那么通用, 因为验证过程中需要用到client_id, client_secret, redirect_uri三个参数, 由于每个Apple App这三个参数都不相同, 所以没有IdentityToken那么通用。

根据官方文档, IdentityCode的验证需要调用接口向Apple Server验证, 接口地址为: https://appleid.apple.com/auth/token

文档中已经说得很明白, 具体代码如下:

func (v *Validator) CheckIdentityCode(code string) (*TokenResponse, error) {
    if code == "" {
        return nil, ErrInvalidIdentityCode
    }
    if v.clientID == "" {
        return nil, ErrInvalidClientID
    }
    if v.clientSecret == "" {
        return nil, ErrInvalidClientSecret
    }
    //验证IdentityCode时需要填写redirect_uri参数,且redirect_uri参数必须是https协议
    if uri := strings.ToLower(v.redirectUri); strings.HasPrefix(uri, "https://") {
        return nil, ErrInvalidRedirectURI
    }
    
    param := fmt.Sprintf("client_id=%s&client_secret=%s&code=%s&grant_type=authorization_code&redirect_uri=%s", v.clientID, v.clientSecret, code, v.redirectUri)
    rder := strings.NewReader(param)
    response, err := http.Post("https://appleid.apple.com/auth/token", "application/x-www-form-urlencoded", rder)
    if err != nil {
        return nil, err
    }
    defer response.Body.Close()
    
    if response.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("checking identityCode from apple server fail: %d", response.StatusCode)
    }
    
    data, err := ioutil.ReadAll(response.Body)
    if err != nil {
        return nil, err
    }
    
    var tkResult *TokenResponse
    if err = json.Unmarshal(data, &tkResult); err != nil {
        return nil, err
    }
    return tkResult, nil
}

详细代码请前往Github
原文地址Golang实现Sign in With Apple服务端登录验证

参考资料

Sign in With Apple

jwt-go

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270