【苹果登录】使用Apple账号登录你的APP(服务端原理介绍+Java实现)

前言

为什么要用苹果登录?在国内大多数人应该都是使用的微信登录或者手机号一键登录多一些。之所以要开发苹果登录,可能大多是因为苹果的霸王条款:要求只要在苹果商店上架的应用,凡是接入了其他第三方登录,必须接入苹果登录。

虽然苹果的霸王条款很让人恼火,但是苹果登录本身还是值得我们学习和思考的。下面介绍一下苹果登录的原理。

铺垫知识(会的可以直接跳过)

在学习苹果登录之前,首先你需要了解以下几个知识点:

  1. 什么是非对称加密,什么是公钥和私钥?
  2. 什么是JWT,这东西是用来干嘛的?
  3. 什么是第三方登录?

下面依次给大家简要介绍一下上面三个知识点:

  • 非对称加密
    我们知道对称加密在加解密时使用的秘钥内容是相同的,而非对称加密在加密和解密时使用不同的秘钥。我们把这两个不同的秘钥一个称为私钥,一个称为公钥。一般私钥由我们自己保留不对外公开,而公钥则可以对外公开下载。这样有什么好处呢?举个例子:我们发布一则公告,使用私钥进行加密,然后把公告放到互联网上。想要看公告的人直接下载我们的公钥,然后用公钥解密公告即可。这样别人就无法仿造我们的公告,因为只有我们提供的公钥才能解密,从而达到对公告信任的目的。

  • JWT是什么
    JWT是什么?直接回答这个问题比较难以理解,我们就拿苹果的JWT举个例子:

{"kid": "W6WcOKB","alg": "RS256"}.{"iss":"https://appleid.apple.com","aud":"com.test","exp":1687776521,"iat":1687690121,"sub":"001001.0bb42737edf44688850c5de5e666d9e.1024","c_hash":"FXhhsDY1Dpabo8_GTID-Lw","auth_time":1687690121,"nonce_supported":true,"real_user_status":2}.xxxxxxxxxxxxxxxxxxxxxxxxxx

我们看到jwt就是一个字符串,由三段字符串组成。

  1. 第一段:{"kid": "W6WcOKB","alg": "RS256"}我们称为头部,标记了这个JWT通过RS256进行加密,(这里的kid是对应的公钥id,这个是苹果特有的)。
  2. 第二段:{"iss":"https://appleid.apple.com","aud":"com.test","exp":1687776521,"iat":1687690121,"sub":"001001.0bb42737edf44688850c5de5e666d9e.1024","c_hash":"FXhhsDY1Dpabo8_GTID-Lw","auth_time":1687690121,"nonce_supported":true,"real_user_status":2}
    这里我们只说关键的几个参数:iss:发布人,固定是苹果的域名;aud:应用的包名;sub:当前用户的唯一ID,类似微信等第三方登录的OpenID;
  3. 第三段:就是校验和类似的东西,没有详细的研究苹果是怎么实现的,但是我们理解技术不必太过纠结,知道他是个校验和的东西就行。比如我可以这么实现,首先将前面的两个部分拼接成一个字符串,然后求一个摘要,再用私钥进行加密后作为第三部分。需要校验时,先求前两部分摘要,然后用公钥解密第三方部分,看解密的结果和我们求出的摘要是否相等即可。
  • 什么是第三方登录?
    其实问为什么需要第三方登录更好。随着我们使用的应用越来越多,如果我们每个都创建一个账户和密码,简直就是噩梦。而我们有微信账号,如果可以直接用微信账号登录多好,一个账号走天下,只要记住微信的密码,能登录微信就可以了,再也不怕忘记密码了。至于通常如何实现第三方登录,可以去了解下OAuth2,这里就不再多说了,不是本文的重点。

APPLE的登录原理

  1. 首先,当你在应用内点击苹果登陆按钮时,苹果会生成一系列的参数给你,其中最重要的一个参数是:identityToken。这个参数的值就是一个JWT。这个JWT是通过苹果的私钥加密的。
  2. 我们将这个identityToken传给我们的后台服务,后台服务从苹果提供的公钥下载地址下载公钥,然后使用公钥验证这个JWT是否合法。
  3. 在合法的前提下,我们看一下iss是否是苹果(显然是),再看aud是不是你自己的包名(别人的应用也可以生成一个token,也可以通过校验,但是包名肯定和你的不同)。都校验通过了,说明这是一个合法JWT。然后我们就取sub作为苹果的OpenId,记录到我们后台的用户表里即可(如果没有用户则创建,如果有则绑定)。等到再次登录的时候,则用sub查询有没有对应的用户,如果有,则直接让其登录即可。

实现(Java)

引入JWT工具包

<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.22.0</version>
</dependency>

实现代码(以下代码复制就可以直接使用):

  • 校验代码(核心逻辑)
public class AppleAuthHelper {

    private static final String PUBLIC_KEY_URL = "/auth/keys";
    private static final String APPLE_DOMAIN = "https://appleid.apple.com";

    private static final String aud = "com.test";

    public static boolean isValid(AppleAuthRequest appleAuthRequest) {
        try {
            IdentityToken idToken = new IdentityToken(appleAuthRequest.getIdentityToken());
            String userID = appleAuthRequest.getUserID();
            PublicKey publicKey = AppPublicKey.get(APPLE_DOMAIN + PUBLIC_KEY_URL, idToken.getKid());
            if (null == publicKey) {
                return false;
            }
            JwtParser jwtParser = Jwts.parser().setSigningKey(publicKey);
            jwtParser.requireIssuer(APPLE_DOMAIN);
            jwtParser.requireAudience(aud);
            jwtParser.requireSubject(userID);
            Jws<Claims> claim = jwtParser.parseClaimsJws(idToken.getToken());
            if (claim != null && claim.getBody().containsKey("auth_time")) {
                return true;
            }
        } catch (Exception e) {
            log.error("校验apple登录信息失败", e);
        }
        return false;
    }
}
  • 获取公钥代码
public class AppPublicKey {

    public static volatile Map<String, Map<String, Object>> cache = new HashMap<>();

    public static PublicKey get(String url, String kid) {
        try {
            if (cache.containsKey(kid)) {
                Map<String, Object> publicKeyConfig = cache.get(kid);
                Jwk jwa = Jwk.fromValues(publicKeyConfig);
                return jwa.getPublicKey();
            }
            Map<String, Object> publicKeyConfig = getPublicKeyConfig(url, kid);
            if (null == publicKeyConfig) {
                return null;
            }
            Jwk jwa = Jwk.fromValues(publicKeyConfig);
            return jwa.getPublicKey();
        } catch (final Exception e) {
            log.error("apple get publicKey error", e);
        }

        return null;
    }

    private static synchronized Map<String, Object> getPublicKeyConfig(String url, String kid) {
        try {
            String str = HttpUtil.get(url);
            JsonNode jsonNode = JsonHelper.asTree(str);
            JsonNode keys = jsonNode.get("keys");
            if (null == keys) {
                return null;
            }
            Iterator<JsonNode> elements = keys.elements();

            while (elements.hasNext()) {
                JsonNode node = elements.next();
                String nodeKid = node.get("kid").asText();
                if (kid.equals(nodeKid)) {
                    Map<String, Object> item = JsonHelper.toMap(node);
                    cache.put(nodeKid, item);
                }
            }
            return cache.get(kid);
        } catch (final Exception e) {
            log.error("apple get publicKey error", e);
        }
        return null;
    }
}
  • 辅助类(请求参数等)
public class AppleAuthRequest {
    private String authorizationCode;
    private String identityToken;
    private String userID;
    private AppleUserInfo fullName;
}

public class AppleUserInfo {
    private String givenName;
    private String familyName;
    private String middleName;
    private String namePrefix;
    private String nameSuffix;
    private String nickname;
    private String phoneticRepresentation;
}

public class IdentityToken {

    //原始token
    private String token;

    //公钥id
    private String kid;

    //应用包名,应该为:com.test
    private String aud;

    //用户openId
    private String sub;

    @SneakyThrows
    public IdentityToken(String token) {
        this.token = token;
        String[] identityTokens = this.token.split("\\.");
        Map<String, Object> firstDate = JSONObject
                .parseObject(new String(Base64.decodeBase64(identityTokens[0]), "UTF-8"));
        Map<String, Object> secondData = JSONObject
                .parseObject(new String(Base64.decodeBase64(identityTokens[1]), "UTF-8"));

        this.kid = String.valueOf(firstDate.get("kid"));
        this.aud = String.valueOf(secondData.get("aud"));
        this.sub = String.valueOf(secondData.get("sub"));
    }
}

public class JsonHelper {
    private final static ObjectMapper objectMapper = new ObjectMapper();

    public static Map<String,Object> toMap(JsonNode jsonNode){
        return objectMapper.convertValue(jsonNode, new TypeReference<Map<String, Object>>(){});
    }
    public static JsonNode asTree(String strJson) {
        try {
            return objectMapper.readTree(strJson);
        } catch (IOException e) {
            log.error("Deserialize fail", e);
        }
        return null;
    }
}

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 13
    评论
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值