iOS 中的现代网络:使用 URLSession 和 async/await – 第 2 部分

发布: (2025年12月25日 GMT+8 21:29)
9 min read
原文: Dev.to

Source: Dev.to

第2部分 – 安全令牌处理

Part 1 中,我们使用 Swift 的现代并发和 URLSession 构建了一个简洁的网络层。示例端点是公开的,不需要身份验证。然而,实际应用通常需要对用户进行身份验证,并在每个请求中附加短期访问令牌。这些令牌最终会过期,我们需要一种在不强制用户重新登录的情况下获取新访问令牌的方法。

在本部分中,我们将逐步讲解如何在第 1 部分的网络客户端之上实现安全且强大的令牌处理机制。

了解访问令牌和刷新令牌

Access tokens 授予对受保护 API 的访问权限。它们通常是短期的(几分钟到几小时),以便在令牌泄露时攻击者只能在有限的时间窗口内使用。

Refresh tokens 是允许客户端请求新访问令牌的凭证。由于它们可以用于生成新的访问令牌,需要像用户密码一样受到保护。

Token 模型

import Foundation

/// A container for access/refresh tokens and their expiration date.
/// Conforms to `Codable` so it can be encoded to and decoded from JSON.
struct TokenBundle: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Date

    /// Returns `true` if the access token is expired.
    var isExpired: Bool {
        return expiresAt  // original logic placeholder
    }

    /// Loads a token bundle from the Keychain.
    static func load() throws -> TokenBundle? {
        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 item: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == errSecItemNotFound {
            return nil
        }
        guard status == errSecSuccess, let data = item as? Data else {
            throw KeychainError.unhandled(status: status)
        }
        return try JSONDecoder().decode(TokenBundle.self, from: data)
    }

    /// Saves a token bundle to the Keychain.
    static func save(_ bundle: TokenBundle) throws {
        let data = try JSONEncoder().encode(bundle)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data
        ]
        let status = SecItemAdd(query as CFDictionary, nil)
        if status != errSecSuccess && status != errSecDuplicateItem {
            throw KeychainError.unhandled(status: status)
        }
    }

    /// Removes the token bundle from the Keychain.
    static func delete() throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unhandled(status: status)
        }
    }
}

此辅助工具将 TokenBundle 编码为 Data,在钥匙串中以单一键存储,并在需要时将其解码回来。它还提供了 delete() 方法,以在用户注销时清除已存储的令牌。

并发安全的令牌管理

当多个网络请求同时需要有效令牌时,我们必须避免同时多次刷新令牌。使用 Swift actor 可确保一次只会并发执行一次刷新调用。

import Foundation

/// Manages access and refresh tokens.
/// Uses an actor to serialize token access and refresh operations safely.
actor AuthManager {
    /// The currently running refresh task, if any.
    private var refreshTask: Task<TokenBundle, Error>?
    /// Cached token bundle loaded from the Keychain.
    private var currentTokens: TokenBundle?

    init() {
        // Load any persisted tokens at initialization.
        currentTokens = try? KeychainService.load()
    }

    /// Returns a valid token bundle, refreshing if necessary.
    /// Throws if no tokens are available or if refresh fails.
    func validTokenBundle() async throws -> TokenBundle {
        // If a refresh is already in progress, await its result.
        if let task = refreshTask {
            return try await task.value
        }

        // If we have a cached token that isn’t expired, return it.
        if let tokens = currentTokens, !tokens.isExpired {
            return tokens
        }

        // No valid token – start a refresh.
        let task = Task { try await refreshTokens() }
        refreshTask = task
        defer { refreshTask = nil }

        let newTokens = try await task.value
        currentTokens = newTokens
        try KeychainService.save(newTokens)
        return newTokens
    }

    /// Performs the actual network call to exchange the refresh token for a new access token.
    private func refreshTokens() async throws -> TokenBundle {
        guard let refreshToken = currentTokens?.refreshToken else {
            throw AuthError.missingRefreshToken
        }

        // Build the request (replace URL & parameters with your own API).
        var request = URLRequest(url: URL(string: "https://api.example.com/auth/refresh")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        let body = ["refresh_token": refreshToken]
        request.httpBody = try JSONEncoder().encode(body)

        // Use the networking client from Part 1.
        let (data, response) = try await URLSession.shared.data(for: request)

        // Basic response validation.
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw AuthError.refreshFailed
        }

        // Decode the new token bundle.
        let newBundle = try JSONDecoder().decode(TokenBundle.self, from: data)
        return newBundle
    }

    /// Clears stored tokens (e.g., on logout).
    func clearTokens() throws {
        currentTokens = nil
        try KeychainService.delete()
    }
}

// MARK: - Supporting Types

enum AuthError: Error {
    case missingRefreshToken
    case refreshFailed
}
  • AuthManager actor 保证一次只会运行一个刷新操作。
  • validTokenBundle() 首先检查是否已有进行中的刷新任务,其次检查缓存的未过期令牌,最后在需要时触发刷新。
  • 刷新后的令牌会持久化到钥匙串,并缓存于内存,以供后续调用。

现在你可以将 AuthManager 注入到网络层(例如通过依赖注入),让每个请求自动获取最新的有效访问令牌。

AuthManager Actor(替代实现)

/// An actor that lazily loads tokens from the keychain at initialization.
actor AuthManager {
    private var currentTokens: TokenBundle?
    private var refreshTask: Task<TokenBundle, Error>?

    init() {
        // Load tokens from the keychain if they exist.
        currentTokens = try? KeychainService.load()
    }

    /// Returns a valid token bundle, refreshing it if necessary.
    func validTokenBundle() async throws -> TokenBundle {
        // No stored tokens means the user must log in.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }

        // If not expired, return immediately.
        if !tokens.isExpired {
            return tokens
        }

        // Otherwise refresh.
        return try await refreshTokens()
    }

    /// Forces a refresh of the tokens regardless of expiration status.
    func refreshTokens() async throws -> TokenBundle {
        // If a refresh is already happening, await it.
        if let task = refreshTask {
            return try await task.value
        }

        // Ensure we have a refresh token.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }

        // Create a new task to perform the refresh.
        let task = Task { () throws -> TokenBundle in
            defer { refreshTask = nil }

            // Build a request to your auth server’s token endpoint.
            // Replace `api.example.com` and path with your actual auth server and endpoint.
            var components = URLComponents()
            components.scheme = "https"
            components.host = "api.example.com"   // change to your auth server
            components.path = "/oauth/token"

            var request = URLRequest(url: components.url!)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")

            let body: [String: String] = ["refresh_token": tokens.refreshToken]
            request.httpBody = try JSONEncoder().encode(body)

            // Perform the network call.
            let (data, response) = try await URLSession.shared.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                throw AuthError.refreshFailed
            }

            // Decode the new token bundle.
            return try JSONDecoder().decode(TokenBundle.self, from: data)
        }

        refreshTask = task
        return try await task.value
    }
}

支持类型

enum AuthError: Error {
    case noCredentials
    case refreshFailed
}

与网络客户端集成

/// A generic network client that can automatically attach authentication headers.
struct NetworkClient {
    let authManager: AuthManager

    /// Sends a request, automatically adding the bearer token when required.
    func send<T: Request>(_ request: T,
                          allowRetry: Bool = true) async throws -> T.Response {
        var urlRequest = try request.makeURLRequest()

        // Add the Authorization header when needed.
        if request.endpoint.requiresAuthentication {
            let tokens = try await authManager.validTokenBundle()
            urlRequest.setValue("Bearer \(tokens.accessToken)",
                                 forHTTPHeaderField: "Authorization")
        }

        // Perform the request.
        let (data, response) = try await URLSession.shared.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        // Handle 401 Unauthorized.
        if httpResponse.statusCode == 401 {
            guard allowRetry else {
                throw NetworkError.unauthorized
            }

            do {
                // Attempt to refresh the token and retry once.
                _ = try await authManager.refreshTokens()
                return try await send(request, allowRetry: false)
            } catch {
                // Refresh failed → propagate the error.
                throw error
            }
        }

        // Decode the successful response.
        return try JSONDecoder().decode(T.Response.self, from: data)
    }
}

支持的类型

enum NetworkError: Error {
    case invalidResponse
    case unauthorized
    // … other cases …
}

Summary

  1. AuthManager 负责令牌的存储、验证和刷新,确保一次只运行一个刷新请求。
  2. Endpoint.requiresAuthentication 告诉客户端请求是否需要身份验证头。
  3. NetworkClient.send 在需要时注入 Bearer 令牌,收到 401 后通过刷新令牌重试一次,否则传播错误。

You can view the full project on GitHub:
https://github.com/Pro-Mobile-Dev/ios-modern-network-layer

Back to Blog

相关文章

阅读更多 »

SwiftUI 手势系统内部

markdown !Sebastien Latohttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%...

SwiftUI 视图差分与调和

SwiftUI 并不会“重新绘制屏幕”。它对视图树进行差异比较。如果你不了解 SwiftUI 如何决定哪些发生了变化、哪些保持不变,你会看到不必要的…

Swift Combine 中的热与冷发布者

什么是 Hot 和 Cold Publisher? Cold Publisher Cold Publisher 为每个订阅者创建一个新的执行。当你订阅时,工作会重新开始。swift...