news 2026/2/14 6:03:01

苹果授权登录(后端)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
苹果授权登录(后端)

一、准备工作

登录https://developer.apple.com/account
,注册软件及其前端配置,这里就不说了。简单讲一下后端配置。

这里填写的就是授权登录回调地址,或者是苹果系统的登录地址

流程图

登录分两种:1.安卓内置网页 2.苹果系统

配置:

#serviceId用于非苹果系统绑定id apple.auth.apple.services-id= #苹果系统登录绑定id app.apple.iap.bundle-id= apple.auth.apple-url=https://appleid.apple.com apple.auth.jwks-endpoint=https://appleid.apple.com/auth/keys apple.auth.token-endpoint=https://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-path=classpath: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 Secret(JWT 格式,基于 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私钥) Map<String, 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) // 设置Header(alg=ES256 + kid) .withIssuer(teamId) // 发行者:苹果开发者Team ID(必填) .withAudience(appleUrl) // 受众:固定值https://appleid.apple.com(必填) .withSubject(getClientId(deviceType)) // 主题:你的Client ID(Bundle 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_secret(ES256算法) String clientSecret = generateClientSecret(deviceType); // 构造请求参数 Map<String, 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 Jws<Claims> 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) Jws<Claims> 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 Set(24小时过期) 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已存入Redis,24小时后过期"); } // 步骤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": [...]}结构 Map<String, Object> jwkSetMap = objectMapper.readValue(jwkSetJson, new TypeReference<Map<String, Object>>() {}); List<Map<String, Object>> keys = (List<Map<String, Object>>) jwkSetMap.get("keys"); // 遍历匹配kid,转换为RSAPublicKey for (Map<String, 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 ResponseEntity<String> 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 ResultModel<JwtResp> 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 ResponseEntity<Void> handleAppleCallback(@RequestBody String payload) { log.info("苹果授权登录通知开始"); appleAuthService.handleNotification(payload); return ResponseEntity.ok().build(); }

创作不易,如需引用博客内容,请注明出处,感谢支持!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/13 15:05:19

通义千问3-Reranker-0.6B在电商搜索中的惊艳效果展示

通义千问3-Reranker-0.6B在电商搜索中的惊艳效果展示 1. 开篇即见真章&#xff1a;一个搜索框背后的“精准力”革命 你有没有遇到过这样的情况&#xff1f;在电商App里搜“适合夏天穿的轻薄防晒衬衫”&#xff0c;结果首页跳出几件厚实牛仔外套&#xff0c;还有一款儿童防晒帽…

作者头像 李华
网站建设 2026/2/13 10:39:42

Elsevier Tracker:学术投稿进度自动化管理工具

Elsevier Tracker&#xff1a;学术投稿进度自动化管理工具 【免费下载链接】Elsevier-Tracker 项目地址: https://gitcode.com/gh_mirrors/el/Elsevier-Tracker 引言&#xff1a;学术投稿管理的现实挑战 学术出版过程中&#xff0c;投稿状态跟踪一直是科研人员面临的重…

作者头像 李华
网站建设 2026/2/13 18:25:22

Zemax光学设计实战:单透镜优化与性能分析

1. 单透镜设计需求与初始参数设置 刚接触Zemax时&#xff0c;设计一个简单的单透镜是个不错的起点。这次我们要设计的是一个F数为4、焦距100mm的N-BK7玻璃单透镜。这个案例虽然基础&#xff0c;但包含了光学设计的完整流程&#xff0c;特别适合新手理解Zemax的核心功能。 先来看…

作者头像 李华
网站建设 2026/2/11 22:29:58

3步攻克Degrees of Lewdity游戏本地化难题:完整解决方案

3步攻克Degrees of Lewdity游戏本地化难题&#xff1a;完整解决方案 【免费下载链接】Degrees-of-Lewdity-Chinese-Localization Degrees of Lewdity 游戏的授权中文社区本地化版本 项目地址: https://gitcode.com/gh_mirrors/de/Degrees-of-Lewdity-Chinese-Localization …

作者头像 李华
网站建设 2026/2/11 10:21:18

从零到六位半:开源万用表硬件设计的艺术与科学

从零到六位半&#xff1a;开源万用表硬件设计的艺术与科学 在电子测量领域&#xff0c;六位半精度的万用表一直被视为专业级的标杆设备。传统商用设备动辄数万元的价格让许多工程师和爱好者望而却步&#xff0c;而开源硬件的兴起为这一领域带来了全新的可能性。本文将深入探讨如…

作者头像 李华
网站建设 2026/2/10 22:07:49

简单三步部署Open-AutoGLM,效率提升翻倍

简单三步部署Open-AutoGLM&#xff0c;效率提升翻倍 你是否曾为重复操作手机而疲惫不堪&#xff1f; “打开微信→点开朋友圈→长按图片→保存→切到小红书→上传→编辑文案→发布”——这一串动作&#xff0c;每天要重复多少次&#xff1f; 现在&#xff0c;只需一句话&#…

作者头像 李华