jwt应用之AppleId登录和服务端苹果验证

背景

之前有介绍过jwt(JSON Web Token),感兴趣的可以查看。本期以实际的AppleId登录集成为例,介绍下jwt的应用,对于需要集成AppleId登录的开发者也有一定帮助。目前(2020年11月),苹果已要求在APP提交审核时,如果要集成第三方比如微信等登录,必须以集成AppleId登录为前提。我们在业务场景中就遇到类似情况,网上很多文章描述了对于客户端的请求验证,比较少涉及向icloud认证,这篇文章将两方面都有提到,按照顺序进行,希望能给读者带来帮助。

准备工作

项目服务端使用java开发,所以首先引入依赖:

 <dependency>
          <groupId>io.jsonwebtoken</groupId>
          <artifactId>jjwt-api</artifactId>
          <version>0.11.2</version>
      </dependency>
      <dependency>
          <groupId>io.jsonwebtoken</groupId>
          <artifactId>jjwt-impl</artifactId>
          <version>0.11.2</version>
      </dependency>
      <!--使用jackson, gson反序列化int/long有问题-->
      <dependency>
          <groupId>io.jsonwebtoken</groupId>
          <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
          <version>0.11.2</version>
      </dependency>
  1. 注意这里序列化还是使用jjwt-jackson。本来项目中使用gson,想统一,但是遇到jjwt-gson,序列化intdouble,如16838383E9等格式,导致验证失败的问题。
  2. 对于项目中使用的各种私钥,id,项目id等,需要在苹果开发者后台查看。

AppleId登录

流程描述

实际的可以参考苹果官网的示意图:


verify user

简单描述下。首先,客户使用SDK访问苹果服务,获取到用户信息。主要会得到一个identityToken(jwt)和code,其中code5分钟有效。然后访问服务端,服务端第一步验证客户端传入的identityToken是否有效。如果失败,则返回客户端;如果有效,再构造请求,带着code访问苹果服务,验证请求的合法性。下面详细描述下具体的实现。

获取苹果公钥

首先根据苹果开放的公钥构造PublicKey,这个key基本上是不变的,可以保存到本地,或者加到缓存里。具体作用是验证客户端传入的identityToken。这里我加了个缓存,仍然请求苹果公钥地址来生成。

  1. 公钥地址

https://appleid.apple.com/auth/keys

  1. 公钥内容示例
    实际内容在公钥地址可以取到,包括kid,算法名称,模数,指数等。
{
    "keys": [
        {
            "kty": "RSA",
            "kid": "86D88Kf",
            "use": "sig",
            "alg": "RS256",
            "n": "iGaLq...",
            "e": "AQAB"
        },
        {
            "kty": "RSA",
            "kid": "eXaunmL",
            "use": "sig",
            "alg": "RS256",
            "n": "4dGQ7bQK..."
            "e": "AQAB"
        }
    ]
}

生成公钥代码:


//依赖引入
import io.jsonwebtoken.*;
import io.jsonwebtoken.impl.DefaultJwtBuilder;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.joda.time.Days;
import org.joda.time.Hours;
import org.springframework.util.Assert;

import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.EncodedKeySpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

  //常量地址
   private static final String APPLE_HOST_URL = "https://appleid.apple.com";
    private static final String APPLE_PUB_KEY_ENDPOINT = "https://appleid.apple.com/auth/keys";
    private static final String APPLE_AUTH_TOKEN_ENDPOINT = "https://appleid.apple.com/auth/token";
    
    private static final LoadingCache<String, PublicKey> cache =
            CacheBuilder.newBuilder().refreshAfterWrite(Hours.SIX.getHours(), TimeUnit.HOURS).build(new CacheLoader<String, PublicKey>() {
                @Override
                public PublicKey load(String key) throws Exception {
                    ApplePublicKey applePublicKey = getApplePublicKey().getByKid(key);
                    if (null == applePublicKey) {
                        return null;
                    }
                    return getRSAPublicKey(applePublicKey.getN(), applePublicKey.getE());
                }
            });

    //请求苹果公钥地址,生成自定义的描述对象
  private static ApplePubKeys getApplePublicKey() {
        String resp = HttpClientUtil.sendHttpGetRequest("https://appleid.apple.com/auth/keys");
        ApplePubKeys keys = new Gson().fromJson(resp, ApplePubKeys.class);
        return keys;
    }
    
    //根据公钥的参数生成RSA PublicKey
   /**
     * @param modulus  模数 n
     * @param exponent 指数 e
     * @return
     */
    private static PublicKey getRSAPublicKey(String modulus, String exponent) {
        try {
            BigInteger bigModule = new BigInteger(1, Base64.decodeBase64(modulus));
            BigInteger bigExponent = new BigInteger(1, Base64.decodeBase64(exponent));
            RSAPublicKeySpec keySpec = new RSAPublicKeySpec(bigModule, bigExponent);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(keySpec);
            return publicKey;
        } catch (Exception e) {
            return null;
        }
    }
    

参数对象定义

    //对应客户端返回的identityToken解析对象
    @Data
    public static class AppleIdentityToken {
        String aud;
        String sub;
        String c_hash;
        boolean email_verified;
        long auth_time;
        String iss;
        long exp;
        long iat;
        String email;
        AppleIdentityToken.Header header;

        @Data
        public static class Header {
            String alg;
            String kid;
        }
    }

   //公钥对象
    @Data
    static class ApplePubKeys {
        List<ApplePublicKey> keys;

        //通过kid获取key
        public ApplePublicKey getByKid(String kid) {
            return keys.stream().filter(e -> e.getKid().equals(kid)).findFirst().orElse(null);
        }
    }

    //公钥实体
    @Data
    public static class ApplePublicKey {
        String kty;
        String kid;
        String use;
        String alg;
        String n;
        String e;
    }

验证客户端请求

服务端验证客户端请求主要是验证传入的identityTokenjwt是否有效。使用引入的依赖jjwt工具进行验证。
首先将identityToken依据jwt格式解析出来,生成对象。然后根据header中指定的RSA公钥id, 获取对应公钥验证是否有效。包括一些固定参数,是否过期等,可以自己定制修改校验逻辑。如果校验通过,则返回sub字段,这个含义类似于openId,可以作为App下的用户标识。

注意很多文章只描述了这部分内容,目前只是对客户端的验证请求的内容校验,核心主要是依据公钥对identityToken的校验。这个jwt的校验没有网络交互,因此也没有涉及到服务端和苹果服务交互的逻辑。

  1. identityToken解析结果示例
    其中的sub即可以认为是openId,作为用户标识。
//header
{
  "kid": "86D88Kf",
  "alg": "RS256"
}
//payload
{
  "iss": "https://appleid.apple.com",
  "aud": "com.project.myApp",
  "exp": 1605601331,
  "iat": 1605514931,
  "sub": "000841.68aec4b5ad874e2196643fffffff7ee9.1020",
  "c_hash": "8c4qj4N9pjaXtUWV2-jF2g",
  "email": "abcdefghijk@privaterelay.appleid.com",
  "email_verified": "true",
  "is_private_email": "true",
  "auth_time": 1605514931,
  "nonce_supported": true
}
  1. 参考代码如下:

    //解析jwt
    private static AppleIdentityToken getAppleIdentityToken(String jwt) {
        String[] arr = jwt.split("\\.");
        String tokenBase64 = arr[1];
        String headerBase64 = arr[0];
        String token = new String(Base64.decodeBase64(tokenBase64), StandardCharsets.UTF_8);
        String header = new String(Base64.decodeBase64(headerBase64), StandardCharsets.UTF_8);
        AppleIdentityToken.Header tokenHeader = new Gson().fromJson(header, AppleIdentityToken.Header.class);
        AppleIdentityToken identityToken = new Gson().fromJson(token, AppleIdentityToken.class);
        identityToken.setHeader(tokenHeader);
        return identityToken;
    }
    /**
     * 验证客户端identityToken参数
     *
     * @param jwt
     * @return
     */
    public static Pair<Boolean, String> verify(String jwt) {
        AppleIdentityToken identityToken = null;
        try {
            identityToken = getAppleIdentityToken(jwt);
            PublicKey publicKey = cache.getUnchecked(identityToken.getHeader().getKid());
            if (null == publicKey) {
                return Pair.of(false, "系统异常");
            }
            JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(publicKey)
                    .requireAudience("com.project.myApp") //一般是项目包名称
                    .requireIssuer("https://appleid.apple.com") //固定值
                    .require("auth_time", identityToken.getIat()) //这里做了个简单的验证,如果auth_time == iat则是有效的。
                    .build();
            Jws<Claims> claimsJws = jwtParser.parseClaimsJws(jwt);
            Claims claims = claimsJws.getBody();
            //验证是否过期,
            if (!claims.getExpiration().before(new Date()) && StringUtils.isNotBlank(identityToken.getSub())) {
                log.error("ios verify fail. exp:{}", claims.getExpiration());
                return Pair.of(true, identityToken.getSub());
            }
        } catch (Exception e) {
            log.error("verify jwt error token:{}.", identityToken, e);
        }
        return Pair.of(false, "验证失败");
    }

与Apple服务验证用户

在以上对客户内容的校验通过后,继续将传入的code传递给苹果服务做校验。注意code只5分钟有效,且只能有效验证一次

描述下流程,这里会用到苹果给app的私钥,可以在开发者后台下载,是一个.p8的文件,里边是文本,读取后构建成用于椭圆曲线加密的私钥(ES256)。过程是利用私钥给包名,teamId等签名,然后和code构造请求,发送给苹果服务,获取验证结果。验证成功的返回中有个id_token字段,可以解析jwt获得需要的内容,包括sub的用户标识,当然也可以用返回头部的公钥id,再次使用公钥验证苹果的请求是否有效。这里我没有处理,

详细参见代码注释。

  1. 请求地址

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

  1. 请求成功返回
{
    "access_token": "a2b23033d2558470fb39b542904fd1762.0.rsqtt.F3A6KMAYu2XxDVVEoWSOyg",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "rbf69049aac814a9493f2c391cea17481.0.rsqtt.axQF5QaifTcrmmrhk9HhXw",
    "id_token": "eyJraW...."
}
  1. 请求失败返回

失败包含几种情况,举例说明

{"error": "invalid_request"} //缺失必要参数
{"error": "invalid_client"} //一般是参数错误

{"error": "invalid_grant"}  //校验失败,比如code过期
  1. 参考代码如下:
    //验证code和openId是否有效,openId对应sub。
    public static boolean authorize(String openId, String code) {
        String clientId = "com.project.myApp";
        boolean result = false;
        try {
            String clientSecret = buildJwt("teamId", clientId, "privite_kid"); //teamId, 私钥kid都是10个字符长度,在开发者后台获取。
            TokenResponse response = authorizeToken(clientId, clientSecret, code, GrantType.AuthorizationCode.getValue(), "", "");
            if (StringUtils.isNotBlank(response.getError())) {
                log.error("get access token from apple error, msg:{}.", response.getError());
            } else {
                String idToken = response.getId_token();
                AppleIdentityToken identityToken = getAppleIdentityToken(idToken);
                //这里只判断苹果返回的sub是否和openId相等,以及返回的aud是否和clientId相等。
                result = openId.equals(identityToken.getSub()) && identityToken.getAud().equals(clientId);
            }
        } catch (Exception e) {
            log.error("authorize code with apple error, openId:{}", openId, e);
        }
        if (!result) {
            log.error("authorize code with apple failed, openId:{}", openId);
        }
        return result;
    }
    
 //创建私钥,读取文件获取
  private static Key getPrivateKey() {
        try (InputStream in = AppleVerifyUtil.class.getClassLoader().getResourceAsStream("private.p8")) {
            String data = IOUtils.toString(in, StandardCharsets.UTF_8);
            List<String> lines = Arrays.stream(data.split("\n")).collect(Collectors.toList());
            StringBuilder keyValue = new StringBuilder();
            for (String s : lines) {
                if (s.startsWith("---")) {
                    continue;
                }
                keyValue.append(s);
            }
            //类型注意是椭圆曲线EC
            KeyFactory factory = KeyFactory.getInstance("EC");
            EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.decodeBase64(keyValue.toString().replaceAll("\\n", "")));
            PrivateKey privateKey = factory.generatePrivate(keySpec);
            return privateKey;
        } catch (Exception e) {
            return null;
        }
    }
    
    //请求苹果服务
    //clientId 一般是包名
    //clientSecret 是私钥签名生成的jwt字符串
    //code是客户端传入的
    //grantType 有 authorization_code 和 refresh_token,这里只用到authorization_code
    //refreshToken 和 redirectUri 这里留空
    public static TokenResponse authorizeToken(String clientId, String clientSecret, String code, String grantType, String refreshToken, String redirectUri) {
        try {
            Map<String, Object> postForm = new HashMap<>();
            postForm.put("client_id", clientId);
            postForm.put("client_secret", clientSecret);
            postForm.put("code", code);
            postForm.put("grant_type", grantType);
            postForm.put("refresh_token", refreshToken);
            postForm.put("redirect_uri", redirectUri);
            Map<String, Object> headers = new HashMap<>();
            headers.put("content-type", "application/x-www-form-urlencoded");
            //请求http client 可以自己创建
            String resp = HttpClientUtil.sendHttpPostRequest("https://appleid.apple.com/auth/token", headers, postForm);
            if (StringUtils.isNotBlank(resp)) {
                return new Gson().fromJson(resp, TokenResponse.class);
            }
        } catch (Exception e) {
            log.error("retrieve access token error.", e);
        }
        return new TokenResponse();
    }

    /**
     * 私钥加密后给苹果去验证,构造clientSecret,就是构造一个jwt字符串
     *
     * @return
     */
    public static String buildJwt(String iss, String sub, String kid) {
        Map<String, Object> header = new HashMap<>();
        header.put("alg", SignatureAlgorithm.ES256.getValue()); //SHA256withECDSA
        header.put("kid", kid);

        long iat = System.currentTimeMillis() / 1000; //以秒为单位
        Map<String, Object> claims = new HashMap<>();
        claims.put("iss", iss);
        claims.put("iat", iat);
        claims.put("exp", iat + Days.SEVEN.toStandardSeconds().getSeconds()); //设置为7天过期
        claims.put("aud", "https://appleid.apple.com"); //固定值
        claims.put("sub", sub);
        return new DefaultJwtBuilder().setHeader(header).setClaims(claims).signWith(privateKey, SignatureAlgorithm.ES256).compact();
    }

    //验证的两种请求类型
    @AllArgsConstructor
    @Getter
    public enum GrantType {
        AuthorizationCode("authorization_code"),
        RefreshToken("refresh_token"),;  //刷新token,这里没有用到,用来刷新accessToken。 貌似苹果限制了一天只能刷新一次。
        String value;
    }

    //成功返回类型,注意将失败返回的error也放到同一个对象里,方便转换处理。
    @Data
    public static class TokenResponse {
        //(Reserved for future use) A token used to access allowed data. Currently, no data set has been defined for access.
        String access_token;
        //The amount of time, in seconds, before the access token expires.
        long expires_in;  //过期时间,一般是3600,表示1小时,不是时间戳。
        //A JSON Web Token that contains the user’s identity information.
        String id_token; //返回的是一个jwt字符,可以解析,获得相应的数据。
        //The refresh token used to regenerate new access tokens. Store this token securely on your server.
        String refresh_token;
        //The type of access token. It will always be bearer.
        String token_type; //Bearer 固定值

        //see ErrorResponse
        /**
         * A string that describes the reason for the unsuccessful request. The string consists of a single allowed value.
         * Possible values: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope
         */
        String error;
    }

总结

以上就是本期的内容,目前实现了业务需求的功能。其中几个点还可以进一步根据工程需要做优化,比如公钥、私钥的生成和使用,以及补充一些安全措施;还有包括一些异常请求下的状态处理,以及redis替换LocalCache的可行性考虑等。感谢阅读。

参考资料

  1. jwt.io在线解析验证jwt
  2. Sign in with Apple REST API
  3. jwt信息校验介绍
  4. Sign in with Apple-苹果登录(客户端和服务端)
  5. Sign in with Apple(苹果授权登陆)服务端验证
  6. 苹果第三方登录Sign in with Apple服务端验证-非常详细
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容