概述
现在在职的公司有一款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登录功能前需要一些准备工作:
- 注册App ID
- 注册Services ID
- 启用
通过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,参考接口文档
待补充