背景

苹果公司要求所有使用第三方登录的 App,都必须接入Sign in with Apple。

接入方式
  1. 基于JWT identityToken的算法验证
  2. 基于授权码的验证
校验流程

苹果账号登录 授权码java 苹果id授权登录_json


上图为苹果对接官网的流程示意图,大致意思就是在苹果手机需要使用第三方APP时,服务端会有用户信息,带着用户信息服务端请求苹果服务,验证用户信息,苹果服务端用户信息验证通过后,意味着用户登录苹果账号成功,则允许使用第三方APP进行登录等操作。

使用JWT方式接入步骤

(通过查询资料,大多数成功案例都是通过JWT方式实现的,下面就记录下JWT接入方式的实现)

我们可以先通过下图了解整个流程,之后对应到代码就会有所共鸣。

苹果账号登录 授权码java 苹果id授权登录_java_02

1. Maven引入JWT相关包
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.8.3</version>
</dependency>
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>jwks-rsa</artifactId>
    <version>0.12.0</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>
2. 相关说明
  • UserId:与用户的 Apple Id 一一对应。在同一个开发帐号下的所有 app 里,获取到的值都一样。
  • IdentityToken:identityToken 是一个 Json Web Token (JWT)。它由点号 (".") 分割为三部分:header、payload、signature。前两部分是两个 Json 字符串经过 base64Url 编码的结果。第三部分是前面二者加密后再做 base64Url 编码得到的。

identityToken 示例:

eyJraWQiOiJlWGF1bm1MIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmRpeWl5aW4ub25saW5lNTMiLCJleHAiOjE1OTc2NTAxNzQsImlhdCI6MTU5NzY0OTU3NCwic3ViIjoiMDAxMzc3LmQ0ZDVmMTAwODQ0ZTQzZjdiMWM1OWRiMzUyZWZkZmI4LjAyNTkiLCJjX2hhc2giOiJkTDVRdld2VTNjVHBxczNSazlUTnRBIiwiZW1haWwiOiI0OTk4OTY1MDdAcXEuY29tIiwiZW1haWxfdmVyaWZpZWQiOiJ0cnVlIiwiYXV0aF90aW1lIjoxNTk3NjQ5NTc0LCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.hM9HjNsMJW2PjYP7SfbzF-GqOt0VnMjYGq4BoU68rkQ-K2lPp_ae5ziX6Bbr3WHg6cc3Z8OzGO63OfExvSj9gQTR596CZLvNGXhbI3piTK6597-cYsPCTbY7xHxgdHLuL8XhD-9dXPn9rouVYu4QA18JBQG1Q4sGsRzLEJ5DjOM9x1bkBz4Vu_5LEOefHFHkWN_RPCh_AOJGviDzm81kTkCTWn8jpm0tGdevMR93MOf44f7bjP2T8yezl0Vbv09TrnkdAqG0BsihCD0VN9JV7X2eagyumoxTdFfoRiOflFKAaQqohVzcqy9tHOGm_6w5h8bsRCmtBC4PnqIFqNy_AQ

前两部分解码后结果示例:

  • header
{
 kid: "86D88Kf",
 alg: "RS256"
}
  • payload
{
    "iss":"https://appleid.apple.com",
    "aud":"io.github.0xa6a",
    "exp":1581854624,
    "iat":1581854024,
    "sub":"001472.dab04f9ba9d34f03ad641fa82d1c598b.0945",
    "nonce":"a-random -string",
    "c_hash":"cZay7wqnmHuPcG6FhVDqZA",
    "email":"yf7a3ps8hm91@privaterelay.appleid.com",
    "email_verified":"true",
    "is_private_email":"true",
    "auth_time":1581854024
}

字段名

说明

iss

签发机构网址

aud

bundle id

exp

int 过期时间戳

iat

签发时间

sub

user id

nouce

客户端发出请求时携带的随机串,用于对照

c_hash

一段哈希

email

email

email_verified

email 是否确认了

is_private_email

是否为 private email

auth_time

email 授权时间

3. 核心代码
  • 解析前端传的identityToken
/**
  * 对前端传来的JWT字符串identityToken的第二部分进行解码
  * 主要获取其中的aud和sub,aud大概对应ios前端的包名,sub大概对应当前用户的授权的openID
  *
  * @param identityToken 身份token
  * @return {"aud":"com.xkj.****","sub":"000***.8da764d3f9e34d2183e8da08a1057***.0***","c_hash":"UsKAuEoI-****","email_verified":"true","auth_time":1574673481,"iss":"https://appleid.apple.com","exp":1574674081,"iat":1574673481,"email":"****@qq.com"}
  */
 private JSONObject parserIdentityToken(String identityToken) {
     String[] arr = identityToken.split("\\.");
     String decode = new String(Base64.decodeBase64(arr[1]));
     String substring = decode.substring(0, decode.indexOf("}") + 1);
     return JSON.parseObject(substring);
 }
  • 获取苹果公钥
/**
  * 获取苹果的公钥
  *
  * @return
  */
 private static JSONArray getAuthKeys() {
     String url = "https://appleid.apple.com/auth/keys";
     RestTemplate restTemplate = new RestTemplate();
     JSONObject json = restTemplate.getForObject(url, JSONObject.class);
     if (json != null) {
         return json.getJSONArray("keys");
     }
     return null;
 }
  • 将公钥与前端传的identityToken进行校验
/**
 * 对前端传来的identityToken进行验证
 *
 * @param jwt     对应前端传来的 identityToken
 * @param authKey 苹果的公钥 authKey
 * @return
 * @throws Exception
 */
private static Boolean verifyExc(String jwt, JSONObject authKey) throws Exception {

    Jwk jwa = Jwk.fromValues(authKey);
    PublicKey publicKey = jwa.getPublicKey();

    String aud = "";
    String sub = "";
    if (jwt.split("\\.").length > 1) {
        String claim = new String(Base64.decodeBase64(jwt.split("\\.")[1]));
        aud = JSONObject.parseObject(claim).get("aud").toString();
        sub = JSONObject.parseObject(claim).get("sub").toString();
    }
    JwtParser jwtParser = Jwts.parser().setSigningKey(publicKey);
    jwtParser.requireIssuer("https://appleid.apple.com");
    jwtParser.requireAudience(aud);
    jwtParser.requireSubject(sub);

    try {
        Jws<Claims> claim = jwtParser.parseClaimsJws(jwt);
        if (claim != null && claim.getBody().containsKey("auth_time")) {
            System.out.println(claim);
            return true;
        }
        return false;
    } catch (ExpiredJwtException e) {
        log.error("apple identityToken expired", e);
        return false;
    } catch (Exception e) {
        log.error("apple identityToken illegal", e);
        return false;
    }
}
4. 遇到的问题
  • 解析前端传的identityToken方法出错

这个问题主要是IOS传的identityToken有问题,后端拿到的identityToken一定是由(“.”)号分割成三部分的字符串,而前端刚开始传的是不带点号的,想到后端有Base64解码编码的代码,就拿着字符串用在线工具解码试了下,发现问题是IOS传的是编码后的identityToken,解码后得到的字符串就是没问题的了。

  • 后端identityToken与苹果返回公钥做校验出错,具体错误如下:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

这个问题是将identityToken和苹果返回的公钥做校验不通过,苹果返回的公钥是一个数组,有两个公钥,我们不能只取一个做校验,应该判断第一个校验不通过还需要拿第二个继续做校验。

  • 后端identityToken过期错误,具体错误如下:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2020-08-17T15:42:54Z. Current time: 2020-08-21T17:14:24Z, a difference of 351090141 milliseconds.  Allowed clock skew: 0 milliseconds.

IOS端给的identityToken是有过期时间的,苹果默认是5分钟,过期后需要前端重新获取一个identityToken.

总结

测试通过的校验代码已上传github:接入苹果授权登录验证。验证可以说是单独的部分,具体登录逻辑还需要结合自身项目去加以改造。