Loading...
Loading...
Compare original and translation side by side
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身份验证令牌应存储在何处?
├─ Access token(短期,<1小时)
│ └─ 使用 kSecAttrAccessibleAfterFirstUnlock 配置的 Keychain
│ 首次解锁后可用,重启后仍保留
│
├─ Refresh token(长期)
│ └─ 使用 kSecAttrAccessibleWhenUnlockedThisDeviceOnly 配置的 Keychain
│ 安全性更高,绑定设备,需解锁才能访问
│
├─ Session ID(服务端会话)
│ └─ 使用 kSecAttrAccessibleAfterFirstUnlock 配置的 Keychain
│ 需支持后台刷新
│
├─ 临时授权码(OAuth 流程)
│ └─ 仅存于内存(不持久化)
│ 一次性使用,立即丢弃
│
└─ 记住我偏好设置
└─ UserDefaults(非敏感数据)
仅为布尔值,不属于凭证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)
│ └─ 后台刷新 + 重新连接
│ 保持连接连续性
│
├─ 离线优先应用
│ └─ 更长令牌有效期 + 重试队列
│ 离线时将请求加入队列
│
└─ 高安全性应用
└─ 短令牌有效期 + 频繁刷新
最小化暴露窗口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 条目
│ 维护活跃账号指针
│
├─ 多设备、单账号
│ └─ 服务端会话管理
│ 设备令牌在服务端注册
│ 支持远程登出
│
└─ 多设备、多账号
└─ 完整会话注册表
服务端跟踪所有设备-账号配对
支持跨设备会话可见性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(若为用户专属)
│ └─ 图片缓存(若包含私密内容)
│
└─ 通常保留
└─ 应用偏好设置(主题、语言)
└─ 引导完成状态
└─ 设备注册信息// ❌ 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")// ❌ 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// ❌ 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.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")// ❌ 令牌出现在控制台日志中 — 安全灾难
print("Token: \(accessToken)")
Logger.debug("Refresh token: \(refreshToken)")
// ✅ 安全记录
Logger.debug("令牌刷新成功") // 不包含令牌内容
Logger.debug("令牌长度: \(accessToken.count)") // 仅记录元数据// ❌ 密钥存在二进制文件中 — 可被提取
let clientSecret = "abc123xyz789"
let apiKey = "sk-live-xxxxx"
// ✅ 使用环境变量或从服务端获取
// 在 OAuth 流程中从服务端获取
// 或使用 Info.plist 并通过 .gitignore 忽略开发密钥
let clientId = Bundle.main.infoDictionary?["CLIENT_ID"] as? String// ❌ 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)
}
}// ❌ 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
}// ❌ 若刷新令牌无效,会进入无限循环
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)
}
}// ❌ 不必要的 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
}// ❌ 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) }
}// ❌ 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
}// ❌ 清理不彻底 — 令牌仍可访问
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) }
}// ❌ 登出后后台刷新仍在运行
func logout() {
clearTokens()
currentUser = nil
// 后台刷新计时器仍在运行!
}
// ✅ 取消所有后台任务
func logout() {
// 取消已调度的任务
sessionRefreshTask?.cancel()
sessionRefreshTask = nil
// 取消所有待处理请求
URLSession.shared.getAllTasks { tasks in
tasks.forEach { $0.cancel() }
}
// 清理数据
clearTokens()
currentUser = nil
}// ❌ 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// ❌ 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)
}
}// ❌ 权限过宽 — 设备锁定时仍可访问
kSecAttrAccessibleAlways // 已弃用且不安全!
kSecAttrAccessibleAlwaysThisDeviceOnly // 权限仍过宽
// ✅ 合适的可访问性设置
// 需支持后台访问的令牌:
kSecAttrAccessibleAfterFirstUnlock
// 高敏感数据(生物识别保护):
kSecAttrAccessibleWhenUnlockedThisDeviceOnly// ❌ 静默失败 — 用户看似已登出
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)
}
}@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
}
}
}
}
}@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
}
}
}
}
}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)"
}
}
}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)"
}
}
}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)
}
}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)
}
}| Level | When Accessible | Use For |
|---|---|---|
| WhenUnlocked | Device unlocked | Foreground-only tokens |
| AfterFirstUnlock | After first unlock | Background refresh tokens |
| WhenUnlockedThisDeviceOnly | Unlocked, no backup | Highly sensitive data |
| WhenPasscodeSetThisDeviceOnly | Passcode set | Biometric-protected |
| 级别 | 可访问时机 | 使用场景 |
|---|---|---|
| WhenUnlocked | 设备解锁后 | 仅前台使用的令牌 |
| AfterFirstUnlock | 首次解锁后 | 后台刷新令牌 |
| WhenUnlockedThisDeviceOnly | 设备解锁后,不备份 | 高敏感数据 |
| WhenPasscodeSetThisDeviceOnly | 设置密码后 | 生物识别保护的数据 |
| Data | Storage | Clear On Logout? |
|---|---|---|
| Access token | Keychain | ✅ Always |
| Refresh token | Keychain | ✅ Always |
| User profile | Memory | ✅ Always |
| API cache | URLCache | ✅ Usually |
| Cookies | HTTPCookieStorage | ✅ Usually |
| User preferences | UserDefaults | ⚠️ Maybe |
| Downloaded files | FileManager | ⚠️ If user-specific |
| App settings | UserDefaults | ❌ Usually keep |
| 数据 | 存储位置 | 登出时是否清理? |
|---|---|---|
| Access token | Keychain | ✅ 必须 |
| Refresh token | Keychain | ✅ 必须 |
| 用户资料 | 内存 | ✅ 必须 |
| API 缓存 | URLCache | ✅ 通常需要 |
| Cookies | HTTPCookieStorage | ✅ 通常需要 |
| 用户偏好设置 | UserDefaults | ⚠️ 视情况而定 |
| 下载文件 | FileManager | ⚠️ 若为用户专属则清理 |
| 应用设置 | UserDefaults | ❌ 通常保留 |
| Strategy | When to Use | Implementation |
|---|---|---|
| On 401 | Simple apps | Retry after refresh |
| Proactive | Frequent API calls | Timer before expiration |
| Background | Real-time features | BGAppRefreshTask |
| 策略 | 使用场景 | 实现方式 |
|---|---|---|
| 收到401时刷新 | 简单应用 | 刷新后重试请求 |
| 主动刷新 | API调用频繁的应用 | 过期前设置计时器 |
| 后台刷新 | 实时功能应用 | 使用 BGAppRefreshTask |
| Smell | Problem | Fix |
|---|---|---|
| Tokens in UserDefaults | Unencrypted storage | Use Keychain |
| Logging token values | Security exposure | Log metadata only |
| Infinite refresh retry | DoS on invalid token | Limited retries + logout |
| Refresh on every request | Unnecessary API calls | Check expiration first |
| Partial logout cleanup | Data leakage | Clear all sensitive data |
| Ignoring Keychain errors | Silent failures | Handle status codes |
| kSecAttrAccessibleAlways | Too permissive | Use AfterFirstUnlock |
| Background tasks after logout | Stale operations | Cancel on logout |
| 问题表现 | 隐患 | 修复方案 |
|---|---|---|
| 令牌存储在 UserDefaults 中 | 未加密存储 | 使用 Keychain |
| 记录令牌值 | 安全暴露 | 仅记录元数据 |
| 无限重试刷新 | 令牌无效时导致服务拒绝 | 限制重试次数 + 失败后登出 |
| 每次请求都刷新令牌 | 不必要的API调用 | 先检查令牌是否过期 |
| 登出清理不彻底 | 数据泄露 | 清理所有敏感数据 |
| 忽略 Keychain 错误 | 静默失败 | 处理返回状态码 |
| 使用 kSecAttrAccessibleAlways | 权限过宽 | 使用 AfterFirstUnlock |
| 登出后后台任务仍运行 | 陈旧操作继续执行 | 登出时取消所有任务 |