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

1、苹果授权登陆方式

第一种: PC/M端授权登陆,采用协议类似于oauth2协议,服务端基于授权码验证

第二种: App端授权登陆,服务端基于JWT的算法验证

第一种方式的验证逻辑如下:

 

     ## 获取/刷新token
     - 首先获取code:GET
        https://appleid.apple.com/auth/authorize?response_type=code&client_id=&redirect_uri=&state=1234

       参考上面的后台配置,其中client_id对应的是Services ID,redirect_uri就是后台配置的接收code码的地址
     - 根据code获取token:POST
        https://appleid.apple.com/auth/token?grant_type=authorization_code&code=code&redirect_uri=url&client_id=id&client_secret=secret

     ## 获取令牌所需参数:
     1. grant_type:'authorization_code'为获取令牌
     2. client_id:client_id
     3. redirect_uri:redirect_uri
     4. code:上一步获取到的授权码
     5. codeclient_secret:secret(一个自己生成的jwt https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens)<br>
     返回值示例

{
"access_token": "a0996b16cfb674c0eb0d29194c880455b.0.nsww.5fi5MVC-i3AVNhddrNg7Qw",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "r9ee922f1c8b048208037f78cd7dfc91a.0.nsww.KlV2TeFlTr7YDdZ0KtvEQQ",
"id_token": "eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNreW1pbmcuYXBwbGVsb2dpbmRlbW8iLCJleHAiOjE1NjU2NjU1OTQsImlhdCI6MTU2NTY2NDk5NCwic3ViIjoiMDAwMjY2LmRiZTg2NWIwYWE3MjRlMWM4ODM5MDIwOWI5YzdkNjk1LjAyNTYiLCJhdF9oYXNoIjoiR0ZmODhlX1ptc0pqQ2VkZzJXem85ZyIsImF1dGhfdGltZSI6MTU2NTY2NDk2M30.J6XFWmbr0a1hkJszAKM2wevJF57yZt-MoyZNI9QF76dHfJvAmFO9_RP9-tz4pN4ua3BuSJpUbwzT2xFD_rBjsNWkU-ZhuSAONdAnCtK2Vbc2AYEH9n7lB2PnOE1mX5HwY-dI9dqS9AdU4S_CjzTGnvFqC9H5pt6LVoCF4N9dFfQnh2w7jQrjTic_JvbgJT5m7vLzRx-eRnlxQIifEsHDbudzi3yg7XC9OL9QBiTyHdCQvRdsyRLrewJT6QZmi6kEWrV9E21WPC6qJMsaIfGik44UgPOnNnjdxKPzxUAa-Lo1HAzvHcAX5i047T01ltqvHbtsJEZxAB6okmwco78JQA"
}


     ## 刷新令牌所需参数:
     1. grant_type:'refresh_token'为刷新令牌
     2. client_id:client_id
     3. client_secret:client_secret有效期最长6个月
     4. refresh_token:上一步获取到的id_token

     ## 对id_token解密
     - 通过 GET:https://appleid.apple.com/auth/keys 接口获取公钥

{
"keys": [
{
"kty": "RSA",
"kid": "86D88Kf",
"use": "sig",
"alg": "RS256",
"n": "iGaLqP6y-SJCCBq5Hv6pGDbG_SQ11MNjH7rWHcCFYz4hGwHC4lcSurTlV8u3avoVNM8jXevG1Iu1SY11qInqUvjJur--hghr1b56OPJu6H1iKulSxGjEIyDP6c5BdE1uwprYyr4IO9th8fOwCPygjLFrh44XEGbDIFeImwvBAGOhmMB2AD1n1KviyNsH0bEB7phQtiLk-ILjv1bORSRl8AK677-1T8isGfHKXGZ_ZGtStDe7Lu0Ihp8zoUt59kx2o9uWpROkzF56ypresiIl4WprClRCjz8x6cPZXU2qNWhu71TQvUFwvIvbkE1oYaJMb0jcOTmBRZA2QuYw-zHLwQ",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "eXaunmL",
"use": "sig",
"alg": "RS256",
"n": "4dGQ7bQK8LgILOdLsYzfZjkEAoQeVC_aqyc8GC6RX7dq_KvRAQAWPvkam8VQv4GK5T4ogklEKEvj5ISBamdDNq1n52TpxQwI2EqxSk7I9fKPKhRt4F8-2yETlYvye-2s6NeWJim0KBtOVrk0gWvEDgd6WOqJl_yt5WBISvILNyVg1qAAM8JeX6dRPosahRVDjA52G2X-Tip84wqwyRpUlq2ybzcLh3zyhCitBOebiRWDQfG26EH9lTlJhll-p_Dg8vAXxJLIJ4SNLcqgFeZe4OfHLgdzMvxXZJnPp_VgmkcpUdRotazKZumj6dBPcXI_XID4Z4Z3OM1KrZPJNdUhxw",
"e": "AQAB"
}
]
}

     - 然后我们用jwt.verify通过公钥解密id_token
     - 解密后得到的verify.sub就是用户apple账号登录在该程序中的唯一标识,我们可以把它存到程序的数据库中与用户信息做映射,用于标识用户身份

2、重点讲解苹果授权登陆服务端如何验证

2.1、基于授权码验证

首先需要了解如何构建client_secret,详细文档可以参考如下两个:
https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens

首先说下client_secret的构建方法:

先在后台生成授权应用APP ID的密钥KEY文件,然后下载密钥文件格式样例:
 
-----BEGIN PRIVATE KEY-----
   BASE64编码后的密钥
-----END PRIVATE KEY-----
 
public  byte[] readKey() throws Exception {
    String temp = "密钥文件中间的编码字符串";
    return Base64.decodeBase64(temp);
}
 
构建client_secret关键代码:
 
String client_id = "..."; // 被授权的APP ID
Map<String, Object> header = new HashMap<String, Object>();
header.put("kid", "密钥id"); // 参考后台配置
Map<String, Object> claims = new HashMap<String, Object>();
claims.put("iss", "team id"); // 参考后台配置 team id
long now = System.currentTimeMillis() / 1000;
claims.put("iat", now);
claims.put("exp", now + 86400 * 30); // 最长半年,单位秒
claims.put("aud", "https://appleid.apple.com"); // 默认值
claims.put("sub", client_id);
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(readKey());
KeyFactory keyFactory = KeyFactory.getInstance("EC");
PrivateKey privateKey = keyFactory.generatePrivate(pkcs8EncodedKeySpec);
String client_secret = Jwts.builder().setHeader(header).setClaims(claims).signWith(SignatureAlgorithm.ES256, privateKey).compact();         

如何验证?

String url = "https://appleid.apple.com/auth/token";
// POST 请求
HttpSynClient client = new HttpSynClient(5000, 5000, 5000, 20);
Map<String, String> form = new HashMap<String, String>();
form.put("client_id", client_id);
form.put("client_secret", client_secret);
form.put("code", code);
form.put("grant_type","authorization_code"); form.put("redirect_uri", redirectUrl); HttpResponse result = client.excutePost(url, form); System.out.println(result);

返回值样例:

{
"access_token":"a0996b16cfb674c0eb0d29194c880455b.0.nsww.5fi5MVC-i3AVNhddrNg7Qw",
"token_type":"Bearer",
"expires_in":3600,
"refresh_token":"r9ee922f1c8b048208037f78cd7dfc91a.0.nsww.KlV2TeFlTr7YDdZ0KtvEQQ",
"id_token":"eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNreW1pbmcuYXBwbGVsb2dpbmRlbW8iLCJleHAiOjE1NjU2NjU1OTQsImlhdCI6MTU2NTY2NDk5NCwic3ViIjoiMDAwMjY2LmRiZTg2NWIwYWE3MjRlMWM4ODM5MDIwOWI5YzdkNjk1LjAyNTYiLCJhdF9oYXNoIjoiR0ZmODhlX1ptc0pqQ2VkZzJXem85ZyIsImF1dGhfdGltZSI6MTU2NTY2NDk2M30.J6XFWmbr0a1hkJszAKM2wevJF57yZt-MoyZNI9QF76dHfJvAmFO9_RP9-tz4pN4ua3BuSJpUbwzT2xFD_rBjsNWkU-ZhuSAONdAnCtK2Vbc2AYEH9n7lB2PnOE1mX5HwY-dI9dqS9AdU4S_CjzTGnvFqC9H5pt6LVoCF4N9dFfQnh2w7jQrjTic_JvbgJT5m7vLzRx-eRnlxQIifEsHDbudzi3yg7XC9OL9QBiTyHdCQvRdsyRLrewJT6QZmi6kEWrV9E21WPC6qJMsaIfGik44UgPOnNnjdxKPzxUAa-Lo1HAzvHcAX5i047T01ltqvHbtsJEZxAB6okmwco78JQA"
}

其中id_token是一个JWT,其中claims中的sub就是授权的用户唯一标识,该token也可以使用上述的验证方法进行有效性验证,另外授权code是有时效性的,且使用一次即失效

2.2、基于JWT的算法验证

使用到的Apple公钥接口:https://appleid.apple.com/auth/keys
详细接口文档说明参见:https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature

前端提交identityToken,服务端通过https://appleid.apple.com/auth/keys获取publicKey(数组结构,上一个失败循环使用下一个),然后验证token(jwt验证),最后解析token获取用户信息(sub即openId)

 

IOS授权登录流程与微信授权登录大同小异,唯一区别的在于需要调用苹果api获取公钥,接口地址为:https://appleid.apple.com/auth/keys。
首先是IOS APP端拿到identifyToken交给后端,后端拿到identifyToken后,首先调用IOS的公钥API拿到IOS的公钥,这里会获取到两个公钥,然后使用公钥对identifyToken进行校验,校验通过后,对identityToken进行解码,解码后可以到授权的唯一标识sub,之后做业务侧的注册登录逻辑。
这里有一个坑,就是在校验identifyToken的时候,偶尔会校验不通过,原因是我们通过IOS的公钥API会拿到两个密钥,如果只拿其中一个去做校验,就会出现这种情况,所以当第一个密钥校验不通过的时候,再拿第二个密钥再做一次校验。

大致流程如下图:

 

代码实现
需要添加的依赖:

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>jwks-rsa</artifactId>
            <version>0.9.0</version>
        </dependency>

工具类实现

 

public class AppleUtil {

    private static final Logger logger = LoggerFactory.getLogger(AppleUtil.class);

    /**
     * 获取苹果的公钥
     * @return
     * @throws Exception
     */
    private static JSONArray getAuthKeys() throws Exception {
        String url = "https://appleid.apple.com/auth/keys";
        RestTemplate restTemplate = new RestTemplate();
        JSONObject json = restTemplate.getForObject(url,JSONObject.class);
        JSONArray arr = json.getJSONArray("keys");
        return arr;
    }

    public static Boolean verify(String jwt) throws  Exception{
        JSONArray arr = getAuthKeys();
        if(arr == null){
            return false;
        }
        JSONObject authKey = null;

        //先取苹果第一个key进行校验
        authKey = JSONObject.parseObject(arr.getString(0));
        if(verifyExc(jwt, authKey)){
            return true;
        }else{
            //再取第二个key校验
            authKey = JSONObject.parseObject(arr.getString(1));
            return verifyExc(jwt, authKey);
        }

    }

    /**
     * 对前端传来的identityToken进行验证
     * @param jwt 对应前端传来的 identityToken
     * @param authKey 苹果的公钥 authKey
     * @return
     * @throws Exception
     */
    public 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) {
            logger.error("apple identityToken expired", e);
            return false;
        } catch (Exception e) {
            logger.error("apple identityToken illegal", e);
            return false;
        }
    }



    /**
     * 对前端传来的JWT字符串identityToken的第二部分进行解码
     * 主要获取其中的aud和sub,aud大概对应ios前端的包名,sub大概对应当前用户的授权的openID
     * @param identityToken
     * @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"}
     */
    public static JSONObject parserIdentityToken(String identityToken){
        String[] arr = identityToken.split("\\.");
        Base64 base64 = new Base64();
        String decode = new String (base64.decodeBase64(arr[1]));
        String substring = decode.substring(0, decode.indexOf("}")+1);
        JSONObject jsonObject = JSON.parseObject(substring);
        return  jsonObject;
    }

}

业务侧注册登录,具体的注册登录逻辑这里不再详细介绍

 /**
     * iOS appleid 授权登录
     * @param identityToken
     * @param request
     * @return
     */
    @Transactional
    public ApiResult appleLogin(String identityToken,HttpServletRequest request){
        try {
            Map<String, String> map = new HashMap<String, String>();
            //验证identityToken
            if(!AppleUtil.verify(identityToken)){
                return new ApiResult(ApiCode.VALIDATION_ERROR, "授权验证失败");
            }
            //对identityToken解码
            JSONObject json = AppleUtil.parserIdentityToken(identityToken);
            if(json == null){
                return new ApiResult(ApiCode.VALIDATION_ERROR, "授权解码失败");
            }
            String ip = Servlets.getRemoteAddr(request);
            User user = oauthAppleService.apple_login(json, ip);
            //将用户信息存到redis并返回token, token有效期为31天
            String token = tokenRedisService.put(user);
            map.put("token",token);
            return new ApiResult(ApiCode.SUCCESS, "success", map);
        }catch (Exception e){
            logger.error("app wxLogin error:" + e.getMessage(),e);
            return new ApiResult(ApiCode.SYS_EXCEPTION, "系统错误");
        }

    }

 

链接

https://www.jianshu.com/p/0c12dc2a214e

https://cloud.tencent.com/developer/ask/sof/691914

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

https://blog.csdn.net/w_monster/article/details/124171787

https://blog.csdn.net/wpf199402076118/article/details/99677412

https://blog.csdn.net/cxh1299543968/article/details/106401444
https://www.cnblogs.com/javallh/p/14168996.html
 https://segmentfault.com/a/1190000020786994
posted @ 2022-06-06 11:46  zbjice  阅读(6933)  评论(0)    收藏  举报