现在在职的公司有一款iOS App,其登录方式有3种,如下截图所示:
一般App都只有一种手机号登录方式。登录方式增加微信登录,即在登录时跳转到微信。
iOS App上如果有接第三方登陆(如微信,微博,Facebook等),则必须要接入AppleId登录,否则iOS上线提交审核无法通过。
具体来说:
在第二步服务器的验证过程中,服务端可选择Code或Token中的任意一种进行验证。
client_id, client_secret, redirect_uri
三个参数。需用到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"
}
]
}
响应体解释:
由于Apple Server是外部URL(https://appleid.apple.com/auth/keys),并不是部署在大陆服务器上,速度慢不稳定,故而考虑将响应体放在Redis本地缓存里,提升登录接口性能。
identityToken是一个JWT,由Header, Payload以及Signature三部分组成:
参考上面的截图。Header: 包括的字段如下,
{
"iss": "https://appleid.apple.com",
"aud": "com.aaaaa.bbbbb",
"exp": 1692757384,
"iat": 1692670984,
"sub": "000942.2a81a3fedeaaaaaaa2179fa9b30b2.0223",
"c_hash": "BJBc4awcx1pCt6OF9Czp9g",
"email": "[email protected]",
"email_verified": "true",
"is_private_email": "true",
"auth_time": 1692670984,
"nonce_supported": true,
"real_user_status": 2
}
包括如下字段:
不是所有的字段都需要关心,参考下面定义的实体类JwsPayload即可。
引入依赖:
<dependency>
<groupId>com.auth0groupId>
<artifactId>jwks-rsaartifactId>
<version>0.22.1version>
dependency>
<dependency>
<groupId>org.bitbucket.b_cgroupId>
<artifactId>jose4jartifactId>
<version>0.9.3version>
dependency>
完整的验证代码:
@Slf4j
@Service
public class AppleIdValidService {
private final static int APPLE_ID_PUBLIC_KEY_EXPIRE = 24;
@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, APPLE_ID_PUBLIC_KEY_EXPIRE, 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) {
// 校验通过,省略其他逻辑
}
}
需用到Apple公钥接口:https://appleid.apple.com/auth/token,参考接口文档
待补充