session-management

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Session Management — Expert Decisions

会话管理 — 专家决策

Expert decision frameworks for session management choices. Claude knows Keychain basics and OAuth concepts — this skill provides judgment calls for security levels, refresh strategies, and cleanup requirements.

会话管理选择的专家决策框架。Claude 了解 Keychain 基础和 OAuth 概念 — 本技能提供安全级别、刷新策略和清理要求的判断依据。

Decision Trees

决策树

Token Storage Strategy

令牌存储策略

Where should you store authentication tokens?
├─ Access token (short-lived, <1hr)
│  └─ Keychain with kSecAttrAccessibleAfterFirstUnlock
│     Available after first unlock, survives restart
├─ Refresh token (long-lived)
│  └─ Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly
│     More secure, device-bound, requires unlock
├─ Session ID (server-side session)
│  └─ Keychain with kSecAttrAccessibleAfterFirstUnlock
│     Needs to work for background refreshes
├─ Temporary auth code (OAuth flow)
│  └─ Memory only (no persistence)
│     Used once, discarded immediately
└─ Remember me preference
   └─ UserDefaults (not sensitive)
      Just a boolean, not a credential
The trap: Storing tokens in UserDefaults. It's unencrypted, backed up to iCloud, and readable by jailbroken devices.
身份验证令牌应存储在何处?
├─ Access token(短期,<1小时)
│  └─ 使用 kSecAttrAccessibleAfterFirstUnlock 配置的 Keychain
│     首次解锁后可用,重启后仍保留
├─ Refresh token(长期)
│  └─ 使用 kSecAttrAccessibleWhenUnlockedThisDeviceOnly 配置的 Keychain
│     安全性更高,绑定设备,需解锁才能访问
├─ Session ID(服务端会话)
│  └─ 使用 kSecAttrAccessibleAfterFirstUnlock 配置的 Keychain
│     需支持后台刷新
├─ 临时授权码(OAuth 流程)
│  └─ 仅存于内存(不持久化)
│     一次性使用,立即丢弃
└─ 记住我偏好设置
   └─ UserDefaults(非敏感数据)
      仅为布尔值,不属于凭证
误区:将令牌存储在 UserDefaults 中。它未加密,会备份到 iCloud,且越狱设备可读取。

Token Refresh Architecture

令牌刷新架构

How should you handle token refresh?
├─ Simple app, few API calls
│  └─ Refresh on 401 response
│     Reactive: refresh when expired
├─ Frequent API calls
│  └─ Proactive refresh before expiration
│     Schedule refresh 5 min before exp
├─ Real-time features (WebSocket)
│  └─ Background refresh + reconnect
│     Maintain connection continuity
├─ Offline-first app
│  └─ Longer token lifetime + retry queue
│     Queue requests when offline
└─ High-security app
   └─ Short tokens + frequent refresh
      Minimize exposure window
应如何处理令牌刷新?
├─ 简单应用,API 调用少
│  └─ 收到 401 响应时刷新
│     被动式:令牌过期后再刷新
├─ API 调用频繁的应用
│  └─ 令牌过期前主动刷新
│     提前5分钟调度刷新
├─ 实时功能(WebSocket)
│  └─ 后台刷新 + 重新连接
│     保持连接连续性
├─ 离线优先应用
│  └─ 更长令牌有效期 + 重试队列
│     离线时将请求加入队列
└─ 高安全性应用
   └─ 短令牌有效期 + 频繁刷新
      最小化暴露窗口

Multi-Session Architecture

多会话架构

How many sessions does your app support?
├─ Single device, single account
│  └─ Simple SessionManager singleton
│     Replace tokens on new login
├─ Single device, multiple accounts (switching)
│  └─ Account-keyed Keychain storage
│     Keychain items per account ID
│     Active account pointer
├─ Multiple devices, single account
│  └─ Server-side session management
│     Device tokens registered with server
│     Remote logout capability
└─ Multiple devices, multiple accounts
   └─ Full session registry
      Server tracks all device-account pairs
      Cross-device session visibility
你的应用支持多少个会话?
├─ 单设备、单账号
│  └─ 简单的 SessionManager 单例
│     新登录时替换令牌
├─ 单设备、多账号(可切换)
│  └─ 按账号区分的 Keychain 存储
│     每个账号对应独立的 Keychain 条目
│     维护活跃账号指针
├─ 多设备、单账号
│  └─ 服务端会话管理
│     设备令牌在服务端注册
│     支持远程登出
└─ 多设备、多账号
   └─ 完整会话注册表
      服务端跟踪所有设备-账号配对
      支持跨设备会话可见性

Logout Cleanup Scope

登出清理范围

What needs clearing on logout?
├─ Always clear
│  └─ Tokens (Keychain)
│  └─ User object (memory)
│  └─ Authenticated state
├─ Usually clear
│  └─ URL cache (cached API responses)
│  └─ HTTP cookies
│  └─ User preferences tied to account
├─ Consider clearing
│  └─ Downloaded files (if user-specific)
│  └─ Core Data (if user-specific)
│  └─ Image cache (if contains private content)
└─ Usually keep
   └─ App preferences (theme, language)
   └─ Onboarding completion state
   └─ Device registration

登出时需要清理哪些内容?
├─ 必须清理
│  └─ 令牌(Keychain)
│  └─ 用户对象(内存)
│  └─ 已认证状态
├─ 通常需要清理
│  └─ URL 缓存(缓存的 API 响应)
│  └─ HTTP Cookie
│  └─ 与账号绑定的用户偏好设置
├─ 考虑清理
│  └─ 下载文件(若为用户专属)
│  └─ Core Data(若为用户专属)
│  └─ 图片缓存(若包含私密内容)
└─ 通常保留
   └─ 应用偏好设置(主题、语言)
   └─ 引导完成状态
   └─ 设备注册信息

NEVER Do

绝对禁止操作

Token Storage

令牌存储

NEVER store tokens in UserDefaults:
swift
// ❌ Unencrypted, backed up, exposed on jailbreak
UserDefaults.standard.set(accessToken, forKey: "accessToken")
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")

// ✅ Use Keychain
try KeychainHelper.shared.save(accessToken, service: "auth", account: "accessToken")
try KeychainHelper.shared.save(refreshToken, service: "auth", account: "refreshToken")
NEVER log or print tokens:
swift
// ❌ Tokens in console logs — security disaster
print("Token: \(accessToken)")
Logger.debug("Refresh token: \(refreshToken)")

// ✅ Log safely
Logger.debug("Token refreshed successfully")  // No token content
Logger.debug("Token length: \(accessToken.count)")  // Metadata only
NEVER hardcode secrets:
swift
// ❌ Secrets in binary — extractable
let clientSecret = "abc123xyz789"
let apiKey = "sk-live-xxxxx"

// ✅ Use environment or server
// Fetch from server during OAuth flow
// Or use Info.plist with .gitignore for dev keys
let clientId = Bundle.main.infoDictionary?["CLIENT_ID"] as? String
绝对禁止将令牌存储在 UserDefaults 中:
swift
// ❌ 未加密、会备份、越狱设备可获取
UserDefaults.standard.set(accessToken, forKey: "accessToken")
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")

// ✅ 使用 Keychain
try KeychainHelper.shared.save(accessToken, service: "auth", account: "accessToken")
try KeychainHelper.shared.save(refreshToken, service: "auth", account: "refreshToken")
绝对禁止记录或打印令牌:
swift
// ❌ 令牌出现在控制台日志中 — 安全灾难
print("Token: \(accessToken)")
Logger.debug("Refresh token: \(refreshToken)")

// ✅ 安全记录
Logger.debug("令牌刷新成功")  // 不包含令牌内容
Logger.debug("令牌长度: \(accessToken.count)")  // 仅记录元数据
绝对禁止硬编码密钥:
swift
// ❌ 密钥存在二进制文件中 — 可被提取
let clientSecret = "abc123xyz789"
let apiKey = "sk-live-xxxxx"

// ✅ 使用环境变量或从服务端获取
// 在 OAuth 流程中从服务端获取
// 或使用 Info.plist 并通过 .gitignore 忽略开发密钥
let clientId = Bundle.main.infoDictionary?["CLIENT_ID"] as? String

Token Refresh

令牌刷新

NEVER retry refresh infinitely:
swift
// ❌ Infinite loop if refresh token is invalid
func refreshToken() async throws {
    do {
        let response = try await API.refresh(token: refreshToken)
        storeTokens(response)
    } catch {
        try await refreshToken()  // Recursive retry — infinite loop!
    }
}

// ✅ Limited retries with backoff, then logout
func refreshToken(attempt: Int = 0) async throws {
    guard attempt < 3 else {
        await MainActor.run { logout() }
        throw SessionError.refreshFailed
    }

    do {
        let response = try await API.refresh(token: refreshToken)
        storeTokens(response)
    } catch {
        try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
        try await refreshToken(attempt: attempt + 1)
    }
}
NEVER refresh on every request:
swift
// ❌ Unnecessary API calls
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
    try await refreshAccessToken()  // Refresh EVERY request!
    return try await performRequest(endpoint)
}

// ✅ Refresh only when needed (expired or 401)
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
    if isTokenExpired() {
        try await refreshAccessToken()
    }

    let (data, response) = try await performRequest(endpoint)

    if (response as? HTTPURLResponse)?.statusCode == 401 {
        try await refreshAccessToken()
        return try await performRequest(endpoint).0
    }

    return data
}
绝对禁止无限重试刷新:
swift
// ❌ 若刷新令牌无效,会进入无限循环
func refreshToken() async throws {
    do {
        let response = try await API.refresh(token: refreshToken)
        storeTokens(response)
    } catch {
        try await refreshToken()  // 递归重试 — 无限循环!
    }
}

// ✅ 限制重试次数并添加退避机制,失败后登出
func refreshToken(attempt: Int = 0) async throws {
    guard attempt < 3 else {
        await MainActor.run { logout() }
        throw SessionError.refreshFailed
    }

    do {
        let response = try await API.refresh(token: refreshToken)
        storeTokens(response)
    } catch {
        try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
        try await refreshToken(attempt: attempt + 1)
    }
}
绝对禁止每次请求都刷新令牌:
swift
// ❌ 不必要的 API 调用
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
    try await refreshAccessToken()  // 每次请求都刷新!
    return try await performRequest(endpoint)
}

// ✅ 仅在需要时刷新(令牌过期或收到401)
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
    if isTokenExpired() {
        try await refreshAccessToken()
    }

    let (data, response) = try await performRequest(endpoint)

    if (response as? HTTPURLResponse)?.statusCode == 401 {
        try await refreshAccessToken()
        return try await performRequest(endpoint).0
    }

    return data
}

Logout

登出

NEVER forget to clear sensitive data:
swift
// ❌ Partial cleanup — tokens still accessible
func logout() {
    currentUser = nil
    isAuthenticated = false
    // Forgot to clear Keychain tokens!
}

// ✅ Complete cleanup
func logout() {
    // Clear tokens
    KeychainHelper.shared.deleteAll(service: keychainService)

    // Clear memory
    currentUser = nil
    isAuthenticated = false

    // Clear caches
    URLCache.shared.removeAllCachedResponses()

    // Clear cookies
    HTTPCookieStorage.shared.removeCookies(since: .distantPast)

    // Clear UserDefaults user data
    let userKeys = ["userId", "userEmail", "userPreferences"]
    userKeys.forEach { UserDefaults.standard.removeObject(forKey: $0) }
}
NEVER leave background tasks running after logout:
swift
// ❌ Background refresh continues for logged-out user
func logout() {
    clearTokens()
    currentUser = nil
    // Background refresh timer still running!
}

// ✅ Cancel all background work
func logout() {
    // Cancel scheduled tasks
    sessionRefreshTask?.cancel()
    sessionRefreshTask = nil

    // Cancel any pending requests
    URLSession.shared.getAllTasks { tasks in
        tasks.forEach { $0.cancel() }
    }

    // Clear data
    clearTokens()
    currentUser = nil
}
绝对禁止忘记清理敏感数据:
swift
// ❌ 清理不彻底 — 令牌仍可访问
func logout() {
    currentUser = nil
    isAuthenticated = false
    // 忘记清理 Keychain 中的令牌!
}

// ✅ 完整清理
func logout() {
    // 清理令牌
    KeychainHelper.shared.deleteAll(service: keychainService)

    // 清理内存数据
    currentUser = nil
    isAuthenticated = false

    // 清理缓存
    URLCache.shared.removeAllCachedResponses()

    // 清理 Cookie
    HTTPCookieStorage.shared.removeCookies(since: .distantPast)

    // 清理 UserDefaults 中的用户数据
    let userKeys = ["userId", "userEmail", "userPreferences"]
    userKeys.forEach { UserDefaults.standard.removeObject(forKey: $0) }
}
绝对禁止登出后仍让后台任务运行:
swift
// ❌ 登出后后台刷新仍在运行
func logout() {
    clearTokens()
    currentUser = nil
    // 后台刷新计时器仍在运行!
}

// ✅ 取消所有后台任务
func logout() {
    // 取消已调度的任务
    sessionRefreshTask?.cancel()
    sessionRefreshTask = nil

    // 取消所有待处理请求
    URLSession.shared.getAllTasks { tasks in
        tasks.forEach { $0.cancel() }
    }

    // 清理数据
    clearTokens()
    currentUser = nil
}

Keychain Security

Keychain 安全

NEVER use wrong accessibility level:
swift
// ❌ Too permissive — accessible even when locked
kSecAttrAccessibleAlways  // Deprecated and insecure!
kSecAttrAccessibleAlwaysThisDeviceOnly  // Still too permissive

// ✅ Appropriate accessibility
// For tokens that need background access:
kSecAttrAccessibleAfterFirstUnlock

// For highly sensitive data (biometric):
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
NEVER ignore Keychain errors:
swift
// ❌ Silent failure — user appears logged out
func getToken() -> String? {
    let query = [...]
    var result: AnyObject?
    SecItemCopyMatching(query as CFDictionary, &result)  // Ignoring status!
    return result as? String
}

// ✅ Handle errors properly
func getToken() throws -> String? {
    let query = [...]
    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)

    switch status {
    case errSecSuccess:
        guard let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            throw KeychainError.invalidData
        }
        return token
    case errSecItemNotFound:
        return nil  // No token stored
    default:
        throw KeychainError.unableToRetrieve(status: status)
    }
}

绝对禁止使用错误的可访问性级别:
swift
// ❌ 权限过宽 — 设备锁定时仍可访问
kSecAttrAccessibleAlways  // 已弃用且不安全!
kSecAttrAccessibleAlwaysThisDeviceOnly  // 权限仍过宽

// ✅ 合适的可访问性设置
// 需支持后台访问的令牌:
kSecAttrAccessibleAfterFirstUnlock

// 高敏感数据(生物识别保护):
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
绝对禁止忽略 Keychain 错误:
swift
// ❌ 静默失败 — 用户看似已登出
func getToken() -> String? {
    let query = [...]
    var result: AnyObject?
    SecItemCopyMatching(query as CFDictionary, &result)  // 忽略返回状态!
    return result as? String
}

// ✅ 正确处理错误
func getToken() throws -> String? {
    let query = [...]
    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)

    switch status {
    case errSecSuccess:
        guard let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            throw KeychainError.invalidData
        }
        return token
    case errSecItemNotFound:
        return nil  // 无存储的令牌
    default:
        throw KeychainError.unableToRetrieve(status: status)
    }
}

Essential Patterns

核心模式

Secure SessionManager

安全的 SessionManager

swift
@MainActor
final class SessionManager: ObservableObject {
    static let shared = SessionManager()

    @Published private(set) var isAuthenticated = false
    @Published private(set) var currentUser: User?

    private let keychainService = "com.app.auth"
    private var refreshTask: Task<Void, Never>?

    private init() {
        restoreSession()
    }

    // MARK: - Authentication

    func login(email: String, password: String) async throws {
        let response = try await AuthAPI.login(email: email, password: password)
        try storeTokens(access: response.accessToken, refresh: response.refreshToken)
        currentUser = response.user
        isAuthenticated = true
        scheduleTokenRefresh()
    }

    func logout() {
        // Cancel background work
        refreshTask?.cancel()
        refreshTask = nil

        // Clear Keychain
        KeychainHelper.shared.deleteAll(service: keychainService)

        // Clear state
        currentUser = nil
        isAuthenticated = false

        // Clear caches
        URLCache.shared.removeAllCachedResponses()
        HTTPCookieStorage.shared.removeCookies(since: .distantPast)
    }

    // MARK: - Token Management

    func getAccessToken() -> String? {
        KeychainHelper.shared.read(service: keychainService, account: "accessToken")
    }

    func refreshAccessToken() async throws {
        guard let refreshToken = KeychainHelper.shared.read(
            service: keychainService, account: "refreshToken"
        ) else {
            throw SessionError.noRefreshToken
        }

        let response = try await AuthAPI.refresh(token: refreshToken)
        try storeTokens(access: response.accessToken, refresh: response.refreshToken)
    }

    // MARK: - Private

    private func storeTokens(access: String, refresh: String) throws {
        try KeychainHelper.shared.save(access, service: keychainService, account: "accessToken")
        try KeychainHelper.shared.save(refresh, service: keychainService, account: "refreshToken")
    }

    private func restoreSession() {
        guard let _ = getAccessToken() else { return }
        isAuthenticated = true
        Task { try? await loadUserProfile() }
    }

    private func scheduleTokenRefresh() {
        refreshTask?.cancel()

        refreshTask = Task {
            while !Task.isCancelled {
                // Refresh 5 minutes before expiration
                try? await Task.sleep(nanoseconds: 55 * 60 * 1_000_000_000)  // 55 min
                guard !Task.isCancelled else { return }

                do {
                    try await refreshAccessToken()
                } catch {
                    await MainActor.run { logout() }
                    return
                }
            }
        }
    }
}
swift
@MainActor
final class SessionManager: ObservableObject {
    static let shared = SessionManager()

    @Published private(set) var isAuthenticated = false
    @Published private(set) var currentUser: User?

    private let keychainService = "com.app.auth"
    private var refreshTask: Task<Void, Never>?

    private init() {
        restoreSession()
    }

    // MARK: - 身份验证

    func login(email: String, password: String) async throws {
        let response = try await AuthAPI.login(email: email, password: password)
        try storeTokens(access: response.accessToken, refresh: response.refreshToken)
        currentUser = response.user
        isAuthenticated = true
        scheduleTokenRefresh()
    }

    func logout() {
        // 取消后台任务
        refreshTask?.cancel()
        refreshTask = nil

        // 清理 Keychain
        KeychainHelper.shared.deleteAll(service: keychainService)

        // 清理状态
        currentUser = nil
        isAuthenticated = false

        // 清理缓存
        URLCache.shared.removeAllCachedResponses()
        HTTPCookieStorage.shared.removeCookies(since: .distantPast)
    }

    // MARK: - 令牌管理

    func getAccessToken() -> String? {
        KeychainHelper.shared.read(service: keychainService, account: "accessToken")
    }

    func refreshAccessToken() async throws {
        guard let refreshToken = KeychainHelper.shared.read(
            service: keychainService, account: "refreshToken"
        ) else {
            throw SessionError.noRefreshToken
        }

        let response = try await AuthAPI.refresh(token: refreshToken)
        try storeTokens(access: response.accessToken, refresh: response.refreshToken)
    }

    // MARK: - 私有方法

    private func storeTokens(access: String, refresh: String) throws {
        try KeychainHelper.shared.save(access, service: keychainService, account: "accessToken")
        try KeychainHelper.shared.save(refresh, service: keychainService, account: "refreshToken")
    }

    private func restoreSession() {
        guard let _ = getAccessToken() else { return }
        isAuthenticated = true
        Task { try? await loadUserProfile() }
    }

    private func scheduleTokenRefresh() {
        refreshTask?.cancel()

        refreshTask = Task {
            while !Task.isCancelled {
                // 提前5分钟刷新
                try? await Task.sleep(nanoseconds: 55 * 60 * 1_000_000_000)  // 55分钟
                guard !Task.isCancelled else { return }

                do {
                    try await refreshAccessToken()
                } catch {
                    await MainActor.run { logout() }
                    return
                }
            }
        }
    }
}

Secure KeychainHelper

安全的 KeychainHelper

swift
final class KeychainHelper {
    static let shared = KeychainHelper()
    private init() {}

    func save(_ value: String, service: String, account: String) throws {
        guard let data = value.data(using: .utf8) else {
            throw KeychainError.invalidData
        }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
        ]

        // Delete existing
        SecItemDelete(query as CFDictionary)

        // Add new
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status: status)
        }
    }

    func read(service: String, account: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

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

        guard status == errSecSuccess,
              let data = result as? Data,
              let string = String(data: data, encoding: .utf8) else {
            return nil
        }

        return string
    }

    func delete(service: String, account: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        SecItemDelete(query as CFDictionary)
    }

    func deleteAll(service: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service
        ]
        SecItemDelete(query as CFDictionary)
    }
}

enum KeychainError: LocalizedError {
    case invalidData
    case saveFailed(status: OSStatus)
    case readFailed(status: OSStatus)

    var errorDescription: String? {
        switch self {
        case .invalidData: return "Invalid data format"
        case .saveFailed(let status): return "Keychain save failed: \(status)"
        case .readFailed(let status): return "Keychain read failed: \(status)"
        }
    }
}
swift
final class KeychainHelper {
    static let shared = KeychainHelper()
    private init() {}

    func save(_ value: String, service: String, account: String) throws {
        guard let data = value.data(using: .utf8) else {
            throw KeychainError.invalidData
        }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
        ]

        // 删除现有条目
        SecItemDelete(query as CFDictionary)

        // 添加新条目
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status: status)
        }
    }

    func read(service: String, account: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

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

        guard status == errSecSuccess,
              let data = result as? Data,
              let string = String(data: data, encoding: .utf8) else {
            return nil
        }

        return string
    }

    func delete(service: String, account: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        SecItemDelete(query as CFDictionary)
    }

    func deleteAll(service: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service
        ]
        SecItemDelete(query as CFDictionary)
    }
}

enum KeychainError: LocalizedError {
    case invalidData
    case saveFailed(status: OSStatus)
    case readFailed(status: OSStatus)

    var errorDescription: String? {
        switch self {
        case .invalidData: return "数据格式无效"
        case .saveFailed(let status): return "Keychain 保存失败: \(status)"
        case .readFailed(let status): return "Keychain 读取失败: \(status)"
        }
    }
}

Auto-Retry Network Client

自动重试网络客户端

swift
actor NetworkClient {
    private let sessionManager: SessionManager

    init(sessionManager: SessionManager = .shared) {
        self.sessionManager = sessionManager
    }

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        var request = try endpoint.asURLRequest()

        // Add token
        if let token = await sessionManager.getAccessToken() {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

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

        // Handle 401 with retry
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 {
            try await sessionManager.refreshAccessToken()

            // Retry with new token
            if let newToken = await sessionManager.getAccessToken() {
                request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
                let (retryData, _) = try await URLSession.shared.data(for: request)
                return try JSONDecoder().decode(T.self, from: retryData)
            }
        }

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

swift
actor NetworkClient {
    private let sessionManager: SessionManager

    init(sessionManager: SessionManager = .shared) {
        self.sessionManager = sessionManager
    }

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        var request = try endpoint.asURLRequest()

        // 添加令牌
        if let token = await sessionManager.getAccessToken() {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

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

        // 处理401错误并重试
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 {
            try await sessionManager.refreshAccessToken()

            // 使用新令牌重试
            if let newToken = await sessionManager.getAccessToken() {
                request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
                let (retryData, _) = try await URLSession.shared.data(for: request)
                return try JSONDecoder().decode(T.self, from: retryData)
            }
        }

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

Quick Reference

快速参考

Keychain Accessibility Levels

Keychain 可访问性级别

LevelWhen AccessibleUse For
WhenUnlockedDevice unlockedForeground-only tokens
AfterFirstUnlockAfter first unlockBackground refresh tokens
WhenUnlockedThisDeviceOnlyUnlocked, no backupHighly sensitive data
WhenPasscodeSetThisDeviceOnlyPasscode setBiometric-protected
级别可访问时机使用场景
WhenUnlocked设备解锁后仅前台使用的令牌
AfterFirstUnlock首次解锁后后台刷新令牌
WhenUnlockedThisDeviceOnly设备解锁后,不备份高敏感数据
WhenPasscodeSetThisDeviceOnly设置密码后生物识别保护的数据

Logout Cleanup Checklist

登出清理检查表

DataStorageClear On Logout?
Access tokenKeychain✅ Always
Refresh tokenKeychain✅ Always
User profileMemory✅ Always
API cacheURLCache✅ Usually
CookiesHTTPCookieStorage✅ Usually
User preferencesUserDefaults⚠️ Maybe
Downloaded filesFileManager⚠️ If user-specific
App settingsUserDefaults❌ Usually keep
数据存储位置登出时是否清理?
Access tokenKeychain✅ 必须
Refresh tokenKeychain✅ 必须
用户资料内存✅ 必须
API 缓存URLCache✅ 通常需要
CookiesHTTPCookieStorage✅ 通常需要
用户偏好设置UserDefaults⚠️ 视情况而定
下载文件FileManager⚠️ 若为用户专属则清理
应用设置UserDefaults❌ 通常保留

Token Refresh Strategies

令牌刷新策略

StrategyWhen to UseImplementation
On 401Simple appsRetry after refresh
ProactiveFrequent API callsTimer before expiration
BackgroundReal-time featuresBGAppRefreshTask
策略使用场景实现方式
收到401时刷新简单应用刷新后重试请求
主动刷新API调用频繁的应用过期前设置计时器
后台刷新实时功能应用使用 BGAppRefreshTask

Red Flags

危险信号

SmellProblemFix
Tokens in UserDefaultsUnencrypted storageUse Keychain
Logging token valuesSecurity exposureLog metadata only
Infinite refresh retryDoS on invalid tokenLimited retries + logout
Refresh on every requestUnnecessary API callsCheck expiration first
Partial logout cleanupData leakageClear all sensitive data
Ignoring Keychain errorsSilent failuresHandle status codes
kSecAttrAccessibleAlwaysToo permissiveUse AfterFirstUnlock
Background tasks after logoutStale operationsCancel on logout
问题表现隐患修复方案
令牌存储在 UserDefaults 中未加密存储使用 Keychain
记录令牌值安全暴露仅记录元数据
无限重试刷新令牌无效时导致服务拒绝限制重试次数 + 失败后登出
每次请求都刷新令牌不必要的API调用先检查令牌是否过期
登出清理不彻底数据泄露清理所有敏感数据
忽略 Keychain 错误静默失败处理返回状态码
使用 kSecAttrAccessibleAlways权限过宽使用 AfterFirstUnlock
登出后后台任务仍运行陈旧操作继续执行登出时取消所有任务