Sign in with Apple

1、前言

“通过 Apple 登录”让用户能用自己的 Apple ID 轻松登录您的 app 和网站。用户不必填写表单、验证电子邮件地址和选择新密码,就可以使用“通过 Apple 登录”设置帐户并立即开始使用您的 app。所有帐户都通过双重认证受到保护,具有极高的安全性,Apple 亦不会跟踪用户在您的 app 或网站中的活动。

2、客户端

在xcode工程中添加Sign in with Apple

2.1 在项目属性找到Signing & Capabilities,选择添加Capability

2.2 在弹出的添加页面,输入sign找到Sign in with Apple

2.3 将Sign in with Apple添加成功

2.4 添加AuthenticationServices.Framework

2.5 然后在登录按钮点击事件调用代码登录

-(void)LoginApple{
//
    if (@available(iOS 13.0, *)) {
        ASAuthorizationAppleIDProvider *appleIDProvider = [[ASAuthorizationAppleIDProvider alloc] init];
        ASAuthorizationAppleIDRequest *appleIDRequest = [appleIDProvider createRequest];
        appleIDRequest.requestedScopes = @[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail];
        ASAuthorizationController *authorizationController = [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[appleIDRequest]];
        authorizationController.delegate = appController;
        authorizationController.presentationContextProvider = appController;
        [authorizationController performRequests];
    }else{
        NSLog(@"error");
    }
}

2.6 回调事件

在回调类中添加 <ASAuthorizationControllerDelegate,ASAuthorizationControllerPresentationContextProviding>

/// 授权成功回调
- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithAuthorization:(ASAuthorization *)authorization API_AVAILABLE(ios(13.0)){
    if ([authorization.credential isKindOfClass:[ASAuthorizationAppleIDCredential class]]) {
        ASAuthorizationAppleIDCredential *appleIDCredential = authorization.credential;
        NSData *identityToken = appleIDCredential.identityToken;

        // 注意:使用过授权的,可能获取不到以下三个参数,要做判空处理
        NSString *nickname = appleIDCredential.fullName.nickname;
        if(nickname == nullptr ){
            if(appleIDCredential.fullName.familyName != nullptr &&  appleIDCredential.fullName.givenName != nullptr){
                nickname = [NSString stringWithFormat:@"%@%@",appleIDCredential.fullName.familyName, appleIDCredential.fullName.givenName];
            }else if(appleIDCredential.fullName.familyName != nullptr){
                nickname = appleIDCredential.fullName.familyName;
            }else if(appleIDCredential.fullName.givenName != nullptr){
                nickname = appleIDCredential.fullName.givenName;
            }
        }

        if(nickname == nullptr){
            nickname = @"";
        }

        // 服务器验证需要使用的参数
        NSString *identityTokenStr = [[NSString alloc] initWithData:identityToken encoding:NSUTF8StringEncoding];

        // TODO, 将用户名和 token 传给服务器做验证。
        // 注意: 非第一次授权,nickname可能为空。


    }else if ([authorization.credential isKindOfClass:[ASPasswordCredential class]]){
        // 这个获取的是iCloud记录的账号密码,需要输入框支持iOS 12 记录账号密码的新特性,如果不支持,可以忽略
        ASPasswordCredential *passwordCredential = authorization.credential;
        NSString *user = passwordCredential.user;
        NSString *password = passwordCredential.password;

    }else{
        NSLog(@"授权信息不符");
    }
}

/// 授权失败回调
- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithError:(NSError *)error API_AVAILABLE(ios(13.0)){
    NSLog(@"Handle error:%@", error);
    NSString *errorMsg = nil;
    switch (error.code) {
        case ASAuthorizationErrorCanceled:
            errorMsg = @"用户取消了授权请求";
            break;
        case ASAuthorizationErrorFailed:
            errorMsg = @"授权请求失败";
            break;
        case ASAuthorizationErrorInvalidResponse:
            errorMsg = @"授权请求响应无效";
            break;
        case ASAuthorizationErrorNotHandled:
            errorMsg = @"未能处理授权请求";
            break;
        case ASAuthorizationErrorUnknown:
            errorMsg = @"授权请求失败未知原因";
            break;
        default:
            break;
    }
    NSLog(@"errorMsg = %@", errorMsg);
}

/// 设置展示内容给用户的Window
- (ASPresentationAnchor)presentationAnchorForAuthorizationController:(ASAuthorizationController *)controller API_AVAILABLE(ios(13.0)){
    return [UIApplication sharedApplication].windows.lastObject;
}

3、服务端

服务端使用客户端登录成功后获取的identityTokenStr值来做验证。identityTokenStr是基于JWT的算法验证。

eyJraWQiOiJZdXlYb1kiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmh6ZGNtLnl5eWxjIiwiZXhwIjoxNjQ5ODg2NDc1LCJpYXQiOjE2NDk4MDAwNzUsInN1YiI6IjAwMTE5NC4zZjExMjE3OGFkYTI0NDU2YjllYTRjMDBhMTZlZmIyYS4yMTQ3IiwiY19oYXNoIjoiekQ0THpRelYxTjY3TmdlX3ZsTjhTQSIsImVtYWlsIjoibXFoNnF0a2s4eUBwcml2YXRlcmVsYXkuYXBwbGVpZC5jb20iLCJlbWFpbF92ZXJpZmllZCI6InRydWUiLCJpc19wcml2YXRlX2VtYWlsIjoidHJ1ZSIsImF1dGhfdGltZSI6MTY0OTgwMDA3NSwibm9uY2Vfc3VwcG9ydGVkIjp0cnVlfQ.zmnDnSxuo9m95ly3tT_ZqO4ZKoRnYBFdgMWGwclbWvSim7pDKbnabvBP8F2pZ_A2O1KbsQyvzSRge6mDAJbFfuyTF2rC1nI2Ghm4oAl1_hZ39UskaM30L0exFrSjltTdpidnlho1SZNFauZy3IOh84IhyKrvXp7QbzmDXZI8_eiXBu7L50S7uEqTUWv3k7V-jUK6etOWg-7uuOadLAyQIuKx-MQ-Pr44IlHRw76abmaZzwWewc0CDGtmZBSlL3VdEdHbetl8FVlDRCUp-kQz0f6K4KKfDOEQNCU0JhIgEemlMX0q-0HW81GBuyPylCvGPMkDeIwP19oSxpiJMHkb1A

JWT格式(以.点号分隔):

  • header: 包括了key id 与加密算法

  • payload:
      1. iss: 签发机构,苹果
      2. aud: 接收者,目标app
      3. exp: 过期时间
      4. iat: 签发时间
      5. sub: 用户id
      6. c_hash: 一个哈希数列
      7. auth_time: 签名时间

  • signature: 用于验证JWT的签名

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

payload

{
  "iss": "https://appleid.apple.com",
  "aud": "com.hzdcm.yyylc",
  "exp": 1649886475,
  "iat": 1649800075,
  "sub": "001194.3f112178ada24456b9ea4c00a16efb2a.2147",
  "c_hash": "zD4LzQzV1N67Nge_vlN8SA",
  "email": "mqh6qtkk8y@privaterelay.appleid.com",
  "email_verified": "true",
  "is_private_email": "true",
  "auth_time": 1649800075,
  "nonce_supported": true
}

token验证原理

因为identityToken使用非对称加密 RSASSA【RSA签名算法】 和 ECDSA【椭圆曲线数据签名算法】,当验证签名的时候,先从https://appleid.apple.com/auth/keys获取公钥,再利用公钥来解密Singature,当解密内容与base64UrlEncode(header) + “.” + base64UrlEncode(payload)的内容完全一样的时候,表示验证通过。

Java服务器验证代码

package com.game.data.platform;

import com.arch.common.JsonUtil;
import com.arch.common.StringUtil;
import com.arch.log.Log;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import io.jsonwebtoken.*;
import org.apache.commons.codec.binary.Base64;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;

import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 苹果账号登录验证
 */
public class PlatformApple {
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class Keys{
        public String kid;
        public String use;
        public String alg;
        public String n;
        public String e;
    }

    // 缓存公钥
    public static Map<String,Keys> keysMap = new HashMap<>();

    // 验证Token
    public static boolean checkToken(String identityToken) {
        if (StringUtil.isNullOrEmpty(identityToken)) {
            return false;
        }

        try {
            String []jwt = identityToken.split("\\.");
            if (jwt.length != 3){
                return false;
            }

            String header  = new String(Base64.decodeBase64(jwt[0]),"utf-8");
            String token  = new String(Base64.decodeBase64(jwt[1]),"utf-8");

            JsonNode jsonNode = JsonUtil.readTree(header );
            Keys keys = getKeys(jsonNode.get("kid").asText(""), jsonNode.get("alg").asText(""));

            JsonNode wNode = JsonUtil.readTree(token );
            String audience = wNode.get("aud").asText("");
            String subject = wNode.get("sub").asText("");
            PublicKey publicKeySpec = build(keys.n, keys.e);
            int result = verify(publicKeySpec, token,audience, subject);
            if (result == 0){
                return true;
            }
        }catch (Exception e){
        }

        return false;
    }

    // 获取公钥
    public static Keys getKeys(String kid, String alg){
        String key = kid+"."+alg;
        Keys keys = keysMap.get(key);
        if (keys != null){
            return keys;
        }

        final String url = "https://appleid.apple.com/auth/keys";
        try{
            CloseableHttpClient httpclient = HttpClients.createDefault();
            HttpGet httpget = new HttpGet(url);
            try (CloseableHttpResponse response =  httpclient.execute(httpget)) {
                if (response.getStatusLine().getStatusCode() == 200) {
                    JsonNode result = JsonUtil.readTree(response.getEntity().getContent());
                    JsonNode ks = result.get("keys");
                    if (ks != null){
                        String kstr = ks.toString();
                        List<Keys> list = JsonUtil.deserialize(new TypeReference<List<Keys>>() {}, kstr);
                        for (Keys k : list){
                            keysMap.put(k.kid+"."+k.alg, k);
                        }

                        return keysMap.get(key);
                    }
                }
            }
        }catch (Exception e) {
            Log.error(e);
        }

        return null;
    }

    // 构建key
    public static PublicKey  build(String n, String e) throws NoSuchAlgorithmException, InvalidKeySpecException {
        BigInteger modulus = new BigInteger(1, Base64.decodeBase64(n));
        BigInteger publicExponent = new BigInteger(1, Base64.decodeBase64(e));
        RSAPublicKeySpec keySpec = new RSAPublicKeySpec(modulus, publicExponent);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey publicKey = keyFactory.generatePublic(keySpec);
        return publicKey;
    }

    // 验证
    public static 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 0;
            }
            return 1;
        } catch (ExpiredJwtException e) {
            Log.error("apple identityToken expired", e);
            return 2;
        } catch (Exception e) {
            Log.error("apple identityToken illegal", e);
            return 3;
        }
    }
}

然后,直接调用PlatformApple.checkToken(identityToken) 来验证

0%