2026/2/12 5:33:17
网站建设
项目流程
网站制作二维码,北京汉邦未来网站建设有限公司,平阳企业网站建设,站酷设计网一、准备工作
登录https://developer.apple.com/account #xff0c;注册软件及其前端配置#xff0c;这里就不说了。简单讲一下后端配置。
这里填写的就是授权登录回调地址#xff0c;或者是苹果系统的登录地址
流程图
登录分两种#xff1a;…一、准备工作登录https://developer.apple.com/account注册软件及其前端配置这里就不说了。简单讲一下后端配置。这里填写的就是授权登录回调地址或者是苹果系统的登录地址流程图登录分两种1.安卓内置网页 2.苹果系统配置#serviceId用于非苹果系统绑定id apple.auth.apple.services-id #苹果系统登录绑定id app.apple.iap.bundle-id apple.auth.apple-urlhttps://appleid.apple.com apple.auth.jwks-endpointhttps://appleid.apple.com/auth/keys apple.auth.token-endpointhttps://appleid.apple.com/auth/token #登录/跳转地址 apple.auth.redirect-uri${project.base}${server.servlet.context-path}/auth/callbacks/sign_in_with_apple #团队id apple.auth.team-id #这个一般是前端提供的跳转app的相关信息 apple.auth.scheme #关于登录密钥的地址在resources底下的apple目录下 apple.auth.login.private-key-pathclasspath:apple/xxx.p8苹果工具类package xx.apple.client; import com.alibaba.fastjson.JSONObject; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import xxx.common.bizenum.DeviceTypeEnum; import xxx.common.enums.BusinessErrorEnum; import xxx.common.exception.BusinessException; import xxx.common.util.HttpUtil; import xxx.common.util.RedisKeyUtils; import xxx.infra.redsisson.RedissonMapper; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; import java.io.BufferedReader; import java.io.InputStreamReader; import java.math.BigInteger; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.RSAPublicKeySpec; import java.util.*; import java.util.concurrent.TimeUnit; /** * 生成苹果授权所需的 Client SecretJWT 格式基于 p8 私钥 */ Slf4j Component public class AppleAuthClient { Value(${apple.auth.team-id}) private String teamId; Value(${app.apple.iap.bundle-id}) private String clientId; Value(${apple.auth.login.key-id}) private String keyId; Value(${apple.auth.login.private-key-path}) private String privateKeyPath; Value(${apple.auth.token-endpoint}) private String appleTokenEndpointUrl; Value(${apple.auth.jwks-endpoint}) private String applePublicKeyUrl; Value(${apple.auth.apple-url}) private String appleUrl; Value(${apple.auth.redirect-uri}) private String appleRedirectUri; Value(${apple.auth.apple.services-id}) private String servicesId; Autowired private RedissonMapper redissonMapper; private final ResourceLoader resourceLoader; public AppleAuthClient(ResourceLoader resourceLoader) { this.resourceLoader resourceLoader; } ObjectMapper objectMapper new ObjectMapper(); /** * 生成 Client Secret有效期 180 天苹果最大支持 */ public String generateClientSecret(String deviceType) { // 2. 读取并解析EC类型p8私钥已修复的getPrivateKeyFromP8File方法 PrivateKey privateKey getPrivateKeyFromP8File(); // 3. 构建JWT Header核心修正alg必须是ES256适配EC私钥 MapString, Object jwtHeader new HashMap(2); jwtHeader.put(alg, ES256); // 苹果要求固定值ECDSA SHA-256绝对不能用RS256 jwtHeader.put(kid, keyId); // p8私钥的Key ID苹果后台生成p8时的ID // 4. 构建JWT Payload有效期建议缩短为5分钟苹果允许最长180天短有效期更安全 long now System.currentTimeMillis(); Date issuedAt new Date(now); // 修正有效期改为60分钟避免私钥泄露风险 Date expiresAt new Date(now 1000L * 60 * 60 ); // 5. 生成ES256签名的client_secret核心修正用ECDSA256算法传入EC私钥 try { // 关键Algorithm.ECDSA256适配EC私钥替代错误的RSA256 Algorithm algorithm Algorithm.ECDSA256(null, (java.security.interfaces.ECPrivateKey) privateKey); return JWT.create() .withHeader(jwtHeader) // 设置HeaderalgES256 kid .withIssuer(teamId) // 发行者苹果开发者Team ID必填 .withAudience(appleUrl) // 受众固定值https://appleid.apple.com必填 .withSubject(getClientId(deviceType)) // 主题你的Client IDBundle ID/Service ID必填 .withIssuedAt(issuedAt) // 签发时间必填 .withExpiresAt(expiresAt) // 过期时间必填最长180天 .sign(algorithm); // 签名传入ECDSA256算法替代错误的Signature对象 } catch (Exception e) { log.error(生成苹果client_secret失败, e); throw new BusinessException(apple client secret, BusinessErrorEnum.AUTH_FAILED); } } /** * 从 p8 文件中读取私钥 */ private PrivateKey getPrivateKeyFromP8File() { // 1. 读取.p8文件并过滤无效行核心去掉BEGIN/END标记、空行 StringBuilder keyBuilder new StringBuilder(); Resource resource resourceLoader.getResource(privateKeyPath); try (BufferedReader reader new BufferedReader(new InputStreamReader(resource.getInputStream()))) { String line; while ((line reader.readLine()) ! null) { // 过滤以----开头的行BEGIN/END标记、空行 if (!line.startsWith(----) !line.trim().isEmpty()) { keyBuilder.append(line.trim()); // 去掉行内多余空格 } } } catch (Exception e) { log.error(读取.p8私钥文件失败路径{}, privateKeyPath, e); throw BusinessException.businessException(private key, BusinessErrorEnum.AUTH_FAILED); } // 2. 校验读取的私钥内容是否为空 String privateKeyContent keyBuilder.toString(); if (StringUtils.isBlank(privateKeyContent)) { log.error(解析后的.p8私钥内容为空路径{}, privateKeyPath); throw BusinessException.businessException(private key, BusinessErrorEnum.AUTH_FAILED); } // 3. 解析EC私钥核心用EC算法 try { // Base64解码私钥内容.p8私钥是Base64编码的PKCS8格式 byte[] keyBytes Base64.getDecoder().decode(privateKeyContent); PKCS8EncodedKeySpec keySpec new PKCS8EncodedKeySpec(keyBytes); // 关键使用EC算法的KeyFactory KeyFactory keyFactory KeyFactory.getInstance(EC); PrivateKey privateKey keyFactory.generatePrivate(keySpec); log.info(解析苹果.p8 EC私钥成功); return privateKey; } catch (Exception e) { log.error(解析.p8 EC私钥失败内容{}, privateKeyContent, e); throw BusinessException.businessException(private key, BusinessErrorEnum.AUTH_FAILED); } } /** * 核心新增调用苹果接口用code兑换token */ public JSONObject exchangeCodeForToken(String code, String deviceType) { //检测code有没有被使用过使用过直接抛出异常code只能使用一次 if (redissonMapper.get(RedisKeyUtils.getThirdPartyCodeDeviceTypeCacheKey(code,deviceType))!null){ throw BusinessException.businessException(apple code, BusinessErrorEnum.AUTH_FAILED); } // 生成苹果要求的client_secretES256算法 String clientSecret generateClientSecret(deviceType); // 构造请求参数 MapString, String params new HashMap(); params.put(grant_type, authorization_code); params.put(code, code); params.put(client_id, getClientId(deviceType)); params.put(client_secret, clientSecret); params.put(redirect_uri, appleRedirectUri); // 调用苹果Token接口这里用Hutool的HttpUtil你可替换为项目中的HTTP工具 String response HttpUtil.doPostForm(appleTokenEndpointUrl, params); JSONObject result JSONObject.parseObject(response); // 检查苹果返回的错误 if (result.containsKey(error)) { String error result.getString(error); log.error(苹果code兑换token返回错误code:{}, error:{}, code, error); throw BusinessException.businessException(apple id_token, BusinessErrorEnum.COMMAND_EXECUTION_FAILED); } // 缓存code redissonMapper.set(RedisKeyUtils.getThirdPartyCodeDeviceTypeCacheKey(code,deviceType),1,10,TimeUnit.MINUTES); return result; } /** * 验证苹果 id_token 的合法性 */ public DecodedJWT verifyAppleIdToken(String idToken, String deviceType) { // 解析 id_token先不验证获取 kid 用于获取公钥实际生产可缓存苹果公钥提升性能 DecodedJWT decodedJWT JWT.decode(idToken); // 验证核心信息iss、aud、exp if (!appleUrl.equals(decodedJWT.getIssuer())) { // throw new RuntimeException(id_token 发行者非法); log.error(id_token 发行者非法); throw BusinessException.businessException(id_token, BusinessErrorEnum.COMMAND_EXECUTION_FAILED); } if (!getClientId(deviceType).equals(decodedJWT.getAudience().get(0))) { // throw new RuntimeException(id_token 受众非法); log.error(id_token 受众非法); throw BusinessException.businessException(id_token, BusinessErrorEnum.COMMAND_EXECUTION_FAILED); } if (decodedJWT.getExpiresAt().before(new Date())) { //throw new RuntimeException(id_token 已过期); log.error(id_token 已过期); throw BusinessException.businessException(id_token, BusinessErrorEnum.COMMAND_EXECUTION_FAILED); } // 注完整验证需通过苹果 jwks 接口获取公钥验证签名此处简化生产环境必须实现公钥验证 // 完整实现可参考通过 https://appleid.apple.com/auth/keys 获取公钥根据 kid 匹配用 RSA256 验证签名 return decodedJWT; } /** * 完整验证苹果通知的JWT含kid全量验证 * 核心逻辑1.解码获取kid 2.验证kid有效性 3.获取对应公钥 4.验证签名有效期iss 5.返回解析后的JWT */ public JwsClaims validateAppleNotification(String rawJson) { try { // Step 1: 必须先从原始 JSON 中提取出 payload 对应的那串 JWT 字符串 ObjectMapper mapper new ObjectMapper(); JsonNode rootNode mapper.readTree(rawJson); String token rootNode.get(payload).asText(); // Step 2: 获取 kid (不验签仅解析 Header) // 注意split 后取第一段 Base64 解码是最直接的 String[] chunks token.split(\\.); String headerJson new String(Base64.getUrlDecoder().decode(chunks[0])); String kid mapper.readTree(headerJson).get(kid).asText(); // Step 3: 获取公钥 RSAPublicKey publicKey getApplePublicKeyFromRedis(kid); // Step 4: 验证签名 (核心改用 parseClaimsJws) JwsClaims jws Jwts.parserBuilder() .setSigningKey(publicKey) .requireIssuer(https://appleid.apple.com) .build() .parseClaimsJws(token); // --- 这里绝对不能用 parseClaimsJwt // Step 5: 提取事件内容 (Apple 的 events 字段是一个转义的 JSON 字符串) Claims claims jws.getBody(); String eventsStr claims.get(events, String.class); JsonNode eventNode mapper.readTree(eventsStr); String type eventNode.get(type).asText(); String sub eventNode.get(sub).asText(); // 用户唯一 ID log.info(验证成功用户 {} 执行了 {} 操作, sub, type); return jws; } catch (ExpiredJwtException e) { log.error(JWT 已过期); throw e; } catch (Exception e) { log.error(验证失败: {}, e.getMessage()); throw new RuntimeException(Apple JWT validation failed); } } private RSAPublicKey getApplePublicKeyFromRedis(String kid) throws Exception { // 步骤1从Redis读取缓存的JWK Set24小时过期 String applePublicKeyCacheKey RedisKeyUtils.getApplePublicKeyCacheKey(kid); Object jwkSetJson redissonMapper.get(applePublicKeyCacheKey); // 步骤2缓存未命中/过期 → 请求苹果服务器并更新Redis if (jwkSetJson null) { jwkSetJson fetchJwkSetFromAppleServer(); // 存入Redis设置24小时过期和原cached(24小时)逻辑一致 redissonMapper.set(applePublicKeyCacheKey, jwkSetJson, 24, TimeUnit.HOURS); log.info(苹果JWK Set已存入Redis24小时后过期); } // 步骤3解析JWK Set匹配kid获取公钥 return parsePublicKeyFromJwkSet(jwkSetJson.toString(), kid); } /** * 从苹果服务器获取JWK Set原生HTTP请求替代JwkProvider的网络请求 */ private String fetchJwkSetFromAppleServer() throws Exception { URL url new URL(applePublicKeyUrl); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(GET); conn.setConnectTimeout(5000); conn.setReadTimeout(5000); try { if (conn.getResponseCode() ! 200) { throw new Exception(请求苹果公钥失败响应码 conn.getResponseCode()); } // 读取响应内容苹果返回的JWK Set JSON try (var reader new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { StringBuilder sb new StringBuilder(); String line; while ((line reader.readLine()) ! null) sb.append(line); return sb.toString(); } } finally { conn.disconnect(); } } /** * 解析JWK Set根据kid提取RSAPublicKey替代JwkProvider的解析逻辑 */ private RSAPublicKey parsePublicKeyFromJwkSet(String jwkSetJson, String kid) throws Exception { // 解析苹果返回的{keys: [...]}结构 MapString, Object jwkSetMap objectMapper.readValue(jwkSetJson, new TypeReferenceMapString, Object() {}); ListMapString, Object keys (ListMapString, Object) jwkSetMap.get(keys); // 遍历匹配kid转换为RSAPublicKey for (MapString, Object keyMap : keys) { if (kid.equals(keyMap.get(kid))) { // 解码JWK的n模数和e指数 Base64.Decoder decoder Base64.getUrlDecoder(); BigInteger n new BigInteger(1, decoder.decode((String) keyMap.get(n))); BigInteger e new BigInteger(1, decoder.decode((String) keyMap.get(e))); // 生成RSAPublicKey RSAPublicKeySpec spec new RSAPublicKeySpec(n, e); return (RSAPublicKey) KeyFactory.getInstance(RSA).generatePublic(spec); } } return null; } /** * 根据不同的设备获取苹果登录的clientId */ public String getClientId(String loginType) { if (DeviceTypeEnum.IOS.getCode().equals(loginType)){ return clientId; }else { return servicesId; } } /** * 缓存第三方的token信息 */ public void cacheToken(String key,JSONObject jsonObject,Date expiration) { // 计算过期秒数苹果 id_token 的过期时间 - 当前时间 long expireSeconds (expiration.getTime() - System.currentTimeMillis()) / 1000; if (expireSeconds 0) { throw new RuntimeException(id_token 已过期); } // 设置值 过期时间秒 redissonMapper.set(key, jsonObject,expireSeconds, TimeUnit.SECONDS); log.info(Redisson 存储 id_token 成功key:{}过期时间:{} 秒, key, expireSeconds); } }package xxx.common.util; import xxx.common.bizenum.DeviceTypeEnum; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; /** * 请求头参数提取工具类 */ Slf4j public class RequestUtils { // 设备系统 deviceType private static final String DEVICE_TYPE_HEADER deviceType; // 设备版本号 appVersion private static final String APP_VERSION_HEADER appVersion; // 设备模式 deviceModel private static final String DEVICE_MODEL_HEADER deviceModel; // 系统版本号 systemVersion private static final String SYSTEM_VERSION_HEADER systemVersion; /** * 获取当前请求对象 */ public static HttpServletRequest getRequest() { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); return attributes ! null ? attributes.getRequest() : null; } /** * 直接获取 deviceType * return deviceType 的字符串值如果不存在则返回 null */ public static String getDeviceType() { return getHeader(DEVICE_TYPE_HEADER) null ? DeviceTypeEnum.IOS.getCode() : getHeader(DEVICE_TYPE_HEADER); } /** * 获取 appVersion */ public static String getAppVersion() { return getHeader(APP_VERSION_HEADER); } /** * 获取 deviceModel */ public static String getDeviceModel() { return getHeader(DEVICE_MODEL_HEADER); } /** * 获取 systemVersion */ public static String getSystemVersion() { return getHeader(SYSTEM_VERSION_HEADER); } /** * 通用的获取 Header 方法 * param headerName 参数名 * return 参数值 */ public static String getHeader(String headerName) { HttpServletRequest request getRequest(); if (request null) { return null; } return request.getHeader(headerName); } }相应枚举package xxx.apple.enums; import lombok.AllArgsConstructor; import lombok.Getter; /** * 苹果回调事件 * * author jpwang18 * version 1.0.0 * since 2026/1/23 */ Getter AllArgsConstructor public enum AppleNoticeEventTypeEnum { //consent-revoked CONSENT_REVOKED(consent-revoked,用户手动取消授权) //account-deleted ,ACCOUNT_DELETED(account-deleted,用户删除账号); private String code; private String desc; }package xxx.common.bizenum; import lombok.AllArgsConstructor; import lombok.Getter; /** * 登录类型枚举 */ Getter AllArgsConstructor public enum LoginTypeEnum { /* * 登录类型email-邮箱密码google-谷歌apple-苹果*/ EMAIL(email, 邮箱密码), GOOGLE(google, 谷歌), APPLE(apple, 苹果); /** * 状态码 */ private final String name; /** * 描述 */ private final String desc; }接口代码/** * 苹果授权登录需要的回调接收地址 * 仅接收 code 参数移除 id_token 相关处理 */ PostMapping(/callbacks/sign_in_with_apple) public ResponseEntityString callbacksSignInWithApple(RequestParam(value code, required true) String code) { // 调整 baseUrl只保留 code 的占位符移除 id_token 相关部分 String baseUrl intent://callback?code%s#Intent;package applePackageName ;scheme appleAuthScheme ;end; // 构造返回给 Flutter 应用的 Intent URL仅传递 code 参数 String intentUrl String.format(baseUrl, code); // 返回包含 Intent URL 的响应跳转到 Flutter 应用 return ResponseEntity.status(302).header(Location, intentUrl).build(); } /** * 苹果授权登录接口 * * param loginRequest 前端登录请求参数携带 identityToken * return 登录结果包含用户信息或错误提示 */ PostMapping(/apple/login) public ResultModelJwtResp loginByApple(RequestBody Validated AppleLoginReq loginRequest) { String deviceType RequestUtils.getDeviceType(); if (StringUtils.isBlank(deviceType)){ throw BusinessException.businessException(deviceType, BusinessErrorEnum.DATA_EXIST); } AppleAuthDto appleAuthDto appleLoginReqMapper.to(loginRequest); appleAuthDto.setDeviceType(deviceType); JwtDto jwtDto appleAuthService.handleAppleAuth(appleAuthDto); return ResultModel.success(jwtMapper.to(jwtDto)); } /** * 苹果授权登录通知接口 * * param payload 苹果授权登录通知参数 * return 响应结果 */ PostMapping(/apple/revoke/callback) public ResponseEntityVoid handleAppleCallback(RequestBody String payload) { log.info(苹果授权登录通知开始); appleAuthService.handleNotification(payload); return ResponseEntity.ok().build(); }创作不易如需引用博客内容请注明出处感谢支持