美文网首页
jwt应用之AppleId登录和服务端苹果验证

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

作者: 西5d | 来源:发表于2020-11-18 17:42 被阅读0次

    背景

    之前有介绍过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服务端验证-非常详细

    相关文章

      网友评论

          本文标题:jwt应用之AppleId登录和服务端苹果验证

          本文链接:https://www.haomeiwen.com/subject/vpnciktx.html