Apple登录流程与实战

概述

现在在职的公司有一款iOS App,其登录方式有3种,如下截图所示:
在这里插入图片描述
一般情况下,iOS App都只有一种手机号登录方式。登录方式增加微信登录,即在登录时跳转到微信。

iOS App上如果有接第三方登陆(如微信,微博,Facebook等),则必须要接入AppleId登录,否则iOS上线提交审核无法通过。这是苹果的要求之一,目的在于提供给用户更多的选择,并加强用户的隐私保护。

通过Apple登录,有很详细的官方文档

储备

Apple登陆的时序图:
在这里插入图片描述
流程大概可以描述为:

  • iOS App请求通过Apple进行第三方登录时,客户端和苹果服务器(Apple Server)通信,获得包括用户唯一凭证UserID(类似于微信的OpenId),用户全名Full Name,验证用的IdentityCode或IdentityToken
  • 客户端将获得的数据发送给服务器,由服务器通过IdentityCode或IdentityToken来验证此次登录是否有效
  • 验证通过,服务端处理完自己内部的登录流程后,将对应的登录结果返回给客户端

具体来说:
在第二步服务器的验证过程中,服务端可选择Code或Token中的任意一种进行验证。

  • identityToken:其实就是一个JWT。JWT的校验,有很多现成的三方jar包可以实现。验证JWT的签名,保证数据没有被篡改之后,还要校验从identityToken decode出来的nonce,iss,aud,exp等信息,主要是iss和exp;
  • IdentityCode:根据Apple官方文档,通过Code验证需要Apple开发者对该App进行配置的额外client_id, client_secret, redirect_uri三个参数。

准备

开发Apple登录功能前需要一些准备工作:

实战

identityToken

公钥接口

需用到Apple公钥接口:https://appleid.apple.com/auth/keys,参考接口文档

GET请求Apple Server地址 https://appleid.apple.com/auth/keys,得到的响应数据如下(省略部分key,仅保留一个做示意用):

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "fh6Bs8C",
      "use": "sig",
      "alg": "RS256",
      "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
      "e": "AQAB"
    }
  ]
}

响应体解释:

  • kid:密钥id标识
  • alg:签名算法采用的是RS256(RSA 256 + SHA 256)
  • kty:常量标识使用RSA签名算法
  • n/e:公钥参数,其值采用BASE64编码,使用时需要先解码

由于Apple Server是外部URL(https://appleid.apple.com/auth/keys),并不是部署在大陆服务器上,速度慢不稳定,故而考虑将响应体放在Redis本地缓存里,提升登录接口性能。
在这里插入图片描述

在这里插入图片描述

JWT

identityToken是一个JWT,由Header、Payload、Signature三部分组成:
在这里插入图片描述
参考上面的截图。

Header包括的字段:

  • kid:表示用于验证签名的Apple公钥
  • alg:表示用于签名的算法
    在这里插入图片描述
    完整的payload字符串为:
{
    "iss": "https://appleid.apple.com",
    "aud": "com.aaaaa.bbbbb",
    "exp": 1692757384,
    "iat": 1692670984,
    "sub": "000942.2a81a3fedeaaaaaaa2179fa9b30b2.0223",
    "c_hash": "BJBc4awcx1pCt6OF9Czp9g",
    "email": "qz2bbjwffd@privaterelay.appleid.com",
    "email_verified": "true",
    "is_private_email": "true",
    "auth_time": 1692670984,
    "nonce_supported": true,
    "real_user_status": 2
}

包括如下字段:

  • iss:String类型,表示Token签发机构, 值固定为: https://appleid.apple.com
  • aud:String类型,表示Apple App的ID
  • exp:Long/int64类型,表示Token的过期时间, 时间戳
  • iat:Long/int64类型,表示client_secret生成时间,时间戳
  • sub:String类型,表示用户唯一标识
  • c_hash:String类型,文档中没看到这个字段,作用未知
  • auth_time:Long/int64类型,表示签名生成时间
  • email:String类型,表示用户邮箱, 可能是真实的也可能Apple处理过的密文邮件地址,取决于用户登录时是否选择隐藏邮箱
  • email_verified:String类型,表示用户邮箱是否已验证,Apple总是返回已验证的邮箱,所以这个字段的值总是为true
  • nonce:String类型,只有当发起登录请求的时候传递此参数,在验证时才会返回,降低被攻击的可能性
  • nonce_supported:Boolean类型,表示是否支持nonce,如果为true,则需要判断nonce字段值是否正确
  • is_private_email:String类型,表示用户提供的邮箱地址是否是Apple处理的邮箱地址
  • real_user_status:Integer类型,表示用户是否是真实用户:
    • 0:Unsupported,表示当前系统版本不支持该字段的值,只有在IOS 14+,macOS 11+,watchOS 7+以上版本才支持
    • 1:Unknown,系统无法识别是否是真实用户
    • 2:LikelyReal,几乎可以确定为真实用户

不是所有的字段都需要关心,参考下面定义的实体类JwsPayload即可。

引入依赖:

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>jwks-rsa</artifactId>
    <version>0.22.1</version>
</dependency>

<dependency>
    <groupId>org.bitbucket.b_c</groupId>
    <artifactId>jose4j</artifactId>
    <version>0.9.3</version>
</dependency>

完整的验证代码:

@Slf4j
@Service
public class AppleIdValidService {
    @Resource(name = "stringRedisTemplate")
    private RedisTemplate<String, String> redisTemplate;

    public boolean isValid(String accessToken) {
        CusJws cusJws = this.getJws(accessToken);
        if (cusJws == null) {
            log.error("accessToken格式非法(非Jws格式)!accessToken={}", accessToken);
            return false;
        }
        // exp
        long curTime = System.currentTimeMillis();
        if (cusJws.getJwsPayload().getExp() * 1000 < curTime) {
            log.error("accessToken已过期!accessToken={}", accessToken);
            return false;
        }
        // iss
        if (!JwsPayload.ISS.equals(cusJws.getJwsPayload().getIss())) {
            log.error("accessToken签发来源不合法!iss={}", cusJws.getJwsPayload().getIss());
            return false;
        }
        // 校验签名
        if (!this.verifySignature(accessToken, cusJws.getJwsHeader().kid)) {
            log.error("accessToken签名验证失败!accessToken={}", accessToken);
            return false;
        }
        log.info("accessToken,验证通过!accessToken={}", accessToken);
        return true;
    }

    /**
     * verify signature
     */
    private boolean verifySignature(String accessToken, String kid) {
        PublicKey publicKey = this.getAppleIdPublicKey(kid);
        JsonWebSignature jsonWebSignature = new JsonWebSignature();
        jsonWebSignature.setKey(publicKey);
        try {
            jsonWebSignature.setCompactSerialization(accessToken);
            return jsonWebSignature.verifySignature();
        } catch (JoseException e) {
            log.error("签名验证异常!", e);
            return false;
        }
    }

    /**
     * publicKey会本地缓存1天,减少请求Apple Server的次数
     */
    private PublicKey getAppleIdPublicKey(String kid) {
        String publicKeyStr = redisTemplate.opsForValue().get(RedisConstants.REDIS_KEY_APPLE_ID_PUBLIC_KEY);
        if (publicKeyStr == null) {
            publicKeyStr = this.getAppleIdPublicKeyFromRemote();
            if (publicKeyStr == null) {
                return null;
            }
            try {
                PublicKey publicKey = this.publicKeyAdapter(publicKeyStr, kid);
                redisTemplate.opsForValue().set(RedisConstants.REDIS_KEY_APPLE_ID_PUBLIC_KEY, publicKeyStr, 24, TimeUnit.HOURS);
                return publicKey;
            } catch (Exception ex) {
                log.error("获取AppleId公钥异常!", ex);
                return null;
            }
        }
        return this.publicKeyAdapter(publicKeyStr, kid);
    }

    /**
     * 将appleServer返回的publicKey转换成PublicKey对象
     */
    private PublicKey publicKeyAdapter(String publicKeyStr, String kid) {
        if (!StringUtils.hasText(publicKeyStr)) {
            return null;
        }
        Map<String, Object> maps = (Map<String, Object>) JSON.parse(publicKeyStr);
        List<Map<String, Object>> keys = (List<Map<String, Object>>) maps.get("keys");
        Map<String, Object> o = Maps.newHashMap();
        for (Map<String, Object> key : keys) {
            if (kid.equals(key.get("kid"))) {
                o = key;
                break;
            }
        }
        Jwk jwa = Jwk.fromValues(o);
        try {
            return jwa.getPublicKey();
        } catch (InvalidPublicKeyException e) {
            log.error("PublicKey转换异常!", e);
            return null;
        }
    }

    /**
     * 从apple Server获取publicKey
     */
    private String getAppleIdPublicKeyFromRemote() {
        ResponseEntity<String> responseEntity = new RestTemplate().getForEntity("https://appleid.apple.com/auth/keys", String.class);
        if (responseEntity.getStatusCode() != HttpStatus.OK) {
            return null;
        }
        return responseEntity.getBody();
    }

    private CusJws getJws(String identityToken) {
        String[] arrToken = identityToken.split("\\.");
        if (arrToken.length != 3) {
            return null;
        }
        Base64.Decoder decoder = Base64.getDecoder();
        JwsHeader jwsHeader = JSON.parseObject(new String(decoder.decode(arrToken[0])), JwsHeader.class);
        JwsPayload jwsPayload = JSON.parseObject(new String(decoder.decode(arrToken[1])), JwsPayload.class);
        return new CusJws(jwsHeader, jwsPayload, arrToken[2]);
    }

    @Data
    @AllArgsConstructor
    private static class CusJws {
        private JwsHeader jwsHeader;
        private JwsPayload jwsPayload;
        private String signature;
    }

    @Data
    private static class JwsHeader {
        private String kid;
        private String alg;
    }

    @Data
    private static class JwsPayload {
        private final static String ISS = "https://appleid.apple.com";
        private String iss;
        private String sub;
        private String aud;
        private long exp;
        private long iat;
        private String nonce;
        private String email;
        private boolean email_verified;
    }
}

Controller层代码:

@PostMapping("/login/apple")
@ApiOperation(value = "苹果AppleID登录", produces = "application/json", consumes = "application/json")
public Response<BaseLoginVo> appleIdLogin(@RequestBody UserSocialParam param) {
    if (param == null || StringUtils.isEmpty(param.getOpenId()) || StringUtils.isEmpty(param.getIdentityToken())) {
        return Response.error("openId/identityToken不能为空!");
    }
    boolean appleValid = appleIdValidService.isValid(param.getIdentityToken());
    if (appleValid) {
    	// 校验通过,省略其他逻辑
    }
}

UserSocialParam实体类源码:

@Data
public class UserSocialParam {
    private String openId;
    private String identityToken;
}

IdentityCode

需用到Apple公钥接口:https://appleid.apple.com/auth/token,参考接口文档

待补充

参考

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

johnny233

晚饭能不能加鸡腿就靠你了

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值