最近新開發的app在IOS平台app store connent提審的時候,被拒了,原因是app上如果有接第三方登陸(比如微信,微博,facebook等),那就必須要接apple id登陸,坑爹~蘋果霸權啊!然而沒辦法,所以只能接入蘋果登錄。
APP端的接入可以看上一篇博客:iOS蘋果授權登錄(Sign in with Apple)/Apple登錄/蘋果登錄集成教程,下面我來說一下對接蘋果登陸的后端驗證模塊。
這里先說一下apple id登陸的主要流程和涉及到的一些知識點。首先apple登陸的時序圖如下:

先是app和蘋果服務器通信獲得identitytoken,然后把identitytoken交給業務后台驗證,驗證通過就可以了。
其中appServer涉及到的驗證,就是identitytoken,其實identitytoken就是一個jws(關於jws的只是可以參考https://www.jianshu.com/p/50ade6f2e4fd),至於校驗jws,其實是有現成的jar包可以實現,驗證jws的簽名,保證數據沒有被篡改之后,還要校驗從identitytokendecode出來的nonce,iss,aud,exp,主要是iss和exp這兩個。
針對后端驗證蘋果提供了兩種驗證方式,一種是基於JWT的算法驗證,另外一種是基於授權碼的驗證。
一、蘋果登錄JAVA后台校驗:JWT的identityToken驗證模式
//接口返回值
{ "keys": [ { "kty": "RSA", "kid": "AIDOPK1", "use": "sig", "alg": "RS256", "n": "lxrwmuYSAsTfn-lUu4goZSXBD9ackM9OJuwUVQHmbZo6GW4Fu_auUdN5zI7Y1dEDfgt7m7QXWbHuMD01HLnD4eRtY-RNwCWdjNfEaY_esUPY3OVMrNDI15Ns13xspWS3q-13kdGv9jHI28P87RvMpjz_JCpQ5IM44oSyRnYtVJO-320SB8E2Bw92pmrenbp67KRUzTEVfGU4-obP5RZ09OxvCr1io4KJvEOjDJuuoClF66AT72WymtoMdwzUmhINjR0XSqK6H0MdWsjw7ysyd_JhmqX5CAaT9Pgi0J8lU_pcl215oANqjy7Ob-VMhug9eGyxAWVfu_1u6QJKePlE-w", "e": "AQAB" } ] }
kid,為密鑰id標識,簽名算法采用的是RS256(RSA 256 + SHA 256),kty常量標識使用RSA簽名算法,其公鑰參數為n和e,其值采用了BASE64編碼,使用時需要先解碼
- 使用方式:APP內蘋果授權登陸會提供如下幾個參數:userID、email、fullName、authorizationCode、identityToken
- userID:授權的用戶唯一標識
- email、fullName:授權的用戶資料
- authorizationCode:授權code
- identityToken:授權用戶的JWT憑證
下面針對identityToken后端驗證做簡要說明,話不多說直接上代碼:
1、app端請求appleServer返回的identityTokenn參考樣例
"identityToken":"ZXlKcmFXUWlPaUpsV0dGMWJtMU1JaXdpUndje***xMXpZZ3BiYWRIWHdGVEtR4ejRPZTBhUkdtcHZOZFpWVkJGQjN4OU13"
// jwt 格式 該token的有效期是10分鍾
eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNreW1pbmcuZGV2aWNlbW9uaXRvciIsImV4cCI6MTU2NTY2ODA4NiwiaWF0IjoxNTY1NjY3NDg2LCJzdWIiOiIwMDEyNDcuOTNiM2E3OTlhN2M4NGMwY2I0NmNkMDhmMTAwNzk3ZjIuMDcwNCIsImNfaGFzaCI6Ik9oMmFtOWVNTldWWTNkcTVKbUNsYmciLCJhdXRoX3RpbWUiOjE1NjU2Njc0ODZ9.e-pdwK4iKWErr_Gcpkzo8JNi_MWh7OMnA15FvyOXQxTx0GsXzFT3qE3DmXqAar96nx3EqsHI1Qgquqt2ogyj-lLijK_46ifckdqPjncTEGzVWkNTX8uhY7M867B6aUnmR7u-cf2HsmhXrvgsJLGp2TzCI3oTp-kskBOeCPMyTxzNURuYe8zabBlUy6FDNIPeZwZXZqU0Fr3riv2k1NkGx5MqFdUq3z5mNfmWbIAuU64Z3yKhaqwGd2tey1Xxs4hHa786OeYFF3n7G5h-4kQ4lf163G6I5BU0etCRSYVKqjq-OL-8z8dHNqvTJtAYanB3OHNWCHevJFHJ2nWOTT3sbw // header 解碼
{"kid":"AIDOPK1","alg":"RS256"} 其中kid對應上文說的密鑰id // claims 解碼
{ "iss":"https://appleid.apple.com", // 蘋果簽發的標識
"aud":"com.skyming.devicemonitor", // 接收者的APP ID
"exp":1565668086,"iat":1565667486, "sub":"001247.93b3a799a7c84c0cb46cd08f100797f2.0704", //用戶的唯一標識
"c_hash":"Oh2am9eMNWVY3dq5JmClbg", "auth_time":1565667486 }
其中 iss標識是蘋果簽發的,aud是接收者的APP ID,該token的有效期是10分鍾,sub就是用戶的唯一標識
如何驗證呢?
//首先通過identityToken中的header中的kid,然后結合蘋果獲取公鑰的接口,拿到相應的n和e的值,然后通過下面這個方法構建RSA公鑰
public RSAPublicKeySpec build(String n, String e) { BigInteger modulus = new BigInteger(1, Base64.decodeBase64(n)); BigInteger publicExponent = new BigInteger(1, Base64.decodeBase64(e)); return new RSAPublicKeySpec(modulus, publicExponent); } //獲取驗證所需的PublicKey
public PublicKey getPublicKey(String n,String e)throws NoSuchAlgorithmException, InvalidKeySpecException { BigInteger bigIntModulus = new BigInteger(1,Base64.decodeBase64(n)); BigInteger bigIntPrivateExponent = new BigInteger(1,Base64.decodeBase64(e)); RSAPublicKeySpec keySpec = new RSAPublicKeySpec(bigIntModulus, bigIntPrivateExponent); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); PublicKey publicKey = keyFactory.generatePublic(keySpec); return publicKey; } //通過下面這個方法驗證JWT的有效性 // jwt 就是 identityToken:授權用戶的JWT憑證 // audience就是APPID // subject 就是 就是userId
public int verify(PublicKey key, String jwt, String audience, String subject) { JwtParser jwtParser = Jwts.parser().setSigningKey(key); jwtParser.requireIssuer("https://appleid.apple.com"); jwtParser.requireAudience(audience); jwtParser.requireSubject(subject); try { Jws<Claims> claim = jwtParser.parseClaimsJws(jwt); if (claim != null && claim.getBody().containsKey("auth_time")) { return GlobalCode.SUCCESS; } return GlobalCode.THIRD_AUTH_CODE_INVALID; } catch (ExpiredJwtException e) { log.error("apple identityToken expired", e); return GlobalCode.THIRD_AUTH_CODE_INVALID; } catch (Exception e) { log.error("apple identityToken illegal", e); return GlobalCode.FAIL_ILLEGAL_REQ; } } //使用的JWT工具庫為:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2、完整校驗代碼:
(1)有2次驗證,分別調取apple提供的接口和標識
private final String APPLE_AUTH_URL = "https://appleid.apple.com/auth/keys"; private final String ISS = "https://appleid.apple.com";
(2)一次是利用token獲取解密的publicKey;另一次就是再校驗這個publicKey及token。
verify(AppleLoginVO appleLoginVO)里解析token
然后getPublicKey(kid)獲取publicKey
然后再verify(publicKey, identityToken, aud, sub)校驗
(3)封裝的httpclient請求:private JSONObject getHttp(String url)
完整示例代碼:
@Slf4j @Service public class AppleLoginService { private final String APPLE_AUTH_URL = "https://appleid.apple.com/auth/keys"; private final String ISS = "https://appleid.apple.com"; public boolean verify(AppleLoginVO appleLoginVO) { //這里傳過來的identityToken應該是三個.分割,解密之后
String identityToken = appleLoginVO.getIdentityToken(); try { if (identityToken.split("\\.").length > 1){ String firstDate = new String( Base64.decodeBase64(identityToken.split("\\.")[0]),"UTF-8"); String claim = new String(Base64.decodeBase64(identityToken.split("\\.")[1]), "UTF-8"); String kid = JSONObject.parseObject(firstDate).get("kid").toString(); String aud = JSONObject.parseObject(claim).get("aud").toString(); String sub = JSONObject.parseObject(claim).get("sub").toString(); PublicKey publicKey = getPublicKey(kid); if (publicKey == null) { log.error("Apple have no info data!"); return false; } boolean reuslt = verify(publicKey, identityToken, aud, sub); if (reuslt) { log.info("蘋果登錄授權成功"); return true; } } } catch (Exception e) { log.error("蘋果登錄授權異常:", LogUtil.getStack(e)); e.printStackTrace(); } log.error("identityToken格式不正確"); return false; } private PublicKey getPublicKey(String kid) { try { JSONObject debugInfo = getHttp(APPLE_AUTH_URL); if (debugInfo == null) { return null; } JSONObject jsonObject = debugInfo.getJSONObject("body"); String keys = jsonObject.getString("keys"); JSONArray jsonArray = JSONObject.parseArray(keys); if (jsonArray.isEmpty()) { return null; } for (Object object : jsonArray) { JSONObject json = ((JSONObject) object); if (json.getString("kid").equals(kid)) { String n = json.getString("n"); String e = json.getString("e"); BigInteger modulus = new BigInteger(1, Base64.decodeBase64(n)); BigInteger publicExponent = new BigInteger(1, Base64.decodeBase64(e)); RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, publicExponent); KeyFactory kf = KeyFactory.getInstance("RSA"); return kf.generatePublic(spec); } } } catch (Exception e) { log.error("getPublicKey異常!", LogUtil.getStack(e)); e.printStackTrace(); } return null; } private boolean verify(PublicKey key, String jwt, String audience, String subject){ boolean result = false; JwtParser jwtParser = Jwts.parser().setSigningKey(key); jwtParser.requireIssuer(ISS); jwtParser.requireAudience(audience); jwtParser.requireSubject(subject); try { Jws<Claims> claim = jwtParser.parseClaimsJws(jwt); if (claim != null && claim.getBody().containsKey("auth_time")) { return true; } } catch (ExpiredJwtException e) { log.error("getPublicKey異常{蘋果identityToken過期}", LogUtil.getStack(e)); } catch (SignatureException e) { log.error("getPublicKey異常{蘋果identityToken非法}", LogUtil.getStack(e)); } return result; } private JSONObject getHttp(String url) { log.info("[請求地址]: " + url); JSONObject resultJson = new JSONObject(); resultJson.put("code", -1); CloseableHttpClient httpclient = HttpClients.createDefault(); try { HttpGet httpPost = new HttpGet(url); CloseableHttpResponse response = httpclient.execute(httpPost); try { HttpEntity entity = response.getEntity(); if (response.getStatusLine().getStatusCode() == 200) { String resp = EntityUtils.toString(entity); resultJson.put("code", 0); resultJson.put("body", JSONObject.parseObject(resp)); } else { resultJson.put("code", response.getStatusLine().getStatusCode()); log.error("[錯誤碼] :" + response.getStatusLine().getStatusCode()); log.error("[請求地址] :" + url); } } finally { response.close(); } } catch (ClientProtocolException e) { log.error("[異常] :", LogUtil.getStack(e)); } catch (IOException e) { log.error("[異常] :", LogUtil.getStack(e)); } finally { try { httpclient.close(); } catch (IOException e) { log.error("[httpclient 關閉異常] : ", LogUtil.getStack(e)); } } return resultJson; } }
