2020年4月之后,上架App Store得應用必須集成apple賬號得登錄。
近期博主剛好配合前端IOS集成apple登錄,網上找了不少文章教程,發現基本都是網頁集成登錄或者是java代碼,比較少純后端net驗證,期間也走了不少彎路,在這分享給大家實現思路和需要得注意事項。
文章開始前先說明一下此文環境為netcore3.1環境代碼編寫,IOS相關配置和文章請參考文末鏈接。
整體思路為:前端調用蘋果接口獲取到userID和authorizationCode,后端通過authorizationCode調用蘋果接口驗證,若檢驗成功會返回相關信息;
以下為apple官方接口文檔說明:https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
根據文檔可知,調用授權碼驗證需要傳遞6個參數,其中3個必填client_id,client_secret和grant_type。
client_id和client_secret為apple驗證請求方是否合法,client_secret得生成也本文得重點講解。
grant_type 我理解為操作方式,固定為驗證授權碼authorization_code和刷新token refresh_token,因為我們是驗證授權碼,所以只需傳遞authorization_code
這里注意Content-Type: application/x-www-form-urlencoded,若使用postman工具嘗試請求Body請記得選x-www-form-urlencoded,否則請求格式失敗必定“invalid_client”;
先說請求的幾種錯誤的返回格式:
| 返回 | 原因 |
| invalid_client | client_id或client_secret錯誤,請復制下面代碼生成 |
|
invalid_grant
|
authorization_code 授權碼錯誤,可以去懟前端了 |
|
unsupported_grant_type
|
grant_type錯誤,嗯請固定authorization_code,別問我為什么知道,當然是特意去請求嘗試給你們看的啦 |

說了這么多先貼一下請求代碼:
只要成功返回,解析返回值的IdToken,jwt解析第二段驗證Aud和clientId,Sub與userID一致即可:
1 /// <summary> 2 /// 檢驗生成的授權碼是正確的,需要給出正確的授權碼 3 /// </summary> 4 /// <param name="authorizationCode">授權碼</param> 5 /// <param name="appUserId">apple用戶ID</param> 6 /// <returns></returns> 7 public async Task TestAppleSign(string authorizationCode, string appUserId) 8 { 9 var httpClientHandler = new HttpClientHandler 10 { 11 ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true 12 }; 13 var httpClient = new HttpClient(httpClientHandler, true) 14 { 15 //超時時間設置長一點點,有時候響應超過3秒,根據情況設置 16 //我這里是單元測試,防止異常所以寫30秒 17 Timeout = TimeSpan.FromSeconds(30) 18 }; 19 var newToken = CreateNewClientSecret(); 20 var clientId = "bundleId";//找IOS要 21 var datas = new Dictionary<string, string>() 22 { 23 {"client_id", clientId}, 24 {"grant_type", "authorization_code"},//固定authorization_code 25 {"code",authorizationCode },//授權碼,前端驗證登錄給予 26 {"client_secret",newToken} //client_secret,后面方法生成 27 }; 28 //x-www-form-urlencoded 使用FormUrlEncodedContent 29 var formdata = new FormUrlEncodedContent(datas); 30 var result = await httpClient.PostAsync("https://appleid.apple.com/auth/token", formdata); 31 var re = await result.Content.ReadAsStringAsync(); 32 if (result.IsSuccessStatusCode) 33 { 34 var deserializeObject = JsonConvert.DeserializeObject<TokenResult>(re); 35 var jwtPlayload = DecodeJwtPlayload(deserializeObject.IdToken); 36 if (!jwtPlayload.Aud.Equals(clientId) || !jwtPlayload.Sub.Equals(appUserId))//appUserId,前端驗證登錄給予 37 { 38 39 } 40 } 41 else 42 { 43 //請根據re的返回值,查看上面的錯誤表格 44 } 45 }
生成ClientSecret代碼如下:需要注意的是IssuedAt和NotBefore時間需要留些余地,我就是在windos開發成功,部署到服務器環境后一致報invalid_client,之前發生過加密錯誤,還以為又是加密方式導致,經過一段時間的排除后發現是時間的坑
1 public static string CreateNewClientSecret() 2 { 3 var handler = new JwtSecurityTokenHandler(); 4 var subject = new Claim("sub", "bundleId");//找IOS要 5 var tokenDescriptor = new SecurityTokenDescriptor() 6 { 7 Audience = "https://appleid.apple.com",//固定值 8 Expires = DateTime.Now.AddMonths(5),//ClientSecret超時時間,可以設置長一點,也可以根據需要設置有效時間 9 Issuer = "team ID",//team ID,找IOS要 10 //防止服務器時間比apple時間晚 11 //簽發時間 12 IssuedAt = DateTime.Now.AddDays(-1), 13 //防止服務器時間比apple時間晚 14 //生效時間 15 NotBefore = DateTime.Now.AddDays(-1), 16 Subject = new ClaimsIdentity(new[] { subject }), 17 }; 18 byte[] keyBlob = GetPrivateKeyBytesAsync(); 19 var algorithm = CreateAlgorithm(keyBlob); 20 { 21 tokenDescriptor.SigningCredentials = CreateSigningCredentials("KeyID", algorithm);//p8私鑰文件得Key,找IOS要 22 23 var clientSecret = handler.CreateEncodedJwt(tokenDescriptor); 24 25 return clientSecret; 26 } 27 } 28 public static byte[] GetPrivateKeyBytesAsync() 29 { 30 //p8文件內容 31 string content = @"-----BEGIN PRIVATE KEY----- 32 ******************************** 33 ******************************* 34 *********************************** 35 ************* 36 ---- - END -----"; 37 38 if (content.StartsWith("-----BEGIN PRIVATE KEY-----", StringComparison.Ordinal)) 39 { 40 string[] keyLines = content.Split('\n'); 41 content = string.Join(string.Empty, keyLines.Skip(1).Take(keyLines.Length - 2)); 42 } 43 44 return Convert.FromBase64String(content); 45 } 46 private static ECDsa CreateAlgorithm(byte[] keyBlob) 47 { 48 var algorithm = ECDsa.Create(); 49 50 try 51 { 52 algorithm.ImportPkcs8PrivateKey(keyBlob, out int _); 53 return algorithm; 54 } 55 catch (Exception) 56 { 57 algorithm?.Dispose(); 58 throw; 59 } 60 } 61 private static SigningCredentials CreateSigningCredentials(string keyId, ECDsa algorithm) 62 { 63 var key = new ECDsaSecurityKey(algorithm) { KeyId = keyId }; 64 return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256Signature); 65 }
只要成功返回,解析返回值的IdToken,jwt解析第二段驗證Aud和clientId,Sub與userID一致即可,附反序列化返回值和jwt解析:
1 /// <summary> 2 /// 解析jwt第二部分 3 /// </summary> 4 /// <param name="jwtString"></param> 5 /// <returns></returns> 6 private JwtPlayload DecodeJwtPlayload(string jwtString) 7 { 8 try 9 { 10 var code = jwtString.Split('.')[1]; 11 code = code.Replace('-', '+').Replace('_', '/').PadRight(4 * ((code.Length + 3) / 4), '='); 12 var bytes = Convert.FromBase64String(code); 13 var decode = Encoding.UTF8.GetString(bytes); 14 return JsonConvert.DeserializeObject<JwtPlayload>(decode); 15 } 16 catch (Exception e) 17 { 18 throw new Exception(e.Message); 19 } 20 } 21 /// <summary> 22 /// 接口返回值 23 /// </summary> 24 public class TokenResult 25 { 26 /// <summary> 27 /// 一個token 28 /// </summary> 29 [JsonProperty("access_token")] 30 public string AccessToken { get; set; } 31 /// <summary> 32 /// Bearer 33 /// </summary> 34 [JsonProperty("token_type")] 35 public string TokenType { get; set; } 36 /// <summary> 37 /// 38 /// </summary> 39 [JsonProperty("expires_in")] 40 public long ExpiresIn { get; set; } 41 /// <summary> 42 /// 一個token 43 /// </summary> 44 [JsonProperty("refresh_token")] 45 public string RefreshToken { get; set; } 46 /// <summary> 47 /// "結果是JWT,字符串形式,identityToken 解析后和客戶端端做比對 48 /// </summary> 49 [JsonProperty("id_token")] 50 public string IdToken { get; set; } 51 } 52 /// <summary> 53 /// jwt第二部分 54 /// </summary> 55 private class JwtPlayload 56 { 57 /// <summary> 58 /// "https://appleid.apple.com" 59 /// </summary> 60 [JsonProperty("iss")] 61 public string Iss { get; set; } 62 /// <summary> 63 /// 這個是你的app的bundle identifier 64 /// </summary> 65 [JsonProperty("aud")] 66 public string Aud { get; set; } 67 /// <summary> 68 /// 69 /// </summary> 70 [JsonProperty("exp")] 71 public long Exp { get; set; } 72 /// <summary> 73 /// 74 /// </summary> 75 [JsonProperty("iat")] 76 public long Iat { get; set; } 77 /// <summary> 78 /// 用戶ID 79 /// </summary> 80 [JsonProperty("sub")] 81 public string Sub { get; set; } 82 /// <summary> 83 /// 84 /// </summary> 85 [JsonProperty("at_hash")] 86 public string AtHash { get; set; } 87 /// <summary> 88 /// 89 /// </summary> 90 [JsonProperty("email")] 91 public string Email { get; set; } 92 /// <summary> 93 /// 94 /// </summary> 95 [JsonProperty("email_verified")] 96 public bool EmailVerified { get; set; } 97 /// <summary> 98 /// 99 /// </summary> 100 [JsonProperty("is_private_email")] 101 public bool IsPrivateEmail { get; set; } 102 /// <summary> 103 /// 104 /// </summary> 105 [JsonProperty("auth_time")] 106 public long AuthTime { get; set; } 107 /// <summary> 108 /// 109 /// </summary> 110 [JsonProperty("nonce_supported")] 111 public bool NonceSupported { get; set; } 112 } 113
下面得生成client_secret方式是由文章參考而來:https://www.scottbrady91.com/OpenID-Connect/Implementing-Sign-In-with-Apple-in-ASPNET-Core
但是這個方式只適用於windos,使用容器方式部署到linux時發現CnKey加密方式會報平台錯誤,若只在windos部署可直接使用該方式
public static string CreateNewToken() { const string iss = "62QM29578N"; // your account's team ID found in the dev portal const string aud = "https://appleid.apple.com"; const string sub = "com.scottbrady91.authdemo.service"; // same as client_id const string privateKey = "MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgnbfHJQO9feC7yKOenScNctvHUP+Hp3AdOKnjUC3Ee9GgCgYIKoZIzj0DAQehRANCAATMgckuqQ1MhKALhLT/CA9lZrLA+VqTW/iIJ9GKimtC2GP02hCc5Vac8WuN6YjynF3JPWKTYjg2zqex5Sdn9Wj+"; // contents of .p8 file var cngKey = CngKey.Import( Convert.FromBase64String(privateKey), CngKeyBlobFormat.Pkcs8PrivateBlob); var handler = new JwtSecurityTokenHandler(); var token = handler.CreateJwtSecurityToken( issuer: iss, audience: aud, subject: new ClaimsIdentity(new List<Claim> {new Claim("sub", sub)}), expires: DateTime.UtcNow.AddMinutes(5), // expiry can be a maximum of 6 months issuedAt: DateTime.UtcNow, notBefore: DateTime.UtcNow, signingCredentials: new SigningCredentials( new ECDsaSecurityKey(new ECDsaCng(cngKey)), SecurityAlgorithms.EcdsaSha256)); return handler.WriteToken(token); }
2021.1.12 經12樓同學反饋
redirect_uri可以為空, 我沒傳遞redirect_uri參數可以正常認證,他沒傳遞會導致invalid_client,所以他就隨意傳遞了一個值導致正常的授權碼一直返回invalid_grant,補充一下給其他出錯的同學參考。
參考資料:
https://www.jianshu.com/p/e1284bd8c72a
蘋果授權登陸后端驗證:
https://blog.csdn.net/wpf199402076118/article/details/99677412
netcore驗證相關文章:
https://www.scottbrady91.com/OpenID-Connect/Implementing-Sign-In-with-Apple-in-ASPNET-Core
https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
