netcore苹果登陆后端验证Sign In With Apple
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