背景
之前有介绍过jwt(JSON Web Token)
,感兴趣的可以查看。本期以实际的AppleId登录集成为例,介绍下jwt
的应用,对于需要集成AppleId登录的开发者也有一定帮助。目前(2020年11月),苹果已要求在APP提交审核时,如果要集成第三方比如微信等登录,必须以集成AppleId登录为前提。我们在业务场景中就遇到类似情况,网上很多文章描述了对于客户端的请求验证,比较少涉及向icloud认证,这篇文章将两方面都有提到,按照顺序进行,希望能给读者带来帮助。
准备工作
项目服务端使用java开发,所以首先引入依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
</dependency>
<!--使用jackson, gson反序列化int/long有问题-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
</dependency>
- 注意这里序列化还是使用
jjwt-jackson
。本来项目中使用gson,想统一,但是遇到jjwt-gson
,序列化int
为double
,如16838383E9
等格式,导致验证失败的问题。- 对于项目中使用的各种私钥,id,项目id等,需要在苹果开发者后台查看。
AppleId登录
流程描述
实际的可以参考苹果官网的示意图:
verify user
简单描述下。首先,客户使用SDK访问苹果服务,获取到用户信息。主要会得到一个identityToken
(jwt)和code
,其中code
5分钟有效。然后访问服务端,服务端第一步验证客户端传入的identityToken
是否有效。如果失败,则返回客户端;如果有效,再构造请求,带着code
访问苹果服务,验证请求的合法性。下面详细描述下具体的实现。
获取苹果公钥
首先根据苹果开放的公钥构造PublicKey
,这个key基本上是不变的,可以保存到本地,或者加到缓存里。具体作用是验证客户端传入的identityToken
。这里我加了个缓存,仍然请求苹果公钥地址来生成。
- 公钥地址
https://appleid.apple.com/auth/keys
- 公钥内容示例
实际内容在公钥地址可以取到,包括kid,算法名称,模数,指数等。
{
"keys": [
{
"kty": "RSA",
"kid": "86D88Kf",
"use": "sig",
"alg": "RS256",
"n": "iGaLq...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "eXaunmL",
"use": "sig",
"alg": "RS256",
"n": "4dGQ7bQK..."
"e": "AQAB"
}
]
}
生成公钥代码:
//依赖引入
import io.jsonwebtoken.*;
import io.jsonwebtoken.impl.DefaultJwtBuilder;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.joda.time.Days;
import org.joda.time.Hours;
import org.springframework.util.Assert;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.EncodedKeySpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
//常量地址
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 LoadingCache<String, PublicKey> cache =
CacheBuilder.newBuilder().refreshAfterWrite(Hours.SIX.getHours(), TimeUnit.HOURS).build(new CacheLoader<String, PublicKey>() {
@Override
public PublicKey load(String key) throws Exception {
ApplePublicKey applePublicKey = getApplePublicKey().getByKid(key);
if (null == applePublicKey) {
return null;
}
return getRSAPublicKey(applePublicKey.getN(), applePublicKey.getE());
}
});
//请求苹果公钥地址,生成自定义的描述对象
private static ApplePubKeys getApplePublicKey() {
String resp = HttpClientUtil.sendHttpGetRequest("https://appleid.apple.com/auth/keys");
ApplePubKeys keys = new Gson().fromJson(resp, ApplePubKeys.class);
return keys;
}
//根据公钥的参数生成RSA PublicKey
/**
* @param modulus 模数 n
* @param exponent 指数 e
* @return
*/
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;
}
}
参数对象定义
//对应客户端返回的identityToken解析对象
@Data
public static class AppleIdentityToken {
String aud;
String sub;
String c_hash;
boolean email_verified;
long auth_time;
String iss;
long exp;
long iat;
String email;
AppleIdentityToken.Header header;
@Data
public static class Header {
String alg;
String kid;
}
}
//公钥对象
@Data
static class ApplePubKeys {
List<ApplePublicKey> keys;
//通过kid获取key
public ApplePublicKey getByKid(String kid) {
return keys.stream().filter(e -> e.getKid().equals(kid)).findFirst().orElse(null);
}
}
//公钥实体
@Data
public static class ApplePublicKey {
String kty;
String kid;
String use;
String alg;
String n;
String e;
}
验证客户端请求
服务端验证客户端请求主要是验证传入的identityToken
即jwt
是否有效。使用引入的依赖jjwt
工具进行验证。
首先将identityToken
依据jwt格式解析出来,生成对象。然后根据header
中指定的RSA
公钥id, 获取对应公钥验证是否有效。包括一些固定参数,是否过期等,可以自己定制修改校验逻辑。如果校验通过,则返回sub
字段,这个含义类似于openId
,可以作为App下的用户标识。
注意很多文章只描述了这部分内容,目前只是对客户端的验证请求的内容校验,核心主要是依据公钥对identityToken的校验。这个jwt的校验没有网络交互,因此也没有涉及到服务端和苹果服务交互的逻辑。
- identityToken解析结果示例
其中的sub即可以认为是openId,作为用户标识。
//header
{
"kid": "86D88Kf",
"alg": "RS256"
}
//payload
{
"iss": "https://appleid.apple.com",
"aud": "com.project.myApp",
"exp": 1605601331,
"iat": 1605514931,
"sub": "000841.68aec4b5ad874e2196643fffffff7ee9.1020",
"c_hash": "8c4qj4N9pjaXtUWV2-jF2g",
"email": "abcdefghijk@privaterelay.appleid.com",
"email_verified": "true",
"is_private_email": "true",
"auth_time": 1605514931,
"nonce_supported": true
}
- 参考代码如下:
//解析jwt
private static AppleIdentityToken getAppleIdentityToken(String jwt) {
String[] arr = jwt.split("\\.");
String tokenBase64 = arr[1];
String headerBase64 = arr[0];
String token = new String(Base64.decodeBase64(tokenBase64), StandardCharsets.UTF_8);
String header = new String(Base64.decodeBase64(headerBase64), StandardCharsets.UTF_8);
AppleIdentityToken.Header tokenHeader = new Gson().fromJson(header, AppleIdentityToken.Header.class);
AppleIdentityToken identityToken = new Gson().fromJson(token, AppleIdentityToken.class);
identityToken.setHeader(tokenHeader);
return identityToken;
}
/**
* 验证客户端identityToken参数
*
* @param jwt
* @return
*/
public static Pair<Boolean, String> verify(String jwt) {
AppleIdentityToken identityToken = null;
try {
identityToken = getAppleIdentityToken(jwt);
PublicKey publicKey = cache.getUnchecked(identityToken.getHeader().getKid());
if (null == publicKey) {
return Pair.of(false, "系统异常");
}
JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(publicKey)
.requireAudience("com.project.myApp") //一般是项目包名称
.requireIssuer("https://appleid.apple.com") //固定值
.require("auth_time", identityToken.getIat()) //这里做了个简单的验证,如果auth_time == iat则是有效的。
.build();
Jws<Claims> claimsJws = jwtParser.parseClaimsJws(jwt);
Claims claims = claimsJws.getBody();
//验证是否过期,
if (!claims.getExpiration().before(new Date()) && StringUtils.isNotBlank(identityToken.getSub())) {
log.error("ios verify fail. exp:{}", claims.getExpiration());
return Pair.of(true, identityToken.getSub());
}
} catch (Exception e) {
log.error("verify jwt error token:{}.", identityToken, e);
}
return Pair.of(false, "验证失败");
}
与Apple服务验证用户
在以上对客户内容的校验通过后,继续将传入的code
传递给苹果服务做校验。注意code
只5分钟有效,且只能有效验证一次
描述下流程,这里会用到苹果给app的私钥,可以在开发者后台下载,是一个.p8
的文件,里边是文本,读取后构建成用于椭圆曲线加密的私钥(ES256)。过程是利用私钥给包名,teamId等签名,然后和code
构造请求,发送给苹果服务,获取验证结果。验证成功的返回中有个id_token
字段,可以解析jwt
获得需要的内容,包括sub
的用户标识,当然也可以用返回头部的公钥id,再次使用公钥验证苹果的请求是否有效。这里我没有处理,
详细参见代码注释。
- 请求地址
https://appleid.apple.com/auth/token
- 请求成功返回
{
"access_token": "a2b23033d2558470fb39b542904fd1762.0.rsqtt.F3A6KMAYu2XxDVVEoWSOyg",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rbf69049aac814a9493f2c391cea17481.0.rsqtt.axQF5QaifTcrmmrhk9HhXw",
"id_token": "eyJraW...."
}
- 请求失败返回
失败包含几种情况,举例说明
{"error": "invalid_request"} //缺失必要参数
{"error": "invalid_client"} //一般是参数错误
{"error": "invalid_grant"} //校验失败,比如code过期
- 参考代码如下:
//验证code和openId是否有效,openId对应sub。
public static boolean authorize(String openId, String code) {
String clientId = "com.project.myApp";
boolean result = false;
try {
String clientSecret = buildJwt("teamId", clientId, "privite_kid"); //teamId, 私钥kid都是10个字符长度,在开发者后台获取。
TokenResponse response = authorizeToken(clientId, clientSecret, code, GrantType.AuthorizationCode.getValue(), "", "");
if (StringUtils.isNotBlank(response.getError())) {
log.error("get access token from apple error, msg:{}.", response.getError());
} else {
String idToken = response.getId_token();
AppleIdentityToken identityToken = getAppleIdentityToken(idToken);
//这里只判断苹果返回的sub是否和openId相等,以及返回的aud是否和clientId相等。
result = openId.equals(identityToken.getSub()) && identityToken.getAud().equals(clientId);
}
} catch (Exception e) {
log.error("authorize code with apple error, openId:{}", openId, e);
}
if (!result) {
log.error("authorize code with apple failed, openId:{}", openId);
}
return result;
}
//创建私钥,读取文件获取
private static Key getPrivateKey() {
try (InputStream in = AppleVerifyUtil.class.getClassLoader().getResourceAsStream("private.p8")) {
String data = IOUtils.toString(in, StandardCharsets.UTF_8);
List<String> lines = Arrays.stream(data.split("\n")).collect(Collectors.toList());
StringBuilder keyValue = new StringBuilder();
for (String s : lines) {
if (s.startsWith("---")) {
continue;
}
keyValue.append(s);
}
//类型注意是椭圆曲线EC
KeyFactory factory = KeyFactory.getInstance("EC");
EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.decodeBase64(keyValue.toString().replaceAll("\\n", "")));
PrivateKey privateKey = factory.generatePrivate(keySpec);
return privateKey;
} catch (Exception e) {
return null;
}
}
//请求苹果服务
//clientId 一般是包名
//clientSecret 是私钥签名生成的jwt字符串
//code是客户端传入的
//grantType 有 authorization_code 和 refresh_token,这里只用到authorization_code
//refreshToken 和 redirectUri 这里留空
public static TokenResponse authorizeToken(String clientId, String clientSecret, String code, String grantType, String refreshToken, String redirectUri) {
try {
Map<String, Object> postForm = new HashMap<>();
postForm.put("client_id", clientId);
postForm.put("client_secret", clientSecret);
postForm.put("code", code);
postForm.put("grant_type", grantType);
postForm.put("refresh_token", refreshToken);
postForm.put("redirect_uri", redirectUri);
Map<String, Object> headers = new HashMap<>();
headers.put("content-type", "application/x-www-form-urlencoded");
//请求http client 可以自己创建
String resp = HttpClientUtil.sendHttpPostRequest("https://appleid.apple.com/auth/token", headers, postForm);
if (StringUtils.isNotBlank(resp)) {
return new Gson().fromJson(resp, TokenResponse.class);
}
} catch (Exception e) {
log.error("retrieve access token error.", e);
}
return new TokenResponse();
}
/**
* 私钥加密后给苹果去验证,构造clientSecret,就是构造一个jwt字符串
*
* @return
*/
public static String buildJwt(String iss, String sub, String kid) {
Map<String, Object> header = new HashMap<>();
header.put("alg", SignatureAlgorithm.ES256.getValue()); //SHA256withECDSA
header.put("kid", kid);
long iat = System.currentTimeMillis() / 1000; //以秒为单位
Map<String, Object> claims = new HashMap<>();
claims.put("iss", iss);
claims.put("iat", iat);
claims.put("exp", iat + Days.SEVEN.toStandardSeconds().getSeconds()); //设置为7天过期
claims.put("aud", "https://appleid.apple.com"); //固定值
claims.put("sub", sub);
return new DefaultJwtBuilder().setHeader(header).setClaims(claims).signWith(privateKey, SignatureAlgorithm.ES256).compact();
}
//验证的两种请求类型
@AllArgsConstructor
@Getter
public enum GrantType {
AuthorizationCode("authorization_code"),
RefreshToken("refresh_token"),; //刷新token,这里没有用到,用来刷新accessToken。 貌似苹果限制了一天只能刷新一次。
String value;
}
//成功返回类型,注意将失败返回的error也放到同一个对象里,方便转换处理。
@Data
public static class TokenResponse {
//(Reserved for future use) A token used to access allowed data. Currently, no data set has been defined for access.
String access_token;
//The amount of time, in seconds, before the access token expires.
long expires_in; //过期时间,一般是3600,表示1小时,不是时间戳。
//A JSON Web Token that contains the user’s identity information.
String id_token; //返回的是一个jwt字符,可以解析,获得相应的数据。
//The refresh token used to regenerate new access tokens. Store this token securely on your server.
String refresh_token;
//The type of access token. It will always be bearer.
String token_type; //Bearer 固定值
//see ErrorResponse
/**
* A string that describes the reason for the unsuccessful request. The string consists of a single allowed value.
* Possible values: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope
*/
String error;
}
总结
以上就是本期的内容,目前实现了业务需求的功能。其中几个点还可以进一步根据工程需要做优化,比如公钥、私钥的生成和使用,以及补充一些安全措施;还有包括一些异常请求下的状态处理,以及redis替换LocalCache的可行性考虑等。感谢阅读。
网友评论