日常bb

日常bb

Sign in with Apple(苹果授权登陆)

219
2022-12-29
Sign in with Apple(苹果授权登陆)

用户是懒的,有快捷登录的情况下,没人会去输入邮箱,接收验证码并输入验证码来完成注册。

明明有一步完成操作的方式,又有几个用户会选择用几步去完成呢。

国内的 APP 基本都会加上微信登录,同理换到海外,如果你的 APP 面向是海外的用户,除 Facebook 登录以外,Apple 登录也同样重要。

APP端

关于手机端登录的代码,这里不做多余介绍,我们看下登录成功后, Apple 返回给手机端的一些参数。

open var user: String { get }
open var state: String? { get }
open var authorizedScopes: [ASAuthorization.Scope] { get }
open var authorizationCode: Data? { get }
open var identityToken: Data? { get }
open var email: String? { get }
open var fullName: PersonNameComponents? { get }
open var realUserStatus: ASUserDetectionStatus

登录成功后,从 Apple 拿到 :user、email、fullName、authorizationCode、identityToken 。

  • userID:授权的用户唯一标识
  • email、fullName:授权的用户资料
  • authorizationCode:授权code。(授权code是有时效性的,且使用一次即失效)
  • identityToken:授权用户的JWT凭证

服务端

服务端校验有两种方式。

  1. 基于JWT的算法验证;
  2. 基于授权码的验证;

基于JWT的算法验证

APP上传userIDidentityToken,服务端解析identityToken获取用户唯一标识与userID进行对比。

服务端解析identityToken的步骤跟下面3、解码id_token是一样的。

基于授权码的验证

  1. 首先需要得到client_secret
  2. 根据APP上传上来的authorizationCode请求苹果服务器进行登录,获取id_token
  3. 解析id_token,获取用户唯一标识。

1、获取client_secret

参考:
https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

首先需要了解如何构建client_secret

client_secret 参数是一个 JWT,singature 部分使用非对称加密 RSASSA【RSA签名算法】 和 ECDSA【椭圆曲线数据签名算法】。

生成client_secret之前:

  1. 获取 APP 的 bundleId。
  2. 获取开发者账户的 TeamID。

获取APP的bundleId和TeamID

  1. 创建 privateKey,获取到 Key ID 和 私钥。

获取Key ID和私钥

  1. 创建完之后把私钥下载下来,并保存好,注意,私钥只能下载一次
  2. 生成client_secret。可以通过如下代码生成 client_secret ,代码为 Ruby 代码,确保已安装 Ruby 环境。
require "jwt"

key_file = "Path to the private key"
team_id = "Your Team ID"
client_id = "Your App Bundle ID"
key_id = "The Key ID of the private key"
validity_period = 180 # In days. Max 180 (6 months) according to Apple docs.

private_key = OpenSSL::PKey::EC.new IO.read key_file

token = JWT.encode(
    {
        iss: team_id,
        iat: Time.now.to_i,
        exp: Time.now.to_i + 86400 * validity_period,
        aud: "https://appleid.apple.com",
        sub: client_id
    },
    private_key,
    "ES256",
    header_fields=
    {
        kid: key_id 
    }
)
puts token

key_file:私钥路径。

  1. 创建文件secret_gen.rb,把上面代码粘贴进去,执行ruby secret_gen.rb即可生成client_secret

2、进行登录

服务器接收到 APP 提交的信息后,根据authorizationCode获取id_token,然后再获取苹果用户的唯一标识 userId。

获取token:https://appleid.apple.com/auth/token

所需参数:

client_id: string (Required) (Authorization and Validation) The application identifier for your app

client_secret: string (Required) (Authorization and Validation) A secret generated as a JSON Web Token that uses the secret key generated by the WWDR portal.

code: string (Authorization) The authorization code received from your application’s user agent. The code is single use only and valid for five minutes.

grant_type: string (Required) (Authorization and Validation) The grant type that determines how the client interacts with the server. For authorization code validation, use authorization_code. For refresh token validation requests, use refresh_token.

refresh_token: string (Validation) The refresh token received during the authorization request.

redirect_uri: string (Authorization) The destination URI the code was originally sent to.
  • client_id 为app的 bundle identifier。
  • code 为 APP 获取到的 authorizationCode。
  • grant_type 传固定字符串 authorization_code
  • client_secret 是前面生成的,需注意是有时效性的,半年过期。

发起请求,返回的结果为:

{
  "access_token": "一个token,此处省略",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "一个token,此处省略",
  "id_token": "结果是JWT,字符串形式,此处省略"
}

参数解释,官方文档:https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse

3、解码id_token

解码步骤:

  1. 先要获取Public Key
  2. 根据获取Public Key去进行解码。

获取 Public Key。https://appleid.apple.com/auth/keys
结果:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "AIDOPK1",
      "use": "sig",
      "alg": "RS256",
      "n": "lxrwmuYSAsTfn-lUu4goZSXBD9ackM9OJuwUVQHmbZo6GW4Fu_auUdN5zI7Y1dEDfgt7m7QXWbHuMD01HLnD4eRtY-RNwCWdjNfEaY_esUPY3OVMrNDI15Ns13xspWS3q-13kdGv9jHI28P87RvMpjz_JCpQ5IM44oSyRnYtVJO-320SB8E2Bw92pmrenbp67KRUzTEVfGU4-obP5RZ09OxvCr1io4KJvEOjDJuuoClF66AT72WymtoMdwzUmhINjR0XSqK6H0MdWsjw7ysyd_JhmqX5CAaT9Pgi0J8lU_pcl215oANqjy7Ob-VMhug9eGyxAWVfu_1u6QJKePlE-w",
      "e": "AQAB"
    }
  ]
}

获取到 Public Key 后,对id_token进行解析。

// claims 解码
{
    "iss":"https://appleid.apple.com",  // 苹果签发的标识
    "aud":"com.skyming.devicemonitor", // 接收者的APP ID
    "exp":1565668086,
    "iat":1565667486,
    "sub":"001247.93b3a799a7c84c0cb46cd08f100797f2.0704", //用户的唯一标识
    "c_hash":"Oh2am9eMNWVY3dq5JmClbg",
    "auth_time":1565667486
}
  • iss:苹果签发的标识;
  • aud:与你的 app 的 bundleID 一致;
  • sub:与手机端获得的 user 一致;

服务器端通过对比 sub 字段信息是否与手机端上传的 user 信息一致来确定是否成功登录。

源码

所需依赖:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

代码:

import cn.hutool.core.codec.Base64;
import cn.hutool.core.lang.Dict;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;

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

@Slf4j
public class AppleUtil {

    public static void main(String[] args) {
        String identityToken = "eyJraWQiOiJXNldjT0tCIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnVtZW94LnFpYmxhIiwiZXhwIjoxNjcyMzY5NDUwLCJpYXQiOjE2NzIyODMwNTAsInN1YiI6IjAwMTMwNy5mN2NkNWFkYmVlMDY0ZDc0YjI4ZjI2OWE3MmUzNjU4Mi4wNTMwIiwiY19oYXNoIjoiV3VBS1lycUhlcUxiSS12cnRLRkF4ZyIsImVtYWlsIjoiMjgwMDM1NzY4QHFxLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjoidHJ1ZSIsImF1dGhfdGltZSI6MTY3MjI4MzA1MCwibm9uY2Vfc3VwcG9ydGVkIjp0cnVlfQ.TUMsZXG8qjA2EjisrKnyksukDY0vbQnDVXsgl1y7hFrbOlu0pyUkm4FfJHB-OAg1FOKFA5Nn7zGLezgGbsgmmSfnP5piitZMtQkNEZ-5FNuXJ1KRHNJexQePUrEpKrUMTVLoi23WDPZOofNlYuyvRnnkQaO2jG4XZ94IHbwrOpbrPYfNNMF9oaRG2w8RV125TN2Bh2nLHP_Xm7Fd5RQDEJVNTBkSkIJ1xbHlsEemrcEd-SPDtp2jvAliiMNyK5FlsHgShuUeAeW2nmyrx0hUanAymjUUckCCOJsdawia7ps_uzdmjWvbDgvUtuIzZVf-kevWC_dqzwEcDjjlkG1R2Q";
        AppleUtil.test(identityToken);
    }

    public static void test(String identityToken) {
        String clientId = "com.xxx.xxxx";
        // 解析id_token
        Map<String, JSONObject> json = parserIdentityToken(identityToken);
        JSONObject header = json.get("header");
        String kid = header.getStr("kid");
        // 根据kid,获取相应的公钥
        PublicKey publicKey = getPublicKey(kid);
        if (publicKey == null) {
            log.error("[苹果登录失败 publicKey获取失败 clientId<{}> kid<{}>]", clientId, kid);
        }
        getAppleUserId(clientId, publicKey, identityToken);
    }

    private static Map<String, Object> getAppleUserId(String appleClientId, PublicKey publicKey, String identityToken) {
        String appleIssuerUrl = "https://appleid.apple.com";
        JwtParser jwtParser = Jwts.parser().requireIssuer(appleIssuerUrl).requireAudience(appleClientId).setSigningKey(publicKey);
        Jws<Claims> claim = jwtParser.parseClaimsJws(identityToken);
        if (claim != null) {
            Map<String, Object> result = claim.getBody();
            String iss = result.get("iss").toString();
            String sub = result.get("sub").toString();
            String aud = result.get("aud").toString();
            Long exp = Long.parseLong(result.get("exp").toString()) * 1000;
            log.info("[苹果登录iss<{}> sub<{}> aud<{}> exp<{}>]", iss, sub, aud, exp);
            if (appleIssuerUrl.equals(iss) && appleClientId.equals(aud)) {
                log.error("[苹果账号登录成功 appleClientId<{}> userId<{}>]", appleClientId, sub);
                return new Dict().set("userId", sub);
            }
        }
        return null;
    }

    /**
     * 解析id_token
     *
     * @param identityToken
     * @return
     */
    private static Map<String, JSONObject> parserIdentityToken(String identityToken) {
        Map<String, JSONObject> map = new HashMap<>(2);
        String[] arr = identityToken.split("\\.");
        String deHeader = Base64.decodeStr(arr[0]);
        JSONObject header = JSONUtil.parseObj(deHeader);
        String dePayload = Base64.decodeStr(arr[1]);
        JSONObject payload = JSONUtil.parseObj(dePayload);
        map.put("header", header);
        map.put("payload", payload);
        return map;
    }

    /**
     * 获取苹果的公钥
     *
     * @param kid
     * @return
     */
    private static PublicKey getPublicKey(String kid) {
        Map<String, Map<String, String>> map = getAppleKeys();
        try {
            String n = map.get(kid).get("n");
            String e = map.get(kid).get("e");
            BigInteger modulus = new BigInteger(1, Base64.decode(n));
            BigInteger publicExponent = new BigInteger(1, Base64.decode(e));
            RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, publicExponent);
            //目前kty均为 "RSA"
            KeyFactory kf = KeyFactory.getInstance("RSA");
            return kf.generatePublic(spec);
        } catch (Exception e) {
            System.err.println("getPublicKey error:{}" + e);
        }
        return null;
    }

    /**
     * 请求苹果公钥信息
     *
     * @return
     */
    private static Map<String, Map<String, String>> getAppleKeys() {
        Map<String, Map<String, String>> result = new HashMap<>(2);
        String keys = HttpUtil.get("https://appleid.apple.com/auth/keys");
        JSONObject keysObject = JSONUtil.parseObj(keys);
        JSONArray jsonArray = keysObject.getJSONArray("keys");
        for (int i = 0; i < jsonArray.size(); i++) {
            Map<String, String> m = new HashMap<>();
            m.put("n", jsonArray.getJSONObject(i).getStr("n"));
            m.put("e", jsonArray.getJSONObject(i).getStr("e"));
            result.put(jsonArray.getJSONObject(i).getStr("kid"), m);
        }
        return result;
    }

}

文章