文档 / 接入指南

AUTHENTICATION · iOS

外部 iOS App
接入 Apple 登录

身份交换只是开始。这里说明从授权到会话生命周期的完整接入流程。

原始 Markdown ↗AI 全文 ↗

版本: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。迁移执行后,日常修改通过后台完成,不修改已执行的迁移文件:

  1. 使用有配置管理权限的账号进入 系统管理 → 配置中心。
  2. 搜索配置键 zing.apple-signin.audiences,找到 Apple 登录允许的应用。
  3. 编辑JSON配置值,在现有数组末尾追加新的Bundle ID;保留原有全部值。例如:
[
  "com.zingyang.zingtalk",
  "com.zingyang.zing-talk",
  "com.zingyang.Cofree",
  "com.example.nextapp"
]
  1. 填写“修改原因”,例如“新增 nextapp 的 Apple 登录”。此配置标记为高风险,按界面要求填写至少4个字符的“确认文本”,例如“确认新增应用”,然后保存。
  2. 重新打开详情核对实际生效值;配置项支持运行时读取,核实目标实例和缓存已更新,再让新App重新发起Apple授权验证。

Bundle ID大小写敏感,不能使用通配符。不要删除既有App标识,也不要通过“新增配置项”再建一个同义键。如果查不到该项,先确认本次代码和172迁移已发布;若缺菜单或编辑权限,由管理员按既有权限流程处理,不绕过权限直接改库。

2. Apple 开发者侧准备

  1. 在 Apple Developer 为目标 App ID 启用 Sign in with Apple。
  2. Xcode 的目标 App Target → Signing & Capabilities 添加 Sign in with Apple,检查签名与 provisioning profile 包含该能力。
  3. 若需要相关 App 共享 Apple 身份,先在同一开发者账号下规划 Primary App ID 与分组;不要把 Xcode 的 App Groups 共享容器当作 Apple 登录分组。
  4. 使用已登录 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字符
email 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
           未登录          清除本会话 → 重新登录

已就绪 → 切换组织中 → 更新上下文与重新初始化 → 已就绪
已就绪 → 退出中 → 清除本地会话与受保护缓存 → 未登录/本机模式

登录成功后的顺序:

  1. 验证登录响应:success=true、平台token非空、userInfo.id有效;HTTP200本身不是成功依据。
  2. 建立会话记录:保存API环境标识、平台用户ID、token、平台返回的loginTenantId。可同时保存显示资料与组织名,但它们不是权限依据。
  3. 安全写入Keychain;成功后再发布“初始化中”状态。写入失败时停留登录页,不切换到已登录主页,不降级保存token到普通文件。
  4. 携带该会话调用GET /sys/user/getUserInfo;检查result.userInfo.id与登录返回ID一致,防止串账号。该步骤确认平台会话,不能只靠Apple凭据状态。
  5. 查询组织成员列表,确认当前业务上下文属于本账号;有权限驱动的业务入口时加载当前权限。两类读取可以在用户验证通过后并行执行。
  6. 组织和权限准备好后,按目标App实际功能加载首屏数据;不是登录后自动拉取全部后台数据,也不自动上传本机录音、扣费或开通权益。
  7. 达到本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启动与恢复登录

  1. 从本App的Keychain读取完整会话;没有会话则进入未登录或已提供的本机模式,不自动弹Apple授权。
  2. 校验本地记录的环境与平台用户ID,设置“恢复中”,暂不展示上一次账号或组织的云端数据。
  3. 用保存的平台token调用当前用户接口。成功且用户ID匹配时,再加载组织/权限及首屏。
  4. 明确401时清除对应会话并显示重新登录入口;断网或超时只进入“暂时无法验证”状态,允许重试或本机功能,不能伪装成已通过云端认证。
  5. 如果App保存了Apple用户标识,可像现有Cofree代码一样调用getCredentialState并监听授权撤销;但Apple仍authorized不代表平台token有效,两套状态必须分别处理。不要用Apple状态代替平台当前用户验证。
  6. 资料姓名或邮箱为空时仍允许恢复,不每次重新索取首次授权资料。切换账号必须按平台用户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才接入)

当前已有的是“记住当前登录组织”接口,不是一套会自动返回新会话和全部新权限的交换接口。

  1. 先查询组织成员关系,展示当前账号可用的正常组织;目标ID只能来自服务器返回的数据。
  2. 用户选择后进入“切换中”,暂停新云端业务请求,取消或隔离旧组织的在途任务。保留旧会话直到收到切换结果。
  3. 发送:
PUT /api/sys/user/changeLoginTenantId HTTP/1.1
Content-Type: application/json
X-Access-Token: <当前平台token>
X-Tenant-Id: <切换前的已确认组织ID>
{"loginTenantId": 1234}
  1. 当前Controller以登录用户身份检查目标租户成员关系,然后更新该用户的loginTenantId;不会签发新token,也不会在响应中返回新用户或新权限。请求不需要提交其他用户ID。
  2. 确认success=true后,以同一token和目标租户建立新的本地上下文,重新核对成员关系、加载目标租户权限与首屏。不要把只改请求头等同于完整切换。
  3. 该接口本身没有完整的组织启用/订阅校验和用户缓存刷新编排;getUserInfo.loginTenantId也可能受缓存影响。最终以目标上下文的实际授权读取为准;无法确认时停在切换错误页,不以旧缓存冒充目标组织数据。业务接口仍必须独立拒绝无权限、禁用或不属于当前账号的组织。
  4. 权限与业务缓存按API环境 + 平台用户ID + 租户ID分区;旧请求迟到的响应必须丢弃,不得覆盖新组织页面。失败不自动授予权限;请求结果未知时先核实上下文,不用重复写操作“碰运气”。

首次Apple自动建号是否额外加入组织由平台配置决定,客户端不能通过本接口加入一个自己不属于的组织。单组织App可不实现切换UI,但仍应正确携带平台确认的租户上下文。

7.8 退出、切换账号与请求并发

  1. 用户主动退出时进入“退出中”,阻止新业务请求,捕获当前会话快照。
  2. 带该token执行POST /sys/logout,解析success;接口响应可没有result。
  3. 无论网络是否成功,都允许清理本地Keychain会话、当前账号/组织的受保护缓存、内存用户和权限状态,再进入未登录或本机模式。若Keychain删除失败,应明确提示并禁止自动恢复,不能声称凭据已删除。
  4. 服务端注销缓存是异步处理;断网或接口失败时不能保证服务器已立即撤销token。不要将“本机已退出”显示为“Apple授权已撤销”或“账号已删除”。
  5. 退出不删除用户本机录音、文档或其他账号的数据,也不自动迁移、合并或上传访客资料。数据清除属于另一项明确操作。
  6. 取消尚未完成的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已实现全部流程。