版本:1.1 · 更新:2026-10-23
本文依据 zing-boot 当前源码编写,供独立 iOS App 对接平台账号登录使用。不是 Apple 内购、订阅或 OAuth 授权服务器接入文档。测试与生产均使用用户指定地址 https://work.zingyang.com;Bundle ID 和返回数据仍为示例,不代表已为目标 App 开通。
1. 接入前先确定
双方需确认以下信息:
| 信息 | 提供方 | 说明 |
|---|---|---|
| 测试、生产 API 根地址 | 平台 | 统一为 https://work.zingyang.com/api,包含 /api 前缀 |
| iOS Bundle ID | App | 如 com.example.externalapp,测试/生产若不同需分别登记 |
| Apple Developer Team 与 App ID 分组 | App | 决定是否需要与现有 App 统一 Apple 身份;不能仅凭相同邮箱合并账号 |
| 首次登录是否允许建号 | 平台 | 对应配置中心 zing.apple-signin.auto-create-user |
| 用户归属 | 平台 | 是否只使用个人空间,还是首登额外加入指定组织 |
| 可调用的业务接口与角色权限 | 双方 | 登录成功不等于拥有全部业务权限 |
必需前置条件:平台验签配置中的 audiences 必须包含目标 App 的真实 Bundle ID。 客户端不能通过请求参数自行指定或覆盖 audience。
当前配置实现的区别:
- 开关、自动建号、默认组织、失败频控通过
RuntimeConfigProvider从配置中心读取。 audiences通过既有配置中心读取,键zing.apple-signin.audiences,类型JSON、平台级,值为Bundle ID字符串数组。- 随
V3.9.4_172迁移初始化两个ZingTalk标识和com.zingyang.Cofree;需部署本次代码并执行迁移才生效,已有自定义配置不被覆盖。 - 本文没有替目标 App 修改服务配置;未完成 audience 开通前,不能承诺直接联调成功。
以后自行新增 App
本次初始化数据位于 zing-boot/zing-module-system/zing-system-start/src/main/resources/flyway/sql/mysql/V3.9.4_172__apple_signin_audiences_database.sql。迁移执行后,日常修改通过后台完成,不修改已执行的迁移文件:
- 使用有配置管理权限的账号进入 系统管理 → 配置中心。
- 搜索配置键
zing.apple-signin.audiences,找到 Apple 登录允许的应用。 - 编辑JSON配置值,在现有数组末尾追加新的Bundle ID;保留原有全部值。例如:
[
"com.zingyang.zingtalk",
"com.zingyang.zing-talk",
"com.zingyang.Cofree",
"com.example.nextapp"
]
- 填写“修改原因”,例如“新增 nextapp 的 Apple 登录”。此配置标记为高风险,按界面要求填写至少4个字符的“确认文本”,例如“确认新增应用”,然后保存。
- 重新打开详情核对实际生效值;配置项支持运行时读取,核实目标实例和缓存已更新,再让新App重新发起Apple授权验证。
Bundle ID大小写敏感,不能使用通配符。不要删除既有App标识,也不要通过“新增配置项”再建一个同义键。如果查不到该项,先确认本次代码和172迁移已发布;若缺菜单或编辑权限,由管理员按既有权限流程处理,不绕过权限直接改库。
2. Apple 开发者侧准备
- 在 Apple Developer 为目标 App ID 启用 Sign in with Apple。
- Xcode 的目标 App Target → Signing & Capabilities 添加 Sign in with Apple,检查签名与 provisioning profile 包含该能力。
- 若需要相关 App 共享 Apple 身份,先在同一开发者账号下规划 Primary App ID 与分组;不要把 Xcode 的 App Groups 共享容器当作 Apple 登录分组。
- 使用已登录 Apple Account 的设备进行真实授权联调。
本接口使用原生 AuthenticationServices 返回的 identity token,不要求 App 内置 Apple 私钥、client secret,也没有网页回调地址参数。若未来接入网页授权、授权码交换或撤销,需要单独核实服务端能力。
Apple 官方参考:启用 Sign in with Apple、相关 App 分组。
3. 登录时序
iOS 生成 rawNonce(每次尝试独立生成)
→ request.nonce = SHA256(rawNonce) 的小写十六进制
→ 系统 Apple 授权
→ 获得 identityToken
→ POST 平台登录接口,提交 identityToken + 原始 rawNonce
→ 平台验证 Apple 签名、issuer、audience、有效期和 nonce
→ 查 Apple 绑定;必要时创建平台账号
→ 平台返回自己的 token、userInfo、tenantList 等
→ iOS 安全保存平台会话,使用平台 token 访问业务接口
两种令牌不可混淆:Apple identity token 仅用于这次登录交换;后续平台 API 使用返回的 result.token。不要把 identity token 当平台 token,也不要将 Apple credential.user 当平台用户 ID。
4. 登录接口
POST {API_BASE}/sys/thirdLogin/apple/native
例如完整地址:https://work.zingyang.com/api/sys/thirdLogin/apple/native。
请求头:
Content-Type: application/json
Accept: application/json
该接口允许未登录访问,不需要旧平台 token、App Secret、App Store 收据或客户端签名参数。不必指定租户 ID,平台按账号成员关系决定登录租户。本文示例是人工使用原生 App 授权后的正常请求;AI 工具代调平台接口时仍须遵守项目的 AI 标识、确认和审计规则。
| JSON 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| identityToken | String | 是 | credential.identityToken 以 UTF-8 转成的 JWT 字符串,不要再次 Base64 编码;当前默认最大长度4096字符 |
| nonce | String | 是 | 授权前生成的原始随机串;不是已经 SHA-256 的摘要 |
| authorizationCode | String | 否 | Apple authorizationCode 的 UTF-8 字符串;当前预留,不交换、不持久化 |
| fullName | String | 否 | 显示姓名候选;只用于补充空资料,不参与身份判断;当前最多保留50字符 |
| String | 否 | 可选资料提示;当前不持久化、不用于查找或合并账号 |
{
"identityToken": "<Apple 返回的原始 JWT>",
"nonce": "<本次授权对应的 rawNonce>",
"fullName": "示例用户"
}
服务端使用验签后的 sub 定位绑定,绑定类型是 apple,绑定记录在默认租户0。该租户仅用于第三方身份登记,不表示业务请求可以任意使用租户0。
5. 响应与错误
成功响应示意(仅展示客户端需要的部分字段):
{
"success": true,
"code": 0,
"message": "登录成功",
"result": {
"token": "<平台 JWT>",
"userInfo": {
"id": "<平台用户ID>",
"username": "apple_example",
"realname": "示例用户",
"loginTenantId": 1234
},
"tenantList": []
}
}
tenantList 内容随真实成员关系返回,上例不是固定值;还可能包含 departs、multi_depart 等字段。解码时允许额外字段,非必需资料按可选处理。成功条件是 HTTP 成功、success=true、result.token 非空及 userInfo 存在,不要只判断 HTTP200 或写死 code=200。
Apple 业务失败通常是 HTTP200 + success=false:
{
"success": false,
"code": 1001,
"message": "Apple 登录校验失败,请重试",
"result": null
}
| 业务码 | 含义 | 客户端处理 |
|---|---|---|
| 1001 | token/签名/issuer/audience/有效期/nonce 不合法 | 结束本次登录;检查接入配置,用户重试时重新发起 Apple 授权 |
| 1002 | 未绑定且禁止自动建号,或 Apple 登录关闭 | 展示服务端提示,联系平台确认开通策略;不要据此认为存在可调用的账号绑定接口 |
| 1003 | Apple 公钥服务不可达或异常 | 提示稍后重试;不要自行放行登录 |
| 1004 | 建号或并发绑定冲突 | 提示重试,避免并发连续提交 |
| 1005 | IP 失败频控超限 | 停止自动重试,提示稍后再试;窗口由平台配置 |
| 500或其他码 | 账号无效、配置、租户或其他公共业务异常 | 按 success=false 处理,保留脱敏诊断信息并联系平台 |
同时处理真实 HTTP401/403、5xx、网络错误及非JSON响应。Apple 登录的1001等业务错误不等同于已有平台会话过期,不要无条件清除其他有效会话。
6. SwiftUI 最小示例
下面示例使用系统按钮、每次独立 nonce 和 URLSession。适用于支持 Swift Concurrency 的 iOS 项目;替换 API 地址后集成。onSession 必须接入 App 自己的 Keychain 和登录状态管理;示例不包含完整导航或 Keychain 封装。
import SwiftUI
import AuthenticationServices
import CryptoKit
import Security
struct PlatformAppleLoginRequest: Encodable {
let identityToken: String
let nonce: String
let fullName: String?
let email: String?
}
struct PlatformUser: Decodable {
let id: String
let username: String?
let loginTenantId: Int?
}
struct PlatformSession: Decodable {
let token: String
let userInfo: PlatformUser
}
struct LoginEnvelope: Decodable {
let success: Bool
let code: Int?
let message: String?
let result: PlatformSession?
}
@MainActor
struct PlatformAppleLoginButton: View {
let apiBase: URL // 例如 https://work.zingyang.com/api
let onSession: (PlatformSession) throws -> Void
@State private var rawNonce: String?
@State private var busy = false
@State private var errorText: String?
var body: some View {
VStack {
SignInWithAppleButton(.signIn) { request in
guard let nonce = makeNonce() else {
rawNonce = nil
errorText = "无法生成登录随机数,请重试"
return
}
busy = true
errorText = nil
rawNonce = nonce
request.requestedScopes = [.fullName, .email]
request.nonce = SHA256.hash(data: Data(nonce.utf8))
.map { String(format: "%02x", $0) }.joined()
} onCompletion: { result in
let nonce = rawNonce
rawNonce = nil
switch result {
case .failure(let error):
busy = false
if (error as? ASAuthorizationError)?.code != .canceled {
errorText = "Apple 授权失败,请重试"
}
case .success(let authorization):
guard let nonce,
let credential = authorization.credential
as? ASAuthorizationAppleIDCredential,
let data = credential.identityToken,
let token = String(data: data, encoding: .utf8),
!token.isEmpty else {
busy = false
errorText = "Apple 登录凭据缺失,请重试"
return
}
let name = credential.fullName.map {
PersonNameComponentsFormatter().string(from: $0)
}
Task {
defer { busy = false }
do {
let session = try await exchange(.init(
identityToken: token, nonce: nonce,
fullName: name, email: credential.email
))
try onSession(session) // 先写入 Keychain,再切换登录态
} catch {
errorText = error.localizedDescription
}
}
}
}
.frame(height: 50)
.disabled(busy)
if let errorText { Text(errorText) }
}
}
private func makeNonce() -> String? {
var bytes = [UInt8](repeating: 0, count: 32)
let status = bytes.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
}
guard status == errSecSuccess else { return nil }
return bytes.map { String(format: "%02x", $0) }.joined()
}
private func exchange(_ body: PlatformAppleLoginRequest) async throws
-> PlatformSession {
let url = apiBase.appendingPathComponent("sys/thirdLogin/apple/native")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 20
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw NSError(domain: "PlatformLogin", code: -1,
userInfo: [NSLocalizedDescriptionKey: "平台登录请求失败,请稍后重试"])
}
let envelope = try JSONDecoder().decode(LoginEnvelope.self, from: data)
guard envelope.success, let session = envelope.result,
!session.token.isEmpty else {
throw NSError(domain: "PlatformLogin", code: envelope.code ?? -2,
userInfo: [NSLocalizedDescriptionKey:
envelope.message ?? "平台登录失败"])
}
return session
}
}
生产集成需由宿主页面处理授权期间的重复点击、页面退出和任务取消。姓名、邮箱可能为空;不要因二次授权缺少这些字段而拒绝登录。随机数生成失败时示例不会交换平台会话,客户端不得改用固定 nonce。
7. 登录成功后的完整会话流程
本节是外部App的集成要求及状态设计;接口契约来自现有源码,不表示Cofree当前已经实现全部步骤。所有路径相对 https://work.zingyang.com/api。
7.1 状态与执行顺序
未登录 → Apple授权中 → 平台交换中 → 安全保存会话 → 初始化中 → 已就绪
↓失败 ↓失败 ↓失败 ↓网络异常
回登录页 回登录页 不进入业务首页 保留凭据,允许重试
App启动 → 读取Keychain → 验证当前平台会话 → 初始化 → 已就绪
↓无凭据 ↓明确401
未登录 清除本会话 → 重新登录
已就绪 → 切换组织中 → 更新上下文与重新初始化 → 已就绪
已就绪 → 退出中 → 清除本地会话与受保护缓存 → 未登录/本机模式
登录成功后的顺序:
- 验证登录响应:
success=true、平台token非空、userInfo.id有效;HTTP200本身不是成功依据。 - 建立会话记录:保存API环境标识、平台用户ID、token、平台返回的
loginTenantId。可同时保存显示资料与组织名,但它们不是权限依据。 - 安全写入Keychain;成功后再发布“初始化中”状态。写入失败时停留登录页,不切换到已登录主页,不降级保存token到普通文件。
- 携带该会话调用
GET /sys/user/getUserInfo;检查result.userInfo.id与登录返回ID一致,防止串账号。该步骤确认平台会话,不能只靠Apple凭据状态。 - 查询组织成员列表,确认当前业务上下文属于本账号;有权限驱动的业务入口时加载当前权限。两类读取可以在用户验证通过后并行执行。
- 组织和权限准备好后,按目标App实际功能加载首屏数据;不是登录后自动拉取全部后台数据,也不自动上传本机录音、扣费或开通权益。
- 达到本App必需的初始化条件才发布“已就绪”。可选头像/组织名称加载失败可以降级为占位;需要组织、权限的数据页不能默认按管理员或租户0运行。
如果新账号确实由平台返回loginTenantId=0,这是平台当前无成员组织时的行为,不能改成其他组织ID。只进入该账号实际被允许的页面;若业务要求有效组织而账号没有组织,展示“暂无可用组织”,按产品已提供的加入/创建流程处理,不伪造组织。
7.2 已有接口清单
| 阶段 | 方法与路径 | 成功时读取 | 说明 |
|---|---|---|---|
| Apple身份交换 | POST /sys/thirdLogin/apple/native |
result.token、result.userInfo、result.tenantList |
未登录可访问;只在授权完成后提交 |
| 验证会话、更新资料 | GET /sys/user/getUserInfo |
result.userInfo |
不是直接把整个result解码为用户 |
| 组织成员关系 | GET /sys/tenant/getTenantListByUserId?userTenantStatus=1 |
result数组 |
查询当前登录账号;参数1只过滤正常成员关系,不代表所有返回组织都处于启用状态 |
| 当前权限 | GET /sys/permission/getPermCode |
result.codeList、result.auth |
按角色、当前租户与订阅过滤;仅在App需要这些功能时接入 |
| 记住登录组织 | PUT /sys/user/changeLoginTenantId |
success |
Body为{"loginTenantId":1234};是业务写操作 |
| 退出平台会话 | POST /sys/logout |
success、message |
不要求响应有result;需要当前token |
后五类请求均携带平台token;涉及租户业务时携带已确认的当前租户头。实际权限仍由服务端判定。文档中的普通App请求不额外添加AI确认头;AI工具代执行写操作时必须遵守平台确认与审计规则。
7.3 用户、组织和权限响应怎么读
当前用户的响应结构示意:
{
"success": true,
"code": 200,
"result": {
"userInfo": {
"id": "platform-user-1",
"username": "apple_example",
"realname": "示例用户",
"loginTenantId": 1234
}
}
}
组织成员关系的关键字段示意:
{
"success": true,
"code": 200,
"result": [
{
"id": "platform-user-1",
"tenantUserId": "1234",
"name": "示例组织",
"status": "1",
"userTenantStatus": "1"
}
]
}
特别注意:这个接口的Mapper将sys_tenant.id命名为 tenantUserId,而id是用户ID,不能把id当组织ID。tenantUserId当前模型为字符串,转为有效整数后才用于租户头。status是组织状态,userTenantStatus是成员关系状态;不能仅因一条记录在列表中就允许切换。平台当前组织启用值为1、正常成员关系值为1,兼容数字/字符串的响应表示。登录响应的tenantList则是另一种模型,其中id才是组织ID,不能与此接口复用同一个含义不明的DTO。
权限响应中的用途:
| 字段 | 用途 | 禁止误用 |
|---|---|---|
codeList |
当前用户获授权的按钮/操作权限编码数组 | 空数组不表示拥有全部权限 |
auth |
当前用户权限项及其规则信息 | 不能代替后端接口校验 |
allAuth |
全量权限定义,用于管理台等展示 | 不是当前用户权限,绝不能据此显示全部能力 |
sysSafeMode |
平台数据源安全模式信息 | 不是登录成功或管理员标志 |
权限只决定客户端展示与交互,后端仍检查RBAC、租户、套餐、配额及资源归属。外部App不需要构建后台菜单路由;只为自身功能映射明确的权限码。没有对应业务协议时,不根据菜单名称猜测可调用接口或计费权益。
7.4 后续请求的统一封装
GET /api/sys/user/getUserInfo HTTP/1.1
Host: work.zingyang.com
Accept: application/json
X-Access-Token: <平台 result.token>
X-Tenant-Id: <当前已确认的组织ID>
- 使用平台
X-Access-Token,不能把Apple identity token放进去,也不能仅发送Bearer头并假定等价。 - JSON写请求同时设置
Content-Type: application/json。 - 每个请求从同一会话快照读取用户、token和租户,避免切换组织时拼出“旧token/新缓存/旧租户”的组合。
- 登录交换请求本身不携带旧平台token,避免污染会话;不要自行设置
X-Forwarded-For来绕过频控。 - 部分接口还有
X-Sign、X-TIMESTAMP等签名要求。以目标接口契约和平台批准的客户端签名方案为准;不要把字典接口的签名规则直接套到所有接口;额外签名要求取决于目标部署的拦截配置。遇到签名拒绝不能全局关闭签名,也不应把服务端秘密随意放进App。 - 解析通用响应时以
success为主要依据,兼容成功码0/200;普通查询需要result时必须检查其存在,而退出、组织记忆等消息型接口允许无result。不要用一个强制要求payload的解码器处理所有请求。
统一错误分类:
| 响应/故障 | 应做什么 | 不应做什么 |
|---|---|---|
| 已认证请求明确HTTP401,或明确的业务401会话失效 | 使对应会话失效、停止受保护请求,回登录入口;保留本机用户数据 | 不无限重试旧token |
| Apple交换1001~1005 | 按第5节提示;失败不建立新会话 | 不把它当成旧平台会话过期 |
| HTTP403或业务510权限拒绝 | 展示权限不足,停止该操作;保留有效登录态 | 不清空全局会话、不换管理员重试 |
| 套餐、配额、资源归属或其他业务拒绝 | 按实际接口code/message处理 | 不靠重新登录绕过,也不将普通业务500全部当掉线 |
| 超时、断网、5xx、非JSON网关响应 | 显示连接失败和重试入口;保留Keychain,暂停云端业务 | 不判定账号注销或自动删除凭据 |
| 请求取消 | 结束当前任务,不显示“密码错误”等误导提示 | 不在已退出/换账号后重新落盘会话 |
可对确认幂等的读取进行有界重试并尊重Retry-After;登录、切换组织、发送消息等写操作不自动无限重试。网络超时可能意味着服务器已受理,必须按具体业务的回读/幂等契约恢复,不能盲目重复写。
7.5 App启动与恢复登录
- 从本App的Keychain读取完整会话;没有会话则进入未登录或已提供的本机模式,不自动弹Apple授权。
- 校验本地记录的环境与平台用户ID,设置“恢复中”,暂不展示上一次账号或组织的云端数据。
- 用保存的平台token调用当前用户接口。成功且用户ID匹配时,再加载组织/权限及首屏。
- 明确401时清除对应会话并显示重新登录入口;断网或超时只进入“暂时无法验证”状态,允许重试或本机功能,不能伪装成已通过云端认证。
- 如果App保存了Apple用户标识,可像现有Cofree代码一样调用
getCredentialState并监听授权撤销;但Apple仍authorized不代表平台token有效,两套状态必须分别处理。不要用Apple状态代替平台当前用户验证。 - 资料姓名或邮箱为空时仍允许恢复,不每次重新索取首次授权资料。切换账号必须按平台用户ID隔离缓存,不能凭昵称或邮箱判断相同账号。
Keychain记录建议包含:API环境标识、平台用户ID、平台token、当前租户ID及最少显示资料。token不进入UserDefaults、日志、错误截图或埋点;Apple token、rawNonce、授权码在本次交换结束后释放。Keychain保存应更新优先,避免“先删除旧记录、再新增失败”导致原会话丢失。
7.6 过期与续期:当前实现的边界
服务端ShiroRealm#jwtTokenRefresh有基于Redis缓存的内部续期逻辑,不是客户端调用的refresh端点,也不保证每次请求都延长会话。客户端继续发送已保存的平台token,不能自行签发新token。
因此:
- 不新增
/refresh、/refreshToken等猜测接口。 - 不只解码JWT的
exp就在本地强制退出;平台还会结合服务器缓存与实际校验状态决定会话是否可用,以认证接口的实际响应为准。 - 一旦明确401,不能靠保存的Apple authorizationCode或旧identity token续期;重新走用户发起的Apple授权流程。
- 当前Apple入口按PC类型签发会话,有效期和同类型互踢受平台策略影响。App不写死“永久登录”“固定30天”或“移动端不会互踢”。
7.7 多组织切换(需要此功能的App才接入)
当前已有的是“记住当前登录组织”接口,不是一套会自动返回新会话和全部新权限的交换接口。
- 先查询组织成员关系,展示当前账号可用的正常组织;目标ID只能来自服务器返回的数据。
- 用户选择后进入“切换中”,暂停新云端业务请求,取消或隔离旧组织的在途任务。保留旧会话直到收到切换结果。
- 发送:
PUT /api/sys/user/changeLoginTenantId HTTP/1.1
Content-Type: application/json
X-Access-Token: <当前平台token>
X-Tenant-Id: <切换前的已确认组织ID>
{"loginTenantId": 1234}
- 当前Controller以登录用户身份检查目标租户成员关系,然后更新该用户的
loginTenantId;不会签发新token,也不会在响应中返回新用户或新权限。请求不需要提交其他用户ID。 - 确认
success=true后,以同一token和目标租户建立新的本地上下文,重新核对成员关系、加载目标租户权限与首屏。不要把只改请求头等同于完整切换。 - 该接口本身没有完整的组织启用/订阅校验和用户缓存刷新编排;
getUserInfo.loginTenantId也可能受缓存影响。最终以目标上下文的实际授权读取为准;无法确认时停在切换错误页,不以旧缓存冒充目标组织数据。业务接口仍必须独立拒绝无权限、禁用或不属于当前账号的组织。 - 权限与业务缓存按
API环境 + 平台用户ID + 租户ID分区;旧请求迟到的响应必须丢弃,不得覆盖新组织页面。失败不自动授予权限;请求结果未知时先核实上下文,不用重复写操作“碰运气”。
首次Apple自动建号是否额外加入组织由平台配置决定,客户端不能通过本接口加入一个自己不属于的组织。单组织App可不实现切换UI,但仍应正确携带平台确认的租户上下文。
7.8 退出、切换账号与请求并发
- 用户主动退出时进入“退出中”,阻止新业务请求,捕获当前会话快照。
- 带该token执行
POST /sys/logout,解析success;接口响应可没有result。 - 无论网络是否成功,都允许清理本地Keychain会话、当前账号/组织的受保护缓存、内存用户和权限状态,再进入未登录或本机模式。若Keychain删除失败,应明确提示并禁止自动恢复,不能声称凭据已删除。
- 服务端注销缓存是异步处理;断网或接口失败时不能保证服务器已立即撤销token。不要将“本机已退出”显示为“Apple授权已撤销”或“账号已删除”。
- 退出不删除用户本机录音、文档或其他账号的数据,也不自动迁移、合并或上传访客资料。数据清除属于另一项明确操作。
- 取消尚未完成的Apple平台交换任务;每次登录/退出/换账号/换组织递增会话代次。所有异步响应落盘或改变UI前检查代次仍匹配。旧账号迟到的401不能清除新账号的有效会话,退出后迟到的登录成功也不能重新登录。
7.9 初始化失败时的产品行为
| 阶段 | 失败后的状态 | 恢复入口 |
|---|---|---|
| 平台交换失败 | 未建立新会话,显示准确业务提示 | 由用户重新授权;按需联系平台 |
| Keychain写入失败 | 不进入业务首页,原有效记录不被先删掉 | 提示安全存储错误后重试 |
| 当前用户明确401/ID不一致 | 阻止云端业务,失效当前会话 | 重新登录;ID不一致需记录脱敏问题 |
| 当前用户网络失败 | 保留凭据,云端初始化未完成 | 重试/本机模式 |
| 组织为空或当前组织不可用 | 保留有效身份,不运行要求组织的页面 | 选择可用组织或既有加入组织流程 |
| 权限读取失败 | 不默认开放按钮;与明确“无权限”区分 | 重试或联系管理员 |
| 可选显示资料失败 | 身份与必要上下文已验证则可继续 | 使用占位并稍后重试 |
| 首屏业务失败 | 保留有效登录和组织上下文 | 页面级错误、重试,不重新自动建号 |
8. 跨 App 账号与能力边界
当前绑定键包含 Apple sub、类型 apple 和绑定租户0,没有额外的外部App命名空间。平台 audience 白名单是平台级配置,不是每个业务租户各自的App授权系统。
- 外部App如果验出与现有绑定相同的
sub,会进入同一平台账号;如果不同,可能建成独立账号。 - 不保证同一自然人在不同 Developer Team、App分组或迁移条件下得到相同
sub。需要共享账号时先验证 Apple 配置与真实用户映射,再决定是否需要受控的绑定/迁移能力。 - 该登录接口没有“将 Apple 绑定到当前已有密码账号”的请求参数,不按邮箱自动合并,不允许客户端提交
sysUserId选定账号。 - 自动建号、额外加入默认组织是平台级策略,不能按本次请求任意指定组织或角色。
- 登录成功不自动获得 Talk 订阅、外部App付费权限或管理员权限;订阅、订单和权益另走业务接口。
- 当前流程不消费 Apple credential 撤销通知,也未实现本流程的授权码交换、Apple token撤销闭环。账号删除、授权撤销等产品流程应另行核对,不能把本登录接口当作已覆盖。
9. 联调验收清单
以下是真实接入时需要执行的检查,不是本文已执行的测试:
| 场景 | 预期 |
|---|---|
| 目标 Bundle ID 首登 | audience校验通过;按平台策略建号或给出1002,成功时返回平台会话 |
| 相同Apple身份再次登录 | 返回同一平台用户ID,不重复建号 |
| 非白名单Bundle ID | 拒绝,不创建用户或绑定 |
| 缺少/不匹配nonce,篡改/过期token | 拒绝,不产生新用户或绑定 |
| 用户取消授权 | 静默回到可登录状态,不请求平台登录 |
| 二次授权无姓名/邮箱 | 仍能登录;已有资料不被空值覆盖 |
| 平台账号禁用或删除 | 拒绝登录,不绕过账号状态 |
| 自动建号开关关闭 | 未绑定账号拒绝,不悄悄新建 |
| 查询当前用户 | 平台 token 有效,返回ID与登录响应相同 |
| 租户与权限 | 仅访问账号所属且获授权的数据;拒绝不能被客户端升权绕过 |
| 网络、1003、1005 | 提示可理解,不自动密集重试,不记录敏感凭据 |
| 退出、会话过期、App重启 | 本地凭据和界面状态一致;过期凭据不继续访问业务 |
| 登录后当前用户校验 | result.userInfo.id与平台登录返回ID一致;ID不一致阻止初始化 |
| 组织字段映射 | 成员接口tenantUserId用于组织ID,id不被误用;禁用/非正常成员关系不可切换 |
| 权限初始化 | 仅codeList/auth用于当前授权,allAuth不得作为获权集合 |
| 断网恢复 | 保留Keychain,不自动当作401登出;需要联网的页面保持错误/待验证状态 |
| 组织切换 | 成功后重载目标上下文;失败不显示目标数据,迟到的旧响应被隔离 |
| 并发退出/重新登录 | 旧401不清新会话,已退出后的迟到登录结果不落盘 |
真实首登会创建平台账号、第三方绑定及相关个人空间资源。测试与生产使用同一地址,不代表测试数据自动隔离。真实首登前须确认测试账号、租户和数据清理范围;不要用真实业务账号随意试建号。缺少真实Apple设备授权或目标audience配置时,应记录未验证,不将模拟JWT单测算作端到端通过。
10. 排查顺序
- Apple授权阶段失败:先检查App ID能力、签名profile、设备Apple Account和系统错误,不改后端鉴权。
- 1001:检查目标Bundle ID是否进入当前部署的audiences、token是否完整、rawNonce是否配对、服务端时钟和Apple公钥连通性。只记录脱敏原因,不贴完整token。
- 1002:区分总开关关闭与未绑定禁止建号,联系平台管理员;不能改走弱校验接口。
- 1003:由平台检查Apple JWKS访问、超时与证书环境。
- 1004:检查建号/绑定冲突;不要删除历史绑定“重试”。
- 登录成功但业务拒绝:检查平台返回的账号、租户、角色和套餐,登录与业务授权分别排查。
11. 平台实现参考
以下路径仅供平台维护者核对;外部App不需要复制这些后端实现:
- 入口:
zing-boot/zing-module-system/zing-system-biz/src/main/java/org/zing/modules/system/controller/LoginController.java#appleNativeLogin - 账号编排:同模块
service/apple/AppleSignInService.java#authenticate - 验签与公钥:
service/apple/AppleIdentityTokenVerifier.java、HttpAppleJwksProvider.java - 配置与错误码:
service/apple/AppleSignInProperties.java、AppleSignInErrorCode.java - 现有客户端:
zing-talk/zing-talk/Features/Auth/LoginView.swift - 客户端会话:
zing-talk/Packages/ZingTalkCore/Sources/ZingTalkCore/Auth/AuthService.swift#loginWithApple - 用户与组织记忆:
zing-boot/zing-module-system/zing-system-biz/src/main/java/org/zing/modules/system/controller/SysUserController.java#changeLoginTenantId - 组织列表字段:同模块
controller/SysTenantController.java#getTenantListByUserId、mapper/xml/SysTenantUserMapper.xml#getTenantListByUserId - 权限:同模块
controller/SysPermissionController.java#getPermCode - 服务端会话续期:
zing-boot/zing-boot-base-core/src/main/java/org/zing/config/shiro/ShiroRealm.java#jwtTokenRefresh
核验范围:本轮静态核对现有客户端、Controller、验签、建号及会话代码,并查阅Apple官方能力配置说明;未修改运行配置、未调用真实Apple登录、未验证外部App的Bundle ID,也未对本文Swift示例执行目标工程构建。示例需在目标iOS工程编译与真实设备联调后验收。
地址核实记录(2026-10-22):用户明确测试、生产统一使用 https://work.zingyang.com;只读访问 /api/sys/loginCaptchaEnabled 返回HTTP200、JSON且success=true,/sys/loginCaptchaEnabled 返回HTML。因此本文API根地址使用 https://work.zingyang.com/api。未执行线上Apple登录或建号。
2026-10-23 Cofree接入变更:新增配置中心audiences消费与172迁移;没有改变签名、issuer、nonce或租户校验。部署和迁移未完成前,不代表线上已经允许Cofree。
发布兼容说明:升级时容忍旧外部audiences属性绑定,但该值会被忽略;实际白名单只从配置中心读取。配置管理仍按本文步骤操作。旧版实例若未切换成功,数据库迁移完成不代表实际登录已使用新白名单。
2026-10-23 v1.1:补齐登录交换后的Keychain落盘、身份/组织/权限初始化、统一认证请求、启动恢复、服务端内部续期边界、现有组织切换接口、退出及并发隔离。此次为源码核实后的文档更新,未执行线上组织切换、退出或真实Apple授权,也不代表Cofree已实现全部流程。