苹果登陆及后端Node 验证

2019 年苹果推出了自己家的登陆,要求集成第三方登陆的app, 必须在2020年4月份前集成苹果登陆, 所以我们来简单说一说

  • 废话不多说, 直接上代码

  • 这里不采用苹果提供的Button, 只提供方法

要求:

  • iOS13 或以上
  • Xcode11
  • enables Sign in with Apple features on Apple developer website , 开启这个后, 你的开发者网站会默认也会开启Sign in with Apple
import UIKit
import AuthenticationServices

public typealias CallbackType = (Any?) -> ()

* 加objc 是为了方便OC 可以直接调用, 看需要加不加
@objc open class SignInWithAppleTool: NSObject {
    var viewController: UIViewController! = nil
    private var callback_: CallbackType? = nil
    required public init(viewController: UIViewController) {
        self.viewController = viewController
    }
    
    @objc public var canShowButton: Bool {
        var r = false
        if #available(iOS 13.0, *) {r = true}
        return r
    }
    
    @objc public func login(callback: @escaping CallbackType ) {
        
        if !canShowButton {return};
        
        guard #available(iOS 13.0, *) else {
            callback(["state" : -1, "errCode" : "0","errDesc": "only ios13 or above"])
            return
        }
        callback_ = callback
        let request = ASAuthorizationAppleIDProvider().createRequest()
        request.requestedScopes = [.fullName, .email]   //需要的权限
        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.presentationContextProvider = self
        controller.performRequests()

    }
    
    @objc public func checkState(withUserID userID: String,callback: @escaping CallbackType) {
        guard #available(iOS 13.0, *) else {return}
        
        let provider = ASAuthorizationAppleIDProvider()
        provider.getCredentialState(forUserID: userID) { (credentialState, error) in
            switch(credentialState){
            case .authorized:
                // Apple ID Credential is valid
                callback(["state":1,"errDesc": "Apple ID Credential is valid"])
                break
            case .revoked:
                // Apple ID Credential revoked, handle unlink
                callback(["state":-1, "errDesc": "Apple ID Credential revoked, handle unlink"])
                fallthrough
            case .notFound:
                // Credential not found, show login UI
                callback(["state":-2, "errDesc": "Credential not found, show login UI"])
                break
            default:
                callback(["state":-3, "errDesc": "Other"])
                break
            }
        }
    }
    
}
extension SignInWithAppleTool: ASAuthorizationControllerDelegate {
    
    /*
    ∙ User ID: Unique, stable, team-scoped user ID,苹果用户唯一标识符,该值在同一个开发者账号下的所有 App 下是一样的,开发者可以用该唯一标识符与自己后台系统的账号体系绑定起来。

    ∙ Verification data: Identity token, code,验证数据,用于传给开发者后台服务器,然后开发者服务器再向苹果的身份验证服务端验证本次授权登录请求数据的有效性和真实性,详见 Sign In with Apple REST API。如果验证成功,可以根据 userIdentifier 判断账号是否已存在,若存在,则返回自己账号系统的登录态,若不存在,则创建一个新的账号,并返回对应的登录态给 App。

    ∙ Account information: Name, verified email,苹果用户信息,包括全名、邮箱等。

    ∙ Real user indicator: High confidence indicator that likely real user,用于判断当前登录的苹果账号是否是一个真实用户,取值有:unsupported、unknown、likelyReal。*/
    @available(iOS 13.0, *)
    public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        switch authorization.credential {
        case let appleIdCredential as ASAuthorizationAppleIDCredential:
            let state = appleIdCredential.state ?? ""
            let userIdentifier = appleIdCredential.user
            let familyName = appleIdCredential.fullName?.familyName ?? ""
            let givenName = appleIdCredential.fullName?.givenName ?? ""
            let nickname = appleIdCredential.fullName?.nickname ?? ""
            let middleName = appleIdCredential.fullName?.middleName ?? ""
            let namePrefix = appleIdCredential.fullName?.namePrefix ?? ""
            let nameSuffix = appleIdCredential.fullName?.nameSuffix ?? ""
            
            let familyName_phone = appleIdCredential.fullName?.phoneticRepresentation?.familyName ?? ""
            let givenName_phone = appleIdCredential.fullName?.phoneticRepresentation?.givenName ?? ""
            let nickname_phone = appleIdCredential.fullName?.phoneticRepresentation?.nickname ?? ""
            let namePrefix_phone = appleIdCredential.fullName?.phoneticRepresentation?.namePrefix ?? ""
            let nameSuffix_phone = appleIdCredential.fullName?.phoneticRepresentation?.nameSuffix ?? ""
            let middleName_phone = appleIdCredential.fullName?.phoneticRepresentation?.middleName ?? ""
            
            let email = appleIdCredential.email ?? ""
            let identityToken = String(bytes: appleIdCredential.identityToken ?? Data(), encoding: .utf8) ?? ""
            let authCode = String(bytes: appleIdCredential.authorizationCode ?? Data(), encoding: .utf8) ?? ""
            let realUserStatus = appleIdCredential.realUserStatus.rawValue
            let info = [
                "state": state,
                "userIdentifier": userIdentifier,
                "familyName": familyName,
                "givenName": givenName,
                "nickname": nickname,
                "middleName": middleName,
                "namePrefix": namePrefix,
                "nameSuffix": nameSuffix,
                "familyName_phone": familyName_phone,
                "givenName_phone": givenName_phone,
                "nickname_phone": nickname_phone,
                "namePrefix_phone": namePrefix_phone,
                "nameSuffix_phone": nameSuffix_phone,
                "middleName_phone": middleName_phone,
                "email": email,
                "identityToken": identityToken,
                "authCode": authCode,
                "realUserStatus": realUserStatus
                ] as [String : Any]
            print("success:=\(info)")
            let p = ["state" : 1, "errCode" : "", "info": info] as [String : Any]
            callback_?(p)
        default:
            let p = ["state" : -1, "errCode" : "0",] as [String : Any]
            callback_?(p)
            break
        }
    }

    @available(iOS 13.0, *)
    public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
        print("error:\(error.localizedDescription)")
        let err = error as NSError
        var errCode = 0;
        var errDesc = "Other"
        switch err.code {
        case ASAuthorizationError.canceled.rawValue:
            print("用户取消了授权请求")
            errCode = -1;
            errDesc = "User cancelled authorization request"
            break;
        case ASAuthorizationError.failed.rawValue:
            print("授权请求失败")
            errCode = -2;
            errDesc = "Authorization request failed"
            break;
        case ASAuthorizationError.invalidResponse.rawValue:
            print("授权请求无响应")
            errCode = -3;
            errDesc = "Authorization request is not responding"
            break;
        case ASAuthorizationError.notHandled.rawValue:
            print("未能处理授权请求")
            errCode = -4;
            errDesc = "Failed to process authorization request"
            break;
        case ASAuthorizationError.unknown.rawValue:
            print("授权请求失败未知原因")
            errCode = -5;
            errDesc = "Authorization request failed : unknown reason"
            break;
        default:
            print("other")
            break;
        }
        let p = ["state" : -1, "errCode" : errCode, "errDesc": errDesc] as [String : Any]
        callback_?(p)
        
    }
}
extension SignInWithAppleTool: ASAuthorizationControllerPresentationContextProviding {
    @available(iOS 13.0, *)
    public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        viewController.view.window ?? UIWindow()
    }
}

  • 授权后就可以拿到 User ID / Identity token , app端不一定可以拿到用户的email , 假如用户在授权时选择隐藏就会没有, 但没事, 后端验证后可以拿到email, 苹果登陆app端还是比较简单的, 这里将获取到的数据丢给后端就可以了
  • authorizationCode:授权code, 给授权码的验证使用的

后端Node 验证

  • 苹果提供两种验证方式: 一种是基于JWT的算法验证,另外一种是基于授权码的验证
  • 我们这里只要讲解 JWT
  • 想了解JWT 的朋友请自行查阅
  • 苹果公钥网站
const NodeRSA = require('node-rsa');
const axios = require('axios');
const jwt = require('jsonwebtoken');
const express = require('express')
const app = express()


/*
alg:string
The encryption algorithm used to encrypt the token.
e: string
The exponent value for the RSA public key.
kid: string
A 10-character identifier key, obtained from your developer account.
kty: string
The key type parameter setting. This must be set to "RSA".
n: string
The modulus value for the RSA public key.
use: string
* kid,为密钥id标识,签名算法采用的是RS256(RSA 256 + SHA 256),kty常量标识使用RSA签名算法,其公钥参数
*/
// 获取苹果的公钥
async function getApplePublicKey() {
    let res = await axios.request({
        method: "GET",
        url: "https://appleid.apple.com/auth/keys",
        headers: {
            'Content-Type': 'application/json'
        }
    })
    let key = res.data.keys[0]
    const pubKey = new NodeRSA();
    pubKey.importKey({ n: Buffer.from(key.n, 'base64'), e: Buffer.from(key.e, 'base64') }, 'components-public');
    return pubKey.exportKey(['public']);
};

// 验证id_token
// id_token:  Identity token
// audience : app bundle id  , 可以不用
// subject : userId , 可以不用
function verifyIdToken(id_token, audience, subject, callback) {
    const applePublicKey = await getApplePublicKey();
    // const jwtClaims = jwt.verify(id_token, applePublicKey, { algorithms: 'RS256', issuer: "https://appleid.apple.com", audience, subject });
    jwt.verify(id_token, applePublicKey, { algorithms: 'RS256' }, (err, decode) => {

        if (err) {
            //message: invalid signature  / jwt expired
            console.log("JJ: verifyIdToken -> error", err.name, err.message, err.date);
            callback && callback(err);
        } else if (decode) {

            // let decode = {
            //     iss: 'https://appleid.apple.com',
            //     aud: 'xxxxxxxx',   
            //     exp: 1579171507,
            //     iat: 1579170907,
            //     sub: 'xxxxxxxx.xxxx',
            //     c_hash: 'xxxxxxxxxxxx',
            //     email: 'xxxxx@qq.com',
            //     email_verified: 'true',
            //     auth_time: 1579170907
            // }
            console.log("JJ: verifyIdToken -> decode", decode)
            callback && callback(decode);
          // sub 就是用户的唯一标识, 服务器可以保存它用来检查用户有没用过apple pay login , 至于用户第一次Login时,服务器就默认开一个member 给用户, 还是见到没login 过就自己再通过app 返回到注册页面再接着注册流程, 最后再pass userId 到server 保存. 这个看公司需求.
        }
    });
};

app.get('/', function (req, res) {
 verifyIdToken('eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmlwcHVkby5haWdlbnMuaW9zIiwiZXhwIjoxNTc5MTcxNTA3LCJpYXQiOjE1NzkxNzA5MDcsInN1YiI6IjAwMTUzMi5iMTZlYWI3NGE5Y2M0ODYyYTQ0ODQ4MDk1MGQzNmVjMC4wOTAyIiwiY19oYXNoIjoidGZDVGFVRGlPVTVxaE9LNWRKYXFnUSIsImVtYWlsIjoiNjAxMzE1NTM4QHFxLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjoidHJ1ZSIsImF1dGhfdGltZSI6MTU3OTE3MDkwN30.ROqWg35ENqzonXyiBGTYVWCsBFbPD8o96vEQy_pzeXrem2Bsry9eGPl47Kst2aaqrKRPb9KhxXpOR02dHMQubRC_6Dj1zthapyKyAYjusqLGl9S2bl03hYKJrh_JB4Cnar71gksA8nMvsDukFfxYITDtmX51iAQVzKhdqrk1mwc4XjnjQUk0opk6uiarRi2quJZ8CS9vHxOHAoOTeWZvWMdiesaLf4zItdPCvBckmsFyq2YLYiv9sFXGVhE1IUc6jrKA7KiuWY52OlLCBLlQb2sQ2mpePqhkb7SpIPuRdsAUVNMz4nFxS2f863TLLdY-f2NDUQOJdXwYJe-piAPfVw');
 res.send('Hello World');
})

app.listen(3000)

后端Java 验证 参考于此

// 1. 还是要获取苹果的公钥, 再将 n / e  , 丢到这个方法
// 
#获取验证所需的PublicKey
public PublicKey getPublicKey(String n,String e)throws NoSuchAlgorithmException, InvalidKeySpecException {
         BigInteger bigIntModulus = new BigInteger(1,Base64.decodeBase64(n));
         BigInteger bigIntPrivateExponent = new BigInteger(1,Base64.decodeBase64(e));
        RSAPublicKeySpec keySpec = new RSAPublicKeySpec(bigIntModulus, bigIntPrivateExponent);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey publicKey = keyFactory.generatePublic(keySpec);
        return publicKey;
 }

/*
key: 通过上面的方法获取
jwt: identityToken
audience: app bundle id
subject: userId
*/
public int verify(PublicKey key, String jwt, String audience, String subject) {                      
    JwtParser jwtParser = Jwts.parser().setSigningKey(key);              
    jwtParser.requireIssuer("https://appleid.apple.com");        
    jwtParser.requireAudience(audience);
    jwtParser.requireSubject(subject); 
    try {
       Jws<Claims> claim = jwtParser.parseClaimsJws(jwt);
       if (claim != null && claim.getBody().containsKey("auth_time")) {  
          return GlobalCode.SUCCESS;            
       }           
       return GlobalCode.THIRD_AUTH_CODE_INVALID;
    } catch (ExpiredJwtException e) { 
       log.error("apple identityToken expired", e);
       return GlobalCode.THIRD_AUTH_CODE_INVALID;
    } catch (Exception e) {
       log.error("apple identityToken illegal", e);
    }
}


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

推荐阅读更多精彩内容