iOS Token 刷新实战(Actor 避免并发重复刷新)

在 iOS 项目中,Token 刷新最容易被低估的不是网络请求,而是并发状态。

一个看似正常的实现通常是:请求收到 401,调用刷新接口,保存新 Token,然后重放原请求。单个请求时没有问题,但真实页面往往会在同一时刻加载用户信息、消息数量、配置和内容列表。Access Token 一旦过期,这些请求可能同时返回 401,随后发生:

  • 多个刷新请求同时发送;
  • 后返回的旧刷新结果覆盖先返回的新结果;
  • Refresh Token 轮换后,第二个刷新请求立刻失效;
  • 每个请求都认为登录失效,重复清理状态和跳转登录页;
  • 某个页面取消任务时,把其他页面正在等待的共享刷新也取消;
  • Token 在多个线程读写,出现数据竞争。

本文使用 Swift Actor 和共享 Task 实现一套并发安全的刷新机制:

text 复制代码
请求 A ─┐
请求 B ─┼─→ AuthManager Actor ─→ 共享 refreshTask ─→ 刷新接口只调用一次
请求 C ─┘              │
                       ├─ 成功:保存新 Token,所有等待者继续
                       ├─ Refresh Token 被拒绝:清除会话
                       └─ 临时失败:保留凭证,向上抛出错误

文章将完整实现:

  1. Keychain Token 存储;
  2. 不经过业务 APIClient 的独立刷新服务;
  3. Actor 隔离的登录状态;
  4. 共享刷新任务与双重检查;
  5. 过期前主动刷新与 401 后被动刷新;
  6. 原请求最多重放一次;
  7. 20 个并发请求只刷新一次的 XCTest。

示例基于 URLSession、Swift 并发和 Codable,不依赖第三方网络库。代码面向 iOS 15 及以上系统。

一、为什么 Actor 适合管理 Token

Token 状态有三个典型特征:

  • 多个并发任务会读取;
  • 刷新成功、退出登录和凭证失效会修改;
  • 修改必须按顺序发生,不能互相覆盖。

Actor 为可变状态提供隔离。外部代码访问 Actor 的隔离属性和方法时需要 await,同一时刻只有一个任务在 Actor 隔离区执行同步代码。

但只把 Token 放进 Actor 还不够。Actor 在遇到 await 时允许其他任务进入,这叫可重入。如果每个调用者发现 Token 过期后都直接:

swift 复制代码
let newTokens = try await service.refresh(using: refreshToken)

第一个调用者在网络请求处挂起后,第二个调用者仍然可以进入 Actor,并发起第二次刷新。

正确做法是:在第一次 await 之前创建并保存一个共享 Task,后续调用者直接等待它。

二、目录与职责

text 复制代码
Auth/
├── TokenPair.swift
├── TokenVault.swift
├── KeychainTokenVault.swift
├── AuthService.swift
└── AuthManager.swift

Networking/
├── Endpoint.swift
├── APIClientError.swift
└── APIClient.swift

Tests/
├── InMemoryTokenVault.swift
├── MockAuthService.swift
└── AuthManagerTests.swift
类型 职责
TokenVault 持久化 Token,不负责刷新
AuthService 只调用刷新接口
AuthManager 隔离状态、合并刷新、处理退出
APIClient 注入 Token、检测 401、重放一次
ViewModel 把不可恢复的登录失效转换成 UI 状态

三、定义 Token 与错误类型

swift 复制代码
// Auth/TokenPair.swift
import Foundation

struct TokenPair: Codable, Equatable, Sendable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Date

    func isAccessTokenExpiringSoon(
        now: Date = .now,
        leeway: TimeInterval = 60
    ) -> Bool {
        expiresAt.timeIntervalSince(now) <= leeway
    }
}

预留 60 秒可以降低客户端时钟误差和请求在途时间导致的"刚发出就过期"。

定义认证错误:

swift 复制代码
// Auth/AuthError.swift
import Foundation

enum AuthError: Error, Equatable, Sendable {
    case missingSession
    case invalidRefreshToken
    case refreshHTTP(statusCode: Int)
    case invalidRefreshResponse
    case keychain(status: OSStatus)
}

extension AuthError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .missingSession:
            return "当前没有登录会话"
        case .invalidRefreshToken:
            return "登录状态已失效,请重新登录"
        case let .refreshHTTP(statusCode):
            return "刷新登录状态失败(HTTP \(statusCode))"
        case .invalidRefreshResponse:
            return "刷新接口返回数据异常"
        case let .keychain(status):
            return "Keychain 操作失败(\(status))"
        }
    }
}

四、抽象 TokenVault

swift 复制代码
// Auth/TokenVault.swift
import Foundation

protocol TokenVault: Sendable {
    func load() async throws -> TokenPair?
    func save(_ tokens: TokenPair) async throws
    func delete() async throws
}

接口使用 async,让 Keychain 操作发生在独立隔离域中,避免调用方被迫在主线程同步执行 Security API。

五、使用 Keychain 保存 Token

Token 属于用户秘密,不应该明文保存到 UserDefaults。下面使用通用密码类型保存一份编码后的 TokenPair

swift 复制代码
// Auth/KeychainTokenVault.swift
import Foundation
import Security

actor KeychainTokenVault: TokenVault {
    private let service: String
    private let account: String

    init(
        service: String = Bundle.main.bundleIdentifier ?? "ExampleApp",
        account: String = "auth-token-pair"
    ) {
        self.service = service
        self.account = account
    }

    func load() async throws -> TokenPair? {
        var query = baseQuery
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne

        var result: CFTypeRef?
        let status = SecItemCopyMatching(
            query as CFDictionary,
            &result
        )

        if status == errSecItemNotFound {
            return nil
        }

        guard status == errSecSuccess else {
            throw AuthError.keychain(status: status)
        }

        guard let data = result as? Data else {
            throw AuthError.invalidRefreshResponse
        }

        return try JSONDecoder().decode(TokenPair.self, from: data)
    }

    func save(_ tokens: TokenPair) async throws {
        let data = try JSONEncoder().encode(tokens)

        let attributes: [String: Any] = [
            kSecValueData as String: data,
            kSecAttrAccessible as String:
                kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
        ]

        let updateStatus = SecItemUpdate(
            baseQuery as CFDictionary,
            attributes as CFDictionary
        )

        if updateStatus == errSecSuccess {
            return
        }

        guard updateStatus == errSecItemNotFound else {
            throw AuthError.keychain(status: updateStatus)
        }

        var addQuery = baseQuery
        attributes.forEach { key, value in
            addQuery[key] = value
        }

        let addStatus = SecItemAdd(
            addQuery as CFDictionary,
            nil
        )

        guard addStatus == errSecSuccess else {
            throw AuthError.keychain(status: addStatus)
        }
    }

    func delete() async throws {
        let status = SecItemDelete(baseQuery as CFDictionary)

        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw AuthError.keychain(status: status)
        }
    }

    private var baseQuery: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
    }
}

这里选择 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,适合设备重启并首次解锁后仍可能执行后台网络任务、但不需要通过备份迁移到其他设备的场景。如果应用不需要后台访问,可以根据安全要求改为更严格的 WhenUnlockedThisDeviceOnly

不要把 Access Token、Refresh Token 分散成多个不具备事务关系的 Keychain 项,否则更新过程中可能读到"新 Access Token + 旧 Refresh Token"的组合。

六、实现独立刷新服务

刷新服务不能调用带自动认证逻辑的业务 APIClient,否则刷新接口的 401 会再次触发刷新。

swift 复制代码
// Auth/AuthService.swift
import Foundation

protocol AuthServicing: Sendable {
    func refresh(using refreshToken: String) async throws -> TokenPair
}

private struct RefreshRequest: Encodable, Sendable {
    let refreshToken: String
}

private struct RefreshResponse: Decodable, Sendable {
    let accessToken: String
    let refreshToken: String
    let expiresInSeconds: TimeInterval
}

struct URLSessionAuthService: AuthServicing {
    let baseURL: URL
    let session: URLSession

    func refresh(using refreshToken: String) async throws -> TokenPair {
        let url = baseURL
            .appendingPathComponent("auth")
            .appendingPathComponent("refresh")

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue(
            "application/json",
            forHTTPHeaderField: "Content-Type"
        )
        request.setValue(
            "application/json",
            forHTTPHeaderField: "Accept"
        )
        request.httpBody = try JSONEncoder().encode(
            RefreshRequest(refreshToken: refreshToken)
        )

        let (data, response) = try await session.data(for: request)

        try Task.checkCancellation()

        guard let http = response as? HTTPURLResponse else {
            throw AuthError.invalidRefreshResponse
        }

        switch http.statusCode {
        case 200...299:
            let body = try JSONDecoder().decode(
                RefreshResponse.self,
                from: data
            )

            return TokenPair(
                accessToken: body.accessToken,
                refreshToken: body.refreshToken,
                expiresAt: Date().addingTimeInterval(
                    body.expiresInSeconds
                )
            )

        case 400, 401:
            throw AuthError.invalidRefreshToken

        default:
            throw AuthError.refreshHTTP(
                statusCode: http.statusCode
            )
        }
    }
}

URLSessionAuthService 只负责刷新,不读取 AuthManager,也不自动添加 Access Token,因此不会形成递归依赖。

七、Actor + 共享 Task 合并刷新

swift 复制代码
// Auth/AuthManager.swift
import Foundation

actor AuthManager {
    private var tokens: TokenPair?
    private var refreshTask: Task<TokenPair, any Error>?

    private let vault: any TokenVault
    private let authService: any AuthServicing

    init(
        initialTokens: TokenPair?,
        vault: any TokenVault,
        authService: any AuthServicing
    ) {
        self.tokens = initialTokens
        self.vault = vault
        self.authService = authService
    }

    static func bootstrap(
        vault: any TokenVault,
        authService: any AuthServicing
    ) async throws -> AuthManager {
        let storedTokens = try await vault.load()

        return AuthManager(
            initialTokens: storedTokens,
            vault: vault,
            authService: authService
        )
    }

    func validAccessToken() async throws -> String {
        guard let tokens else {
            throw AuthError.missingSession
        }

        if !tokens.isAccessTokenExpiringSoon() {
            return tokens.accessToken
        }

        return try await refreshedTokens().accessToken
    }

    func refreshAfterUnauthorized(
        failedAccessToken: String
    ) async throws -> String {
        guard let current = tokens else {
            throw AuthError.missingSession
        }

        // 等待进入 Actor 期间,其他请求可能已经刷新成功。
        if current.accessToken != failedAccessToken {
            return current.accessToken
        }

        return try await refreshedTokens().accessToken
    }

    func install(_ newTokens: TokenPair) async throws {
        try await vault.save(newTokens)
        tokens = newTokens
    }

    func logout() async throws {
        refreshTask?.cancel()
        refreshTask = nil
        tokens = nil
        try await vault.delete()
    }

    func hasSession() -> Bool {
        tokens != nil
    }

    private func refreshedTokens() async throws -> TokenPair {
        if let refreshTask {
            let value = try await refreshTask.value
            try Task.checkCancellation()
            return value
        }

        guard let current = tokens else {
            throw AuthError.missingSession
        }

        let refreshToken = current.refreshToken
        let authService = self.authService

        // 必须在第一个 await 之前保存,后续调用者才能看到并复用。
        let task = Task<TokenPair, any Error> {
            try await authService.refresh(using: refreshToken)
        }

        refreshTask = task

        do {
            let newTokens = try await task.value

            // 先安全持久化,再把内存状态切换到新 Token。
            try await vault.save(newTokens)
            tokens = newTokens
            refreshTask = nil

            return newTokens
        } catch {
            refreshTask = nil

            if error as? AuthError == .invalidRefreshToken {
                tokens = nil
                try? await vault.delete()
            }

            throw error
        }
    }
}

共享 Task 如何避免重复刷新

当第一个调用者进入 refreshedTokens()

  1. refreshTask 为空;
  2. 创建刷新任务;
  3. 在调用 await task.value 之前保存到 Actor 属性;
  4. Actor 在 await 处允许其他调用者进入;
  5. 后续调用者发现 refreshTask 已存在;
  6. 所有人等待同一个 Task
  7. 刷新接口只调用一次。

如果把 refreshTask = task 写在 await 之后,合并机制就失效了。

为什么等待者取消不应该取消共享刷新

共享刷新可能同时服务多个业务请求。某个页面消失,只代表该页面不再需要结果,不代表其他请求也不需要。

因此 refreshTask 是由 AuthManager 持有的非结构化任务。等待者在获得结果后检查自己的取消状态,但不会直接取消共享任务。只有明确退出登录时,AuthManager 才取消它。

八、定义业务 Endpoint

swift 复制代码
// Networking/Endpoint.swift
import Foundation

enum HTTPMethod: String, Sendable {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case patch = "PATCH"
    case delete = "DELETE"
}

protocol Endpoint {
    associatedtype Response: Decodable & Sendable

    var path: String { get }
    var method: HTTPMethod { get }
    var queryItems: [URLQueryItem] { get }
    var body: Data? { get }
    var requiresAuthentication: Bool { get }
}

extension Endpoint {
    var method: HTTPMethod { .get }
    var queryItems: [URLQueryItem] { [] }
    var body: Data? { nil }
    var requiresAuthentication: Bool { true }
}

登录、注册、公开配置等接口可以返回 false,业务接口默认需要认证。

九、APIClient:注入 Token 并最多重放一次

swift 复制代码
// Networking/APIClientError.swift
import Foundation

enum APIClientError: Error, Equatable, Sendable {
    case invalidURL
    case invalidResponse
    case unauthorized
    case http(statusCode: Int)
    case decoding
}

完整客户端:

swift 复制代码
// Networking/APIClient.swift
import Foundation

struct APIClient: Sendable {
    let baseURL: URL
    let session: URLSession
    let authManager: AuthManager

    func send<E: Endpoint>(_ endpoint: E) async throws -> E.Response {
        var request = try makeRequest(endpoint)
        var failedAccessToken: String?

        if endpoint.requiresAuthentication {
            let accessToken = try await authManager.validAccessToken()
            failedAccessToken = accessToken
            request.setValue(
                "Bearer \(accessToken)",
                forHTTPHeaderField: "Authorization"
            )
        }

        let first = try await perform(request)

        if first.response.statusCode == 401,
           endpoint.requiresAuthentication,
           let failedAccessToken {
            let newAccessToken = try await authManager
                .refreshAfterUnauthorized(
                    failedAccessToken: failedAccessToken
                )

            try Task.checkCancellation()

            request.setValue(
                "Bearer \(newAccessToken)",
                forHTTPHeaderField: "Authorization"
            )

            let second = try await perform(request)

            // 最多重放一次;刷新后仍是 401 就停止。
            guard second.response.statusCode != 401 else {
                throw APIClientError.unauthorized
            }

            return try decode(
                E.Response.self,
                data: second.data,
                response: second.response
            )
        }

        return try decode(
            E.Response.self,
            data: first.data,
            response: first.response
        )
    }

    private func perform(
        _ request: URLRequest
    ) async throws -> (data: Data, response: HTTPURLResponse) {
        let (data, response) = try await session.data(for: request)

        try Task.checkCancellation()

        guard let http = response as? HTTPURLResponse else {
            throw APIClientError.invalidResponse
        }

        return (data, http)
    }

    private func decode<T: Decodable>(
        _ type: T.Type,
        data: Data,
        response: HTTPURLResponse
    ) throws -> T {
        guard (200...299).contains(response.statusCode) else {
            if response.statusCode == 401 {
                throw APIClientError.unauthorized
            }

            throw APIClientError.http(
                statusCode: response.statusCode
            )
        }

        do {
            return try JSONDecoder().decode(type, from: data)
        } catch {
            throw APIClientError.decoding
        }
    }

    private func makeRequest<E: Endpoint>(
        _ endpoint: E
    ) throws -> URLRequest {
        var url = baseURL

        for component in endpoint.path.split(separator: "/") {
            url.appendPathComponent(String(component))
        }

        guard var components = URLComponents(
            url: url,
            resolvingAgainstBaseURL: false
        ) else {
            throw APIClientError.invalidURL
        }

        if !endpoint.queryItems.isEmpty {
            components.queryItems = endpoint.queryItems
        }

        guard let finalURL = components.url else {
            throw APIClientError.invalidURL
        }

        var request = URLRequest(url: finalURL)
        request.httpMethod = endpoint.method.rawValue
        request.httpBody = endpoint.body
        request.setValue(
            "application/json",
            forHTTPHeaderField: "Accept"
        )

        if endpoint.body != nil {
            request.setValue(
                "application/json",
                forHTTPHeaderField: "Content-Type"
            )
        }

        return request
    }
}

这套流程同时包含两种刷新时机:

  • 请求前发现 Token 即将过期:validAccessToken() 主动刷新;
  • 服务端返回 401:refreshAfterUnauthorized() 被动刷新。

无论哪种路径,最终都进入同一个 refreshedTokens(),因此并发刷新仍然只执行一次。

十、登录、启动和退出组装

应用启动时从 Keychain 恢复:

swift 复制代码
let vault = KeychainTokenVault()

let refreshSession = URLSession(
    configuration: .ephemeral
)

let authService = URLSessionAuthService(
    baseURL: URL(string: "https://api.example.com")!,
    session: refreshSession
)

let authManager = try await AuthManager.bootstrap(
    vault: vault,
    authService: authService
)

let apiClient = APIClient(
    baseURL: URL(string: "https://api.example.com")!,
    session: URLSession(configuration: .default),
    authManager: authManager
)

登录成功时安装新会话:

swift 复制代码
let tokens = TokenPair(
    accessToken: loginResponse.accessToken,
    refreshToken: loginResponse.refreshToken,
    expiresAt: Date().addingTimeInterval(
        loginResponse.expiresInSeconds
    )
)

try await authManager.install(tokens)

主动退出时:

swift 复制代码
try await authManager.logout()

logout() 会取消正在执行的刷新、清除内存状态并删除 Keychain 项。

十一、UI 如何处理登录失效

AuthManager 不应该直接操作 SwiftUI 路由。API 调用最终会抛出 AuthError.invalidRefreshTokenAuthError.missingSession,应用根状态根据错误切换会话:

swift 复制代码
import Combine

@MainActor
final class SessionViewModel: ObservableObject {
    enum State {
        case launching
        case signedIn
        case signedOut
    }

    @Published private(set) var state: State = .launching

    func handle(_ error: Error) {
        guard let authError = error as? AuthError else { return }

        switch authError {
        case .invalidRefreshToken, .missingSession:
            state = .signedOut
        default:
            break
        }
    }
}

临时网络错误和服务端 5xx 不会删除 Keychain 会话,UI 可以显示"稍后重试"。只有刷新凭证明确被拒绝时才回到登录页。

十二、测试替身

1. 内存 TokenVault

swift 复制代码
// Tests/InMemoryTokenVault.swift
import Foundation

actor InMemoryTokenVault: TokenVault {
    private var stored: TokenPair?

    init(_ initial: TokenPair? = nil) {
        stored = initial
    }

    func load() async throws -> TokenPair? {
        stored
    }

    func save(_ tokens: TokenPair) async throws {
        stored = tokens
    }

    func delete() async throws {
        stored = nil
    }
}

2. 可计数 MockAuthService

swift 复制代码
// Tests/MockAuthService.swift
import Foundation

actor MockAuthService: AuthServicing {
    enum Result: Sendable {
        case success(TokenPair)
        case failure(AuthError)
    }

    private(set) var callCount = 0
    private let result: Result
    private let delay: Duration

    init(
        result: Result,
        delay: Duration = .milliseconds(50)
    ) {
        self.result = result
        self.delay = delay
    }

    func refresh(using refreshToken: String) async throws -> TokenPair {
        callCount += 1
        try await Task.sleep(for: delay)

        switch result {
        case let .success(tokens):
            return tokens
        case let .failure(error):
            throw error
        }
    }

    func calls() -> Int {
        callCount
    }
}

十三、验证 20 个并发请求只刷新一次

swift 复制代码
// Tests/AuthManagerTests.swift
import XCTest
@testable import DemoApp

final class AuthManagerTests: XCTestCase {
    func testConcurrentRequestsShareOneRefreshTask() async throws {
        let expired = TokenPair(
            accessToken: "access-old",
            refreshToken: "refresh-old",
            expiresAt: .distantPast
        )

        let refreshed = TokenPair(
            accessToken: "access-new",
            refreshToken: "refresh-new",
            expiresAt: .distantFuture
        )

        let vault = InMemoryTokenVault(expired)
        let service = MockAuthService(
            result: .success(refreshed)
        )

        let manager = AuthManager(
            initialTokens: expired,
            vault: vault,
            authService: service
        )

        let accessTokens = try await withThrowingTaskGroup(
            of: String.self,
            returning: [String].self
        ) { group in
            for _ in 0..<20 {
                group.addTask {
                    try await manager.validAccessToken()
                }
            }

            var values: [String] = []
            for try await value in group {
                values.append(value)
            }
            return values
        }

        let callCount = await service.calls()
        let saved = try await vault.load()

        XCTAssertEqual(callCount, 1)
        XCTAssertEqual(Set(accessTokens), ["access-new"])
        XCTAssertEqual(saved, refreshed)
    }
}

如果没有共享 Task,这个测试通常会看到 callCount 接近 20。

十四、验证双重检查与失效清理

1. 已经刷新后不再次刷新

swift 复制代码
func testUnauthorizedWithOldTokenUsesCurrentToken() async throws {
    let current = TokenPair(
        accessToken: "access-new",
        refreshToken: "refresh-new",
        expiresAt: .distantFuture
    )

    let vault = InMemoryTokenVault(current)
    let service = MockAuthService(
        result: .failure(.invalidRefreshToken)
    )

    let manager = AuthManager(
        initialTokens: current,
        vault: vault,
        authService: service
    )

    let token = try await manager.refreshAfterUnauthorized(
        failedAccessToken: "access-old"
    )

    let callCount = await service.calls()

    XCTAssertEqual(token, "access-new")
    XCTAssertEqual(callCount, 0)
}

这就是 Actor 版本的双重检查:调用者排队进入 Actor 时,先确认失败请求使用的 Token 是否仍然是当前 Token。

2. Refresh Token 被拒绝后清除会话

swift 复制代码
func testInvalidRefreshTokenClearsSession() async throws {
    let expired = TokenPair(
        accessToken: "access-old",
        refreshToken: "refresh-invalid",
        expiresAt: .distantPast
    )

    let vault = InMemoryTokenVault(expired)
    let service = MockAuthService(
        result: .failure(.invalidRefreshToken)
    )

    let manager = AuthManager(
        initialTokens: expired,
        vault: vault,
        authService: service
    )

    do {
        _ = try await manager.validAccessToken()
        XCTFail("预期刷新失败")
    } catch let error as AuthError {
        XCTAssertEqual(error, .invalidRefreshToken)
    }

    let hasSession = await manager.hasSession()
    let saved = try await vault.load()

    XCTAssertFalse(hasSession)
    XCTAssertNil(saved)
}

3. 临时失败保留会话

刷新服务的 5xx、超时和断网错误不属于 invalidRefreshToken。AuthManager 会把错误继续抛出,但不会清除 tokens 和 Keychain,用户可以稍后重试。

十五、常见问题与排错

1. 使用 Actor 后为什么还是刷新多次

因为 Actor 在 await 处可重入。如果没有在第一次挂起前保存共享任务,后续调用者仍会进入并创建新刷新请求。

2. 为什么要同时做"过期前刷新"和"401 后刷新"

本地过期时间可以减少不必要的失败请求,但客户端时钟可能不准,Token 也可能被服务端提前撤销。因此服务端 401 仍是最终判断依据。

3. 为什么刷新接口不能使用同一个 APIClient

业务 APIClient 遇到 401 会请求 AuthManager 刷新。如果刷新接口本身也经过业务 APIClient,它的 401 会再次进入相同流程,形成递归或自等待。

4. 为什么共享刷新使用 Task,而不是只保存 Bool

布尔值只能表示"正在刷新",无法让等待者直接获得同一个结果和错误。共享 Task 同时承载进行状态、成功值、失败和取消。

5. 一个等待者取消时是否应该取消 refreshTask

通常不应该。刷新任务是多请求共享资源。单个调用者取消只停止自己的后续工作;明确退出登录时才由 AuthManager 取消共享刷新。

6. Keychain Actor 是否等于绝对安全

Actor 解决并发访问,不代替完整安全设计。仍要选择合适的可访问级别、避免日志泄露、处理设备备份策略,并配合服务端轮换、撤销和短有效期机制。

7. 刷新成功但 Keychain 保存失败怎么办

本文选择先保存再切换内存状态,并向上抛出错误,避免出现"内存已更新但重启后又恢复旧 Token"的静默不一致。实际产品也可以选择保留内存 Token 并记录高优先级故障,但必须明确策略。

十六、上线前检查清单

  • Access Token 与 Refresh Token 作为一个 TokenPair 原子更新;
  • Token 使用 Keychain,而不是 UserDefaults 明文保存;
  • Keychain 项的 Accessibility 符合前后台需求;
  • AuthManager 是唯一修改内存 Token 状态的组件;
  • 刷新 Task 在第一次 await 之前保存;
  • 并发调用者等待同一个 refreshTask;
  • 进入 Actor 后比较失败 Token 与当前 Token;
  • 刷新接口不经过业务 APIClient;
  • 请求前可以处理即将过期的 Token;
  • 服务端 401 后最多重放一次;
  • 刷新后再次 401 会停止,不会无限循环;
  • 只有 Refresh Token 被明确拒绝时才清除会话;
  • 断网、超时和 5xx 不会强制退出;
  • 单个页面取消不会取消共享刷新;
  • 主动退出会取消刷新并删除 Keychain;
  • 已通过 20 个并发请求只刷新一次的测试;
  • 日志、Crash 和埋点不会记录 Token。

总结

iOS Token 刷新的并发安全可以归纳为三个核心动作:

text 复制代码
Actor 隔离共享状态
        +
Task 合并同一轮刷新
        +
失败 Token 与当前 Token 双重检查

Actor 不是一把"自动解决所有并发"的魔法锁。理解它在 await 处可重入,才能明白为什么必须在挂起前保存共享 Task。

同时,认证系统必须区分不可恢复的凭证失效与暂时性网络故障。前者清除会话,后者保留凭证并允许重试。这样既能保证安全,也不会因为一次断网破坏用户登录体验。

下一篇将来到 Web 端,系统处理 Access Token、Refresh Token、请求拦截、并发刷新与原请求重放。

参考资料

相关推荐
终端安全笔记21 小时前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
花北城21 小时前
【C#底层库】access_token授权鉴权验证
c#·鉴权·token·授权
大熊猫侯佩1 天前
独立 App 首发完成 iPhone Duo 展开态适配
ios·swiftui·swift
光影少年1 天前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架
七夜zippoe1 天前
Agent 上下文工程:Token 管理、上下文压缩与分层记忆设计
ai·agent·token·上下文压缩·分层记忆
2501_916008891 天前
SSE 和 gRPC 流式接口如何抓包,调试 SSE 与 gRPC 流式接口
网络协议·计算机网络·网络安全·ios·adb·https·udp
HouWan2 天前
Flutter: MediaQuery.of(context) 为什么可能拖慢页面?
android·flutter·ios
SoaringHeart2 天前
Swift 进阶&封装:仿 Get 给SwiftUI 添加路由导航能力
swiftui·swift
用户38034165882972 天前
Broadcast Extension 里的 Live Activity:一次跨进程妥协
ios·音视频开发