如何向iOS快捷指令传递认证令牌?App Intents与Cognito方案咨询
基于App Intents+Cognito的认证令牌处理最佳实践与实现指导
一、令牌存储的安全规范
- 绝对不要把access token、refresh token明文存在UserDefaults里,必须用Keychain Services存储,这是iOS/macOS下敏感数据存储的标准方案,能利用系统级加密保护令牌不被泄露。
- 存储时设置合适的访问控制:比如指定
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,确保只有设备解锁且当前用户能访问,避免备份到iCloud或被其他应用读取。 - 给每个令牌设置唯一的Keychain条目标识,比如
com.yourapp.cognito.access-token、com.yourapp.cognito.refresh-token、com.yourapp.cognito.user-id,方便后续精准读取。
二、主应用向App Intents传递令牌的实现
App Intents运行在独立进程,无法直接访问主应用内存数据,需通过Keychain共享实现:
- 配置App Groups:在开发者后台和Xcode项目中开启同一个App Group(比如
group.com.yourapp.shared),主应用和App Intents扩展都要加入该组。 - 修改Keychain存储代码:存储令牌时指定
kSecAttrAccessGroup为你的App Group标识,让扩展能访问同一组的Keychain条目。 - 在App Intents中读取令牌:编写通用的Keychain读取工具类,直接从共享Keychain中获取user ID、access token和refresh token。
三、Cognito令牌的自动刷新逻辑
access token有效期通常为1小时,必须在App Intents中处理过期刷新:
- 每次提交数据前,解析JWT的
exp字段检查access token是否过期。 - 若令牌过期,调用Cognito的
initiateAuth接口,用refresh token换取新的access token和refresh token,成功后更新Keychain中的令牌。 - 封装刷新逻辑为异步方法,确保数据提交前完成令牌更新,避免因过期导致请求失败。
四、App Intents中数据提交的安全处理
- 所有Web服务通信必须使用HTTPS,禁止HTTP传输,防止令牌被窃听。
- 提交请求时,将access token放在HTTP请求头的
Authorization字段,格式为Bearer <access-token>,遵循OAuth2标准授权方式。 - 处理请求失败场景:收到401 Unauthorized响应时,立即触发refresh token刷新流程并重试请求;若刷新失败(如refresh token过期),提示用户回到主应用重新登录。
五、代码示例片段
Keychain存储工具类(主应用与扩展通用)
import Security enum KeychainError: Error { case noData case unexpectedData case unhandledError(status: OSStatus) } class KeychainManager { private let accessGroup = "group.com.yourapp.shared" private let tokenKeys = [ "userId": "com.yourapp.cognito.user-id", "accessToken": "com.yourapp.cognito.access-token", "refreshToken": "com.yourapp.cognito.refresh-token" ] func saveToken(value: String, forKey key: String) throws { guard let keyIdentifier = tokenKeys[key] else { return } let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: keyIdentifier, kSecAttrAccessGroup: accessGroup, kSecValueData: value.data(using: .utf8)! ] SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } } func getToken(forKey key: String) throws -> String { guard let keyIdentifier = tokenKeys[key] else { throw KeychainError.noData } let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: keyIdentifier, kSecAttrAccessGroup: accessGroup, kSecReturnData: kCFBooleanTrue!, kSecMatchLimit: kSecMatchLimitOne ] var data: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &data) guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } guard let dataValue = data as? Data, let token = String(data: dataValue, encoding: .utf8) else { throw KeychainError.unexpectedData } return token } }
App Intents中令牌刷新与数据提交
import AppIntents struct SubmitDataIntent: AppIntent { static var title: LocalizedStringResource = "提交用户数据" static var description: IntentDescription = IntentDescription("收集并提交用户数据到Web服务") @Parameter(title: "用户数据") var userData: String func perform() async throws -> some IntentResult { let keychain = KeychainManager() let userId = try keychain.getToken(forKey: "userId") var accessToken = try keychain.getToken(forKey: "accessToken") let refreshToken = try keychain.getToken(forKey: "refreshToken") // 检查access token是否过期 if isTokenExpired(accessToken) { // 刷新令牌 let newTokens = try await refreshCognitoToken(refreshToken: refreshToken) accessToken = newTokens.accessToken try keychain.saveToken(value: accessToken, forKey: "accessToken") try keychain.saveToken(value: newTokens.refreshToken, forKey: "refreshToken") } // 提交数据到Web服务 try await submitDataToServer(userId: userId, accessToken: accessToken, data: userData) return .result(dialog: "数据提交成功") } private func isTokenExpired(_ token: String) -> Bool { // 解析JWT的exp字段判断过期状态 guard let payload = token.split(separator: ".").dropFirst().first else { return true } guard let data = Data(base64Encoded: payload + "=="), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let exp = json["exp"] as? TimeInterval else { return true } return Date().timeIntervalSince1970 > exp } private func refreshCognitoToken(refreshToken: String) async throws -> (accessToken: String, refreshToken: String) { // 调用Cognito令牌刷新接口 let url = URL(string: "https://your-cognito-domain/oauth2/token")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let body = "grant_type=refresh_token&client_id=your-client-id&refresh_token=\(refreshToken)" request.httpBody = body.data(using: .utf8) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw NSError(domain: "TokenRefresh", code: -1, userInfo: [NSLocalizedDescriptionKey: "令牌刷新失败"]) } let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] guard let newAccessToken = json?["access_token"] as? String, let newRefreshToken = json?["refresh_token"] as? String else { throw NSError(domain: "TokenRefresh", code: -2, userInfo: [NSLocalizedDescriptionKey: "令牌解析失败"]) } return (newAccessToken, newRefreshToken) } private func submitDataToServer(userId: String, accessToken: String, data: String) async throws { let url = URL(string: "https://your-web-service.com/submit")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let payload = ["userId": userId, "data": data] request.httpBody = try JSONSerialization.data(withJSONObject: payload) let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { throw NSError(domain: "DataSubmit", code: -1, userInfo: [NSLocalizedDescriptionKey: "数据提交失败"]) } } }
内容的提问来源于stack exchange,提问作者Kittovski
相关产品推荐
相关产品推荐

