本人写博客,向来主张:代码要完整,代码可运行,文中不留下任何疑惑。
最讨厌写博客,代码只留下片段,文中关键的东西没写清楚。之前看了那么多文章,就是不告诉我clientId从哪来的。
官方资料地址:
Sign in with Apple JS | Apple Developer Documentation
一、网页客户端代码
clientId:这个会在下文中告诉你怎么来的
usePopup:如果设置为true,就会以弹框的方式打开苹果登录窗口。设置 为false,你自己试试吧
redirectURI:这在usePopup=true时,没啥用
state:在各种页面跳转时会原样传递,你自己看着办
nonce:一个随机数,至于作用么,你自己猜,照着做就好
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport" />
<title></title>
<script type="text/javascript">
var hostBase = "https://myserver.cn";
function getQuery(i) { var j = location.search.match(new RegExp("[?&]" + i + "=([^&]*)(&?)", "i")); return j ? j[1] : j; }
function getQueryIn(i, params) { var j = ("?&" + params).match(new RegExp("[?&]" + i + "=([^&]*)(&?)", "i")); return j ? j[1] : j; }
</script>
</head>
<body>
<div id="app">
<div id="appleid-signin" data-color="black" data-border="true" data-type="sign in"></div>
</div>
</body>
<style></style>
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script type="text/javascript"
src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
<script>
window.app = new Vue({
el: '#app',
data: function () {
return {
}
},
watch: {
},
created: function () {
setTimeout(function () {
AppleID.auth.init({
clientId: '填上你的clientId',
scope: 'name email',
redirectURI: 'https://myclient.cn/login_apple_redirect.html',
state: "这个参数在各种跳转中会一直带上,你可以用来标记这次登录过程",
nonce: '' + new Date().getTime(),
usePopup: true
});
}, 10);
},
methods: {
OnAppleSignIn: function (authorization) {
var that = this;
var req = {
state: authorization.state,
code: authorization.code,
idToken: authorization.id_token
};
$.ajax({
url: hostBase + "/fawork/AppleLoginGetResult", //请求的url地址
dataType: "json", //返回格式为json
contentType: "application/json; charset=utf-8",
async: true, //请求是否异步,默认为异步,这也是ajax重要特性
data: JSON.stringify(req),
async: true, //请求是否异步,默认为异步,这也是ajax重要特性
type: "POST", //请求方式
beforeSend: function () {
//请求前的处理
},
success: function (rsp) {
console.log(rsp);
// 返回 uid和token
},
complete: function () {
//请求完成的处理
},
error: function () {
//请求出错处理
}
});
}
}
});
// Listen for authorization success.
document.addEventListener('AppleIDSignInOnSuccess', function (event) {
// Handle successful response.
console.log(event);
window.app.OnAppleSignIn(event.detail.authorization);
});
// Listen for authorization failures.
document.addEventListener('AppleIDSignInOnFailure', function (event) {
// Handle error.
console.log(event);
});
</script>
</html>
登录成功时,前端 AppleIDSignInOnSuccess事件中,打印的event参数值
二、服务器端代码
前端传递过来code和id_token,这是两个不同的校验方法
code是一种,不过在苹果登录中我不知道怎么用,我用的是id_token校验。
对于id_token,这就是JWT校验技术。
JWT的介绍,请看我用ChatGpt问的结果。
在JWT(JSON Web Token)中,id token是一种用于身份验证和认证的令牌。要验证id token的有效性,您可以遵循以下步骤:
-
解码id token:JWT由三部分组成,即头部、载荷和签名。使用Base64解码id token,您可以获取其中的头部和载荷信息。
-
验证签名:使用头部中提供的算法(通常是HMAC、RSA或ECDSA)和密钥,验证签名的正确性。您需要获取与签名算法相对应的密钥,并将其与头部和载荷一起使用相同的算法进行签名验证。如果签名验证失败,则表示id token被篡改过或者是伪造的。
-
验证令牌的有效期:在载荷中,id token包含了发行时间(issued time)和过期时间(expiration time)。您需要检查当前时间是否在有效期范围内。如果当前时间在过期时间之后,说明id token已过期,不能再继续使用。
-
校验接收者:在载荷中,id token还可以包含一个接收者(audience)字段,用于指定该令牌的预期接收者。您可以检查接收者字段是否与您的应用程序的标识符匹配,以确保id token只能被合法的接收者使用。
-
可选的附加校验:根据您的需求,您还可以进行其他的校验,例如验证签发者(issuer)字段、检查令牌是否被撤销等。
需要注意的是,为了保证安全性,您应该将密钥存储在安全的位置,并定期更换密钥以防止泄露和滥用。此外,使用受信任的JWT库来处理JWT的解码和验证操作,而不是自行编写代码,以确保正确性和安全性。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.22</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
package cn.huali.fawork.constant;
import cn.huali.fawork.exception.SelfException;
import cn.huali.fawork.utils.Base64Util;
import cn.huali.fawork.utils.HttpsUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import org.apache.commons.codec.binary.Base64;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.HashMap;
import java.util.Map;
public class AppleAuthorizationConfig {
private static final String APPLE_HOST_URL = "https://appleid.apple.com";
private static final String APPLE_PUB_KEY_ENDPOINT = "https://appleid.apple.com/auth/keys";
private static final String APPLE_AUTH_TOKEN_ENDPOINT = "https://appleid.apple.com/auth/token";
private static final String Apple_Client_Id = "填上你的clientId";
public static AppleAuthCheckResult checkAuth(String idToken, long unixtimeAt) throws UnsupportedEncodingException {
AppleAuthCheckResult result = new AppleAuthCheckResult();
result.isOK = false;
try {
String[] identityTokens = idToken.split("\\.");
String headerStr = new String(Base64Util.decodeWithUTF8(identityTokens[0]));
JSONObject jsonObjectHeader = JSON.parseObject(headerStr);
String contentStr = new String(Base64Util.decodeWithUTF8(identityTokens[1]));
JSONObject jsonObjectContent = JSON.parseObject(contentStr);
System.out.println(headerStr);
System.out.println(contentStr);
String kid = jsonObjectHeader.getString("kid");
String alg = jsonObjectHeader.getString("alg");
String iss = jsonObjectContent.getString("iss");
String aud = jsonObjectContent.getString("aud");
String exp = jsonObjectContent.getString("exp");
String iat = jsonObjectContent.getString("iat");
String sub = jsonObjectContent.getString("sub");
String nonce = jsonObjectContent.getString("nonce");
String c_hash = jsonObjectContent.getString("c_hash");
String email = jsonObjectContent.getString("email");
String email_verified = jsonObjectContent.getString("email_verified");
boolean is_private_email = jsonObjectContent.getBooleanValue("is_private_email");
String auth_time = jsonObjectContent.getString("auth_time");
String nonce_supported = jsonObjectContent.getString("nonce_supported");
result.email = email;
result.sub = sub;
result.is_private_email = is_private_email;
result.tokenPayload = contentStr;
JSONObject publicKey = getPublicKey(APPLE_PUB_KEY_ENDPOINT, kid);
PublicKey rsaPublicKey = getRSAPublicKey(publicKey.getString("n"), publicKey.getString("e"));
// require部分,是jwt自动帮你校验,如果校验不通过,会报异常
// 切记不要在require部分校验auth_time和iat两部分,Jwt有bug,会报异常的,所以还是手动校验比较好
JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(rsaPublicKey)
.requireAudience(Apple_Client_Id) //一般是项目包名称
.requireIssuer(APPLE_HOST_URL) //固定值
//.require("auth_time", auth_time) //这里做了个简单的验证,如果auth_time == iat则是有效的。
.require("email", email)
.require("sub", sub)
.build();
Jws<Claims> claimsJws = jwtParser.parseClaimsJws(idToken);
Claims claims = claimsJws.getBody();
if (!claims.get("auth_time").toString().equalsIgnoreCase(auth_time)) {
result.isOK = false;
result.msg = "auth_time不一致";
} else if (!claims.get("iat").toString().equalsIgnoreCase(iat)) {
result.isOK = false;
result.msg = "iat不一致";
} else if (!auth_time.equalsIgnoreCase(iat)) {
result.isOK = false;
result.msg = "iat和auth_time不一致";
} else if (!claims.get("exp").toString().equalsIgnoreCase(exp)) {
result.isOK = false;
result.msg = "exp不一致";
} else if (Long.parseLong(exp) < unixtimeAt) {
result.isOK = false;
result.msg = "exp已过期";
}
result.isOK = true;
result.msg = "";
}
catch (SelfException e) {
result.isOK = false;
result.msg = e.msg;
}
catch (Exception e) {
result.isOK = false;
result.msg = e.getMessage();
}
return result;
}
public static class AppleAuthCheckResult {
public boolean isOK;
public String msg;
/**
* 用户唯一账号
*/
public String sub;
public String email;
public boolean is_private_email;
public String tokenPayload;
}
private static volatile Map<String, JSONObject> pubKeyMap = new HashMap<>();
private static PublicKey getRSAPublicKey(String modulus, String exponent) {
try {
BigInteger bigModule = new BigInteger(1, Base64.decodeBase64(modulus));
BigInteger bigExponent = new BigInteger(1, Base64.decodeBase64(exponent));
RSAPublicKeySpec keySpec = new RSAPublicKeySpec(bigModule, bigExponent);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);
return publicKey;
} catch (Exception e) {
return null;
}
}
/**
* {
* "keys": [
* {
* "kty": "RSA",
* "kid": "W6WcOKB",
* "use": "sig",
* "alg": "RS256",
* "n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw",
* "e": "AQAB"
* },
* {
* "kty": "RSA",
* "kid": "fh6Bs8C",
* "use": "sig",
* "alg": "RS256",
* "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
* "e": "AQAB"
* },
* {
* "kty": "RSA",
* "kid": "lVHdOx8ltR",
* "use": "sig",
* "alg": "RS256",
* "n": "nXDu9MPf6dmVtFbDdAaal_0cO9ur2tqrrmCZaAe8TUWHU8AprhJG4DaQoCIa4UsOSCbCYOjPpPGGdE_p0XeP1ew55pBIquNhNtNNEMX0jNYAKcA9WAP1zGSkvH5m39GMFc4SsGiQ_8Szht9cayJX1SJALEgSyDOFLs-ekHnexqsr-KPtlYciwer5jaNcW3B7f9VNp1XCypQloQwSGVismPHwDJowPQ1xOWmhBLCK50NV38ZjobUDSBbCeLYecMtsdL5ZGv-iufddBh3RHszQiD2G-VXoGOs1yE33K4uAto2F2bHVcKOUy0__9qEsXZGf-B5ZOFucUkoN7T2iqu2E2Q",
* "e": "AQAB"
* }
* ]
* }
*
* @param url
* @param kid
* @return
*/
private static JSONObject getPublicKey(String url, String kid) {
if (!pubKeyMap.containsKey(kid)) {
String allPubKeyJsonStr = getPublicKeyFromServer(url);
JSONObject jsonObjectAllPubKey = JSON.parseObject(allPubKeyJsonStr);
JSONArray keysArray = jsonObjectAllPubKey.getJSONArray("keys");
if (keysArray.size() > 0) {
pubKeyMap.clear();
for (int i = 0; i < keysArray.size(); i++) {
JSONObject key = keysArray.getJSONObject(i);
String tmpKid = key.getString("kid");
pubKeyMap.put(tmpKid, key);
}
}
}
JSONObject keyJsonObject = pubKeyMap.getOrDefault(kid, null);
if (keyJsonObject == null) {
throw new SelfException("没有找到PublicKey:kid=" + kid);
}
return keyJsonObject;
}
private static String getPublicKeyFromServer(String url) {
HttpsUtils.HttpRsp httpRsp = HttpsUtils.get(url);
if (httpRsp.statusCode != 200) {
throw new SelfException("获取PublicKey出错:" + httpRsp.statusCode + "," + httpRsp.statusDesc);
}
return httpRsp.content;
}
}
package cn.huali.fawork.utils;
import java.io.UnsupportedEncodingException;
import java.util.Base64;
public class Base64Util {
public static String encode(byte[] bytes) {
byte[] newBytes = Base64.getEncoder().encode(bytes);
String content = new String(newBytes);
return content;
}
public static byte[] decode(String content) {
byte[] newBytes = Base64.getDecoder().decode(content.getBytes());
return newBytes;
}
public static String encodeWithUTF8(byte[] bytes) throws UnsupportedEncodingException {
byte[] newBytes = Base64.getEncoder().encode(bytes);
String content = new String(newBytes, "UTF-8");
return content;
}
public static byte[] decodeWithUTF8(String content) throws UnsupportedEncodingException {
byte[] newBytes = Base64.getDecoder().decode(content.getBytes("UTF-8"));
return newBytes;
}
}
/**
*
*/
package cn.huali.fawork.utils;
import javax.net.ssl.HttpsURLConnection;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
/**
* @author Administrator
*
*/
public class HttpsUtils {
public static HttpRsp get(String url) {
return doGet(url, null, null);
}
public static HttpRsp get(String url, String param) {
return doGet(url, param, null);
}
public static HttpRsp get(String url, Map<String, String> param) {
return doGet(url, makeParam(param), null);
}
public static HttpRsp get(String url, Map<String, String> param, Map<String, String> headMap) {
return doGet(url, makeParam(param), headMap);
}
public static HttpRsp get(String url, String param, Map<String, String> headMap) {
return doGet(url, param, headMap);
}
private static HttpRsp doGet(String url, String param, Map<String, String> headMap) {
HttpRsp rsp = new HttpRsp();
BufferedReader in = null;
try {
if (param != null && param.isEmpty() == false) {
if (url.endsWith("&") || url.endsWith("?")) {
url += param;
} else if (url.contains("?")) {
url += "&" + param;
} else {
url += "?" + param;
}
}
HttpURLConnection connection = (HttpURLConnection) (new URL(url)).openConnection();
connection.setRequestMethod("GET");
connection.setDoInput(true);
if (headMap != null && headMap.isEmpty() == false) {
// 设置包头
Iterator<Entry<String, String>> it = headMap.entrySet().iterator();
Entry<String, String> entry = null;
while (it.hasNext()) {
entry = it.next();
System.out.println(entry.getKey() + ":" + entry.getValue());
connection.setRequestProperty(entry.getKey(), entry.getValue());
}
}
in = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));
StringBuffer sb = new StringBuffer();
String line;
while ((line = in.readLine()) != null) {
sb.append(line + System.lineSeparator());
}
rsp.content = sb.toString();
rsp.headerFieldsMap = connection.getHeaderFields();
rsp.statusCode = connection.getResponseCode();
rsp.statusDesc = connection.getResponseMessage();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} finally {
try {
if (in != null) {
in.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
return rsp;
}
public static HttpRsp post(String url) {
return doPost(url, null, null);
}
public static HttpRsp post(String url, String param) {
return doPost(url, param, null);
}
public static HttpRsp post(String url, Map<String, String> param) {
return doPost(url, makeParam(param), null);
}
public static HttpRsp post(String url, Map<String, String> param, Map<String, String> headMap) {
return doPost(url, makeParam(param), headMap);
}
public static HttpRsp post(String url, String param, Map<String, String> headMap) {
return doPost(url, param, headMap);
}
private static HttpRsp doPost(String url, String param, Map<String, String> headMap) {
HttpRsp rsp = new HttpRsp();
PrintWriter out = null;
BufferedReader in = null;
try {
HttpURLConnection connection = (HttpURLConnection) (new URL(url)).openConnection();
connection.setRequestMethod("POST");
connection.setDoInput(true);
connection.setDoOutput(true);
if (headMap != null && headMap.isEmpty() == false) {
// 设置包头
Iterator<Entry<String, String>> it = headMap.entrySet().iterator();
Entry<String, String> entry = null;
while (it.hasNext()) {
entry = it.next();
connection.setRequestProperty(entry.getKey(), entry.getValue());
}
}
if (param != null && param.isEmpty() == false) {
out = new PrintWriter(connection.getOutputStream());
out.print(param);
out.flush();
}
in = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));
StringBuffer sb = new StringBuffer();
String line;
while ((line = in.readLine()) != null) {
sb.append(line + System.lineSeparator());
}
rsp.content = sb.toString();
rsp.headerFieldsMap = connection.getHeaderFields();
rsp.statusCode = connection.getResponseCode();
rsp.statusDesc = connection.getResponseMessage();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} finally {
try {
if (in != null) {
in.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
return rsp;
}
/**
* 将参数组织在一块
*
* @param map
* @return
*/
public static String makeParam(Map<String, String> map) {
StringBuffer sb = new StringBuffer();
Iterator<Entry<String, String>> it = map.entrySet().iterator();
Entry<String, String> entry = null;
if (it.hasNext()) {
entry = it.next();
sb.append(entry.getKey() + "=" + entry.getValue());
}
while (it.hasNext()) {
entry = it.next();
sb.append("&" + entry.getKey() + "=" + entry.getValue());
}
return sb.toString();
}
public static class HttpRsp {
public int statusCode;
public String statusDesc;
public String content;
public Map<String, List<String>> headerFieldsMap = new HashMap<String, List<String>>();
@Override
public String toString() {
return "statusCode=" + statusCode + ", statusDesc=" + statusDesc + ", content=" + content;
}
public List<String> getCookie() {
if (headerFieldsMap != null) {
return headerFieldsMap.get("Set-Cookie");
} else {
return null;
}
}
}
}
package cn.huali.fawork.exception;
/**
* 此处的异常一定要集成于RuntimeException,是为了数据库事务回滚,请不要改动
*/
public class SelfException extends RuntimeException {
public Integer code;
public String msg;
public Object data;
public SelfException(Exception e) {
super(e);
this.code = 1;
this.msg = e.getLocalizedMessage();
}
public SelfException(int code, String msg) {
super(msg);
this.code = code;
this.msg = msg;
}
public SelfException(String msg) {
super(msg);
this.code = 1;
this.msg = msg;
}
public SelfException(int code, String msg, Object data) {
super(msg);
this.code = code;
this.msg = msg;
this.data = data;
}
public String toString() {
return "code=" + code + ",msg=" + msg;
}
}
这段代码是关键,即验证id_token是否有效;也验证require部分的字段是否存在,是否一致。注意,如果验证不通过,这段代码会报Exception的,如果报了Exception说明id_token是无效的。
这一部分,你主要验证一下 auth_time 和 iat 两个时间是否过了期限,其它无所谓。我写的可代码可能有点多余,你自己看着办。
三、创建clientId
Sign In - Apple
1、创建一个Services IDs
看到没,这个Identifier就是clientId
后面我就不截图了,反正会关联一个 appId;也会填一个域名,域名就是前段登录的网页的域名;redirectURI或者returnURI,就是登录后跳转的页面,与客户端网页代码中的那个字段保持一致即可,一般对于usePopup=true时,这个字段用不上。
2、在AppId中,启用登录功能
点上图中的那个Edit按钮后,再进行配置