十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Sign in with Apple接入指南:从审核规则到后端验证完整实践

Sign in with Apple接入指南:从审核规则到后端验证完整实践 1. 为什么App Store审核盯上了第三方登录先搞懂Sign in with Apple的硬性规则先说个很多团队栽过跟头的场景App功能全做完了提审之后被Apple打回理由就一句话——“Your app uses third-party login but does not offer Sign in with Apple”。不少开发者看到这条还一脸懵微信登录和Apple登录有什么关系这个关系其实很硬。App Store审核指南4.8条写得很明白如果你的App使用了微信、QQ、Google、Facebook等任何一种第三方社交登录就必须同时提供“通过Apple登录”作为登录选项。这里的逻辑并不复杂Apple不允许你的产品把登录入口完全交给别家平台否则用户隐私和数据主导权就都落到竞品手里了。换句话说第三方便携登录是“选修课”而Sign in with Apple是在你选了第三方登录之后的“必修课”。这里要分清两种情况如果你的App完全没有第三方登录只有手机号、邮箱、账号密码这类自建登录那不需要接Apple登录。只要你的App里有任何一个第三方登录入口Apple登录就是强制项没有讨价还价的余地。想绕过审核也不是没有团队试过比如提审时藏掉入口过审后再放出来。但Apple现在会用代码扫描和后台数据交叉核验风险很大。我见过不止一个App因为这个操作被下架甚至开发者账号被标记。正规做法就是老老实实把Sign in with Apple接好。另外一个很多人没意识到的点Sign in with Apple并不只是一个“登录按钮”它本质上是一套完整的身份认证与授权体系。前端拿到的identityToken是JWT格式需要后端配合做验签authorizationCode可以再去换refresh_token用户还可以在系统设置里一键撤销授权你的App必须监听这个状态并及时响应。这些后面我都会逐个拆开讲。从业务价值角度看Sign in with Apple还有个隐性好处它天然自带“防垃圾注册”属性。Apple为每一个真实Apple ID都生成了独立的临时身份隐私邮箱和中继转发机制也能避免用户把真实邮箱暴露给你。对用户来说这是一种“无感登录”对开发者来说跳过了很多人机验证和验证码流程。所以别再把它当负担了——这本身就是一套质量很高的认证基础设施。接下来从配置开始走一遍完整接入流程。2. 证书、标识符与Entitlements配置App登录接不上的头号原因很多开发者第一次接Sign in with Apple时代码写完了一运行却报错或者按钮不出弹窗。排查半天最后发现根因根本不是代码而是工程配置没做全。这个环节有四个关键步骤少一步都会出问题。2.1 开发者后台创建或修改App ID打开Apple登录能力登录Apple Developer后台进入“Certificates, Identifiers Profiles”在Identifiers里找到你的App ID或者新建一个然后往下拉找到Capabilities列表勾选“Sign in with Apple”。这里注意几个细节App ID是唯一的别在马甲包上复用同一个App ID。如果这是已有线上App修改App ID会重新生成对应的配置文件Provisioning Profile需要把旧的配置文件删掉重建Xcode那边也要同步更新。修改生效需要大概几秒到几分钟别刚勾完就急着下载配置。2.2 Xcode工程添加Sign in with Apple Capability打开Xcode选中你的项目Target切到“Signing Capabilities”标签页点左上角的“ Capability”搜索“Sign in with Apple”双击添加。添加成功后工程里会自动生成一个名为xxx.entitlements的文件里面会出现一行com.apple.developer.applesignin值是一个数组。没有这个文件或者这行配置运行时ASAuthorizationAppleIDProvider就会报错甚至会导致启动崩溃。这是新手最容易忽略的一步。Xcode里还有个额外步骤容易漏如果你在后台用的是自动签名Automatically manage signing一般来说Xcode会自动同步App ID的能力配置但如果你关闭了自动签名用手动Profile那就必须确认下载的Provisioning Profile里包含了Sign in with Apple的授权。手动签名的团队经常在旧Profile上反复栽跟头我建议直接改用自动签名省事得多。2.3 部署目标确认iOS 13起步Sign in with Apple最低支持iOS 13。如果App还要兼容iOS 12及以下就需要做系统版本判断。不过坦白说2025年的今天还在纠结iOS 12的App已经很少了现在App Store的下载设备里iOS 13以上的占比超过99%。如果你确实需要兼容旧系统那就只能通过if #available(iOS 13.0, *)包一层旧系统上不显示Apple登录按钮。需要注意的另一种情况是如果App部署目标低于iOS 13而你使用了ASAuthorizationAppleIDButton又没做可用性判断旧系统上可能会抛unrecognized selector之类的崩溃。所以版本判断必须写别偷懒。2.4 创建后端验证用的KeysSign in with Apple密钥Sign in with Apple这套机制里后端需要用“开发者密钥”来生成client_secret而这个密钥不是普通的App Secret而是一个.p8格式的私钥文件。在开发者后台的“Keys”页面点击“”新建Key给Key起个名字勾选“Sign in with Apple”然后关联你刚才配置好的App ID下一步会看到一行Key ID整串看起来类似ABC123DEFG这个Key ID后面生成JWT时要用到。创建完成后只下载一次.p8文件后面平台不再提供第二次下载入口丢了就得重新生成。下载下来的文件通常叫AuthKey_ABC123DEFG.p8里面是ES256算法的私钥后端存好别提交到Git仓库。这一步之所以很多团队漏掉是因为前端开发者觉得“后端的事情”后端又以为“Apple登录只需前端”结果联调时后端拿不出client_secret整个token换取流程直接卡死。配置阶段就把前端、后端两边要拿到的参数列一张表会省去后面很多沟通成本。配置项来源用途Team ID开发者团队ID开发者后台账号页生成client_secret JWT的issClient IDBundle IdentifierApp ID生成JWT的sub与前端请求的audKey IDKeys页面创建时生成标识signing key.p8私钥文件Keys页面一次性下载ES256签名client_secret回调地址可选Apple后台配置部分web场景使用原生App可不配3. Swift 5代码接入从按钮显示到拿到identityToken的完整链路配置做完就该写代码了。这里我按实际工程里常见的方式拆成四块讲引入框架并创建登录请求、处理回调、状态检查与监听、按钮定制。每一块都附上可以直接套用的Swift代码。3.1 引入AuthenticationServices并搭建登录控制器Sign in with Apple的登录面板是系统级的不需要你去搭建UI只需要创建ASAuthorizationController并调用performRequests()。核心代码在下面import AuthenticationServices class AppleSignInManager: NSObject { static let shared AppleSignInManager() private var currentController: ASAuthorizationController? func startAppleSignIn() { let provider ASAuthorizationAppleIDProvider() let request provider.createRequest() // 需要获取哪些信息可以组合 request.requestedScopes [.fullName, .email] let controller ASAuthorizationController(authorizationRequests: [request]) controller.delegate self controller.presentationContextProvider self controller.performRequests() currentController controller } }requestedScopes很关键如果这里不声明.fullName和.email系统会认为你只做纯登录不会把对应的授权信息放到结果里。但是要明确一点即使用户同意授权也不代表你的后端能每次都拿到这些字段——这一点第5章会详细说。3.2 处理回调email、fullName、identityToken分别代表什么实现ASAuthorizationControllerDelegate这是拿到结果的唯一入口extension AppleSignInManager: ASAuthorizationControllerDelegate { func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { guard let credential authorization.credential as? ASAuthorizationAppleIDCredential else { return } // 1. 用户的Apple ID唯一标识符用于识别用户身份 let userID credential.user // 2. 用户授权时提供的邮箱注意只有第一次登录才回传 let email credential.email // 3. 用户姓名也是只有第一次登录才回传 let givenName credential.fullName?.givenName let familyName credential.fullName?.familyName // 4. JWT格式的身份令牌发给后端做服务端校验 let identityToken credential.identityToken.flatMap { String(data: $0, encoding: .utf8) } // 5. 一次性授权码后端用它换access_token和refresh_token let authorizationCode credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) } // 发送给后端由后端完成token交换和用户注册逻辑 let payload: [String: Any] [ userID: userID, identityToken: identityToken ?? , authorizationCode: authorizationCode ?? ] // networkManager.post(/auth/apple, payload) } func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: Error ) { // 用户取消或系统报错 let nsError error as NSError switch nsError.code { case ASAuthorizationError.canceled.rawValue: print(用户取消登录) case ASAuthorizationError.failed.rawValue: print(授权失败) case ASAuthorizationError.invalidResponse.rawValue: print(无响应/响应不合法) case ASAuthorizationError.notHandled.rawValue: print(未处理比如没有展示登录页面) case ASAuthorizationError.unknown.rawValue: print(未知错误) default: break } } }这段代码里最容易看晕的就是那几个字段的拆分。我用自己的话重新理一遍user这是Apple给这个App里的用户生成的稳定唯一ID同一用户在同一App里任何时候登录都是同一个值不会变。email如果用户选择“隐藏我的邮箱”这里会是类似于xxxxxxprivaterelay.appleid.com的中转邮箱如果用户直接分享真实邮箱这里就是真实地址。fullName用户如果选择不分享这里就是空。选择分享时它也只在首次授权时返回之后永远是nil。所以App的后端在首次授权时一定要把姓名、邮箱存下来别指望以后还能补。identityToken签名过的JWT内有sub即上面的user、email、iss、aud等字段后端验签后可以信任它。authorizationCode一次性授权码有效期5分钟后端可以拿它换取长期的refresh_token。还有一个credential.state字段ASAuthorizationAppleIDCredential里有一个realUserStatus属性它表示系统对这个账号是真实用户还是随机账号的置信度评估值为.likelyReal或.unsupported。这个字段对风控有一定帮助但不要把它当业务主链路依赖。3.3 登录状态检查与撤销监听用户的Apple登录状态不是一成不变的。用户可以去系统设置里停用“使用Apple登录”某款App你的App需要马上感知到这个变化否则会出现用户明明已经撤销授权但App内还保持登录态的问题这在审核和用户体验上都很糟。启动时推荐做一次状态检查func checkAppleLoginState() { guard let userID UserDefaults.standard.string(forKey: appleUserID) else { return } let provider ASAuthorizationAppleIDProvider() provider.getCredentialState(forUserID: userID) { state, error in DispatchQueue.main.async { switch state { case .authorized: // 用户仍然授权正常使用 break case .revoked: // 用户已撤销授权需要登出App业务账号并清理本地数据 self.logout() case .notFound: // 未找到该用户的授权记录同样做登出处理 self.logout() unknown default: break } } } }同时注册系统的撤销通知NotificationCenter.default.addObserver( self, selector: #selector(handleAppleIDStateRevoked), name: ASAuthorizationAppleIDProvider.credentialRevokedNotification, object: nil ) objc private func handleAppleIDStateRevoked() { logout() }这里有个经验之谈getCredentialState的判定是异步的网络不好时可能长时间无响应不要在主线程等。另外审核期间Apple审核员有可能手动在你的测试账号里撤销授权来验证App的响应逻辑如果App没有处理“撤销后自动登出”的场景被打回的概率很高。3.4 自定义登录按钮的注意事项系统自带的ASAuthorizationAppleIDButton样式有三种黑色、白色、白色描边。直接实例化就可以用let appleButton ASAuthorizationAppleIDButton(type: .signIn, style: .black) appleButton.addTarget(self, action: #selector(startAppleSignIn), for: .touchUpInside)Apple对Sign in with Apple按钮的UI规范卡得很严格要求不能随意变尺寸、不能遮挡Apple Logo、不能让它看起来不像是标准按钮。视图层级里如果自定义了背景、圆角过小或者旋转了按钮图形审核也可能被拒。我在负责的App里曾试过把按钮圆角改到特别小提审被追问过一次后来老老实实保持系统默认样式。如果你确实需要自定义UI也有一种合规方式你可以在按钮上方覆盖一层透明的UIControl去接收点击而不是直接修改官方按钮的视觉元素。屏幕阅读器的辅助功能标签也建议设置为“使用Apple登录”这个细节做无障碍审查时会看。4. 后端验证与Token刷新前端拿到令牌后服务端怎么信任它前端把identityToken和authorizationCode抛给后端之后真正的安全主战场才刚开始。如果后端直接拿user字段当用户ID存数据库那这个登录流程等于裸奔——任何人都可以伪造一个请求体说自己是某个user。后端必须完成两步验证身份令牌的签名再拿着授权码去Apple换正式的Token。4.1 JWT结构拆解与ES256公钥验签identityToken是一串标准的JWT字符串用.分隔成三段Header、Payload、Signature。Header大概是这样的{ kid: AIDTPK-JSON-KEY-ID, alg: RS256 }Payload里面常见字段{ iss: https://appleid.apple.com, aud: com.yourcompany.yourapp, exp: 1710000000, iat: 1709990000, sub: 001234.56789abcdef, email: userexample.com, email_verified: true, is_private_email: true, real_user_status: 2 }验签时只要知道Apple的公钥就可以。Apple把公钥托管在固定地址https://appleid.apple.com/auth/keys返回的是JWKS格式里面有多个kid对应的公钥。后端拿到token后先看Header里的kid然后去JWKS里取对应的公钥做RS256验签。验签通过之后再校验iss必须是https://appleid.apple.comaud必须是你的Bundle IDexp不能过期。四步全部通过这个身份令牌才算被信任。后端语言不同实现的库也不同通用流程是调用Apple的JWKS端点获取公钥集合。按kid选择公钥。使用JWT库验签。校验iss、aud、exp等声明。从JWT的sub字段拿到该用户在你的App体系里的唯一标识。注意这里的email字段在验证时是可选的如果用户选择隐藏邮箱email字段会是中转地址is_private_email为true。不要因为邮箱看起来像垃圾地址就拒绝注册。4.2 用authorization_code换access_token和refresh_tokenidentityToken验证只是确认“这个用户确实是Apple说的那个人”但要想拿到可长期使用的身份凭证后端得再调用一次Apple的Token端点curl -X POST https://appleid.apple.com/auth/token \ -H Content-Type: application/x-www-form-urlencoded \ -d client_idcom.yourcompany.yourapp \ -d client_secretclient-secret-jwt \ -d grant_typeauthorization_code \ -d codeauthorization-code-from-app这里的client_secret需要后端用你在第2.4节里下载的.p8私钥动态生成。client_secret本身也是一个JWT加密算法用ES256Payload结构如下{ iss: 你的Team ID, iat: 当前Unix时间戳, exp: 当前时间戳 180天, aud: https://appleid.apple.com, sub: 你的Bundle ID }生成后请求Token端点成功响应大致长这样{ access_token: ..., token_type: Bearer, expires_in: 3600, refresh_token: ..., id_token: ... }refresh_token是长期有效的可以存到后端数据库用于后续静默刷新用户状态或主动撤销用户授权。这个refresh_token一个用户只有一个如果你重复用同一个authorization_code去换Apple会返回错误。authorization_code只能用一次而且5分钟内就必须兑换过期作废。4.3 刷新令牌的使用与过期处理当access_token过期后后端可以用grant_typerefresh_token再次请求curl -X POST https://appleid.apple.com/auth/token \ -H Content-Type: application/x-www-form-urlencoded \ -d client_idcom.yourcompany.yourapp \ -d client_secretclient-secret-jwt \ -d grant_typerefresh_token \ -d refresh_tokenrefresh-token这一步在实践中有两个坑第一个坑是refresh_token不会因为你的操作而立即失效同一个Token可以反复使用。但Apple会自带刷新驱动的机制如果你的refresh token长时间不使用Apple视情况可能会主动把它作废。所以线上服务里要对Sign in with Apple的Token做持久化存储并且要有定时使用或主动唤醒的逻辑。第二个坑是refresh_token也有可能被Apple作废。当用户在系统设置里撤销了对App的授权Apple会让该用户的refresh token失效。这时候后端再去刷新会拿到invalid_grant错误。此时的正确做法不是重试而是把该用户的登录态标记为“已撤销”并通知前端强制登出。后端每次收到Apple Token响应时建议把expires_in记录到Redis里配合定时任务做预警刷新避免在用户请求高峰期集中重放刷新请求把Apple的限流触发器点燃。5. 真机调试、上架审核与线上运维中的坑代码写完了后端也通了但这不等于万事大吉。这一章把我在实际项目里踩过的、以及见过同事踩过的坑都列出来每一条都对应一个真实教训。5.1 模拟器调试时的常见怪问题在Xcode模拟器上跑Sign in with Apple大多数情况下是能正常弹出系统登录面板的。但有一种情况比较隐蔽模拟器上已经登录了一个Apple ID但该Apple ID的密码出了变化或者被风控锁定登录面板就会一直转圈或者提示“验证失败”。这时候不要怀疑代码先到模拟器的“设置—App Store”里退出再重新登录一次。另一个模拟器特有的问题模拟器里如果一直停留在旧系统版本比如iOS 15以下ASAuthorizationAppleIDButton的布局在不同iOS版本间会有细微差异有些情况下按钮会显示不出来但依然占据布局空间。遇到这种情况可以检查一下是不是把按钮写在了UIKit布局约束不正确的容器里。还有一点模拟器上“用户主动取消”错误码和真机不同有些版本取消时返回的是ASAuthorizationError.canceled1001但个别系统版本返回1000unknown调试逻辑时不要只判断一个code值。5.2 审核注意事项别在登录入口顺序上玩花招App Store审核员对Sign in with Apple的检查非常细。我见过一个案例App把Apple登录按钮放在需要展开二级页面才能看到的位置审核直接被拒理由就是入口不够直观不符合“用户应当无负担地找到Apple登录方式”的原则。实践中比较稳的做法是把“使用Apple登录”按钮和微信、QQ等第三方登录按钮放在同一层级视觉上不故意弱化。Apple官方按钮的宽度不要小于其他第三方登录按钮。如果登录页有多个语言版本确认所有语言环境下的文案和按钮都正常显示。如果你的App只支持纯账号密码登录不加第三方登录那么不接Apple登录也合规这一点不少团队理解有误。关于审核还有一个容易被忽略的细节如果App里集成了支付相关能力Sign in with Apple和支付机制的关联不会直接影响审核但Apple会把登录方式与App里的真实业务做交叉验证。比如你在App Store的展示页写着支持游客登录而实际版本里去掉了游客入口审核员也有理由打回。5.3 实测数据字段的怪癖邮箱只返回一次别等第二次这是Sign in with Apple最反直觉的一点。你第一次授权时如果拿到了用户的email和fullName那么第二次、第三次以及之后每一次登录Apple都不会再返回这两个字段。它们只在首次授权时出现在ASAuthorizationAppleIDCredential里之后恒为nil。所以后端必须搞清楚一个设计用户表里要有独立的apple_email和apple_full_name字段在首次登录回调时写入后续登录只更新user_id关联和token信息绝不覆盖这两个字段。我自己接手过的一个项目就是因为没注意这一点第二次登录时拿到的邮箱是nil直接覆盖了数据库里原来的邮箱导致用户第一天的欢迎邮件发出后第二天就收到了“邮箱已变更”的错误通知。后来修复的方式是把字段改成“仅首次非空才写入”并增加了一个apple_info_updated_at时间戳用于排查。5.4 中继邮箱Relay Email的处理策略当用户选择“隐藏我的邮箱”时Apple给App的是一个privaterelay.appleid.com结尾的中继邮箱。这个邮箱能收到Apple的转发来信但不是用户真实邮箱。如果你的业务高度依赖邮箱做营销推送这里会遇到一个现实问题用户用中继邮箱注册后续你的营销邮件会被Apple统一转发到用户的真实邮箱但你无法反查真实邮箱。而且用户如果在系统设置里关掉了“邮件转发”你的后续信件就会静默丢失。从工程角度看我建议在用户注册流程里不要过度依赖邮箱的唯一性而是把userApple User ID作为真正的主键。邮箱只作为辅助通知渠道不方便用于登录识别更不要用邮箱去判断“老用户回归”。5.5 多环境配置别混用开发版、测试版、生产版的问题每个Target如果Bundle ID不同对应的client_id和JWT的aud也不同。开发环境的Apple登录key和生产的key尽量分开不要图省事共用一个.p8文件。否则你在测试环境改了一版client_secret生成逻辑可能会把生产环境的登录也带挂掉。多环境配置是Sign in with Apple集成里最容易被忽略但最致命的问题。常见做法是给每个环境建一个独立的App ID和对应的Apple登录能力配置后端配置中心里把Team ID、Key ID、Bundle ID、私钥路径分开管理。上线后如果发现登录突然大面积失败第一步不是查代码而是查是不是有人把测试环境的client_secret部署到了生产服务器。6. 从一次线上故障聊聊最终建议讲一个真实案例收尾。我朋友所在的团队在某个大版本更新中接了Sign in with Apple上线第三天接到大量用户反馈“无法登录”。排查后发现后端的JWT验签逻辑里写死了Apple的某个kid但Apple偶尔会轮换JWKS里的公钥服务端没有定期刷新密钥集合导致所有新登录请求验签失败。那次故障持续了快两个小时被迫临时发布紧急版本才恢复。这个案例给我的教训有三点这里分享给你们第一Apple登录的JWKS公钥必须配置定时任务定期拉取不能缓存一份用到天荒地老。JWT库一般都有jwks_uri自动轮换功能没有的话自己写个定时任务最简单也最稳妥。第二client_secret虽然是后端生成的JWT但它有最长180天的有效期不是永久有效。如果你用的是一个固定写死的client_secret到期那天所有登录会一起挂掉。正规做法是在生成函数里加点随机化逻辑让每次请求都动态生成并保证后端时钟同步准确。第三Sign in with Apple的调试和联调一定要在真机上完整跑一遍尤其是首次授权、二次登录、系统撤销授权、App卸载重装这几个典型场景。模拟器能覆盖的边界有限不要在上架前最后一刻才想起验证授权边界。如果你正在考虑要不要在下次迭代里补上Sign in with Apple我的建议是别拖。即使目前App还没有第三方登录把Apple登录作为备用登录方式提前接入也能让账号体系多一条可靠的恢复路径。等到审核被拒再临时补那种赶工压力是完全没有必要的。
返回列表