在iOS设备中使用推送通知实现安全身份验证

本文详细介绍了如何使用Okta的DirectAuth和推送通知在iOS应用中构建原生、防钓鱼的多重身份验证流程,涵盖从SDK集成、配置到完整SwiftUI应用实现的全过程。

构建安全且无缝的登录体验是当今iOS开发者的核心挑战。用户期望身份验证既即时又通过多重身份验证等强大保护措施进行防护。借助Okta的DirectAuth和推送通知支持,您可以两者兼得——在无需离开应用的情况下,提供原生、防钓鱼的MFA流程。 本文将指导您完成以下操作:

  • 设置您的Okta开发者账户
  • 为DirectAuth和推送通知因子配置您的Okta组织
  • 使您的iOS应用能够原生驱动DirectAuth流程
  • 在DirectAuth的支持下创建AuthService
  • 构建一个利用AuthService的完整SwiftUI演示

目录

  • 使用带有推送通知因子的Okta DirectAuth
  • 优先选择防钓鱼的身份验证因子
  • 使用Okta移动SDK设置iOS项目
  • 使用Okta DirectAuth验证您的iOS应用
  • 将OIDC配置添加到您的iOS应用
  • 在iOS应用中使用Okta DirectAuth添加无需浏览器重定向的身份验证
  • iOS中的安全原生登录
  • 使用DirectAuth时注销用户
  • 安全刷新访问令牌
  • 显示已验证用户的信息
  • 构建SwiftUI视图以显示验证状态
  • 读取ID令牌信息
  • 查看已验证用户的个人资料信息
  • 保持令牌刷新和维护用户会话
  • 构建您自己的安全原生登录iOS应用

使用带有推送通知因子的Okta DirectAuth

实现基于推送的MFA直接身份验证的第一步是设置您的Okta组织并启用推送通知因子。DirectAuth允许您的应用完全在其自身的原生UI中处理身份验证——无需浏览器重定向——同时在底层仍利用Okta安全的OAuth 2.0和OpenID Connect标准。 这意味着您的应用可以无缝验证凭据、获取令牌并触发推送通知质询,而无需切换上下文或依赖SafariViewController。 开始之前,您需要一个Okta Integrator免费计划账户。注册Integrator账户后,登录您的Integrator账户。接下来,在管理控制台中:

  1. 转到 Applications > Applications
  2. 选择 Create App Integration
  3. 选择 OIDC - OpenID Connect 作为登录方法
  4. 选择 Native Application 作为应用程序类型,然后选择 Next
  5. 输入一个应用集成名称
  6. 配置重定向URI:
    • Redirect URI: com.okta.{yourOktaDomain}:/callback
    • Post Logout Redirect URI: com.okta.{yourOktaDomain}:/ (其中 {yourOktaDomain}.okta.com 是您的Okta域名)。您的域名被反转以提供在设备上打开应用的唯一方案。
  7. 选择 Advanced v.
    • 选择 OOBMFA OOB 授权类型。
  8. Controlled access 部分,选择适当的访问级别
  9. 选择 Save

注意:使用自定义授权服务器时,需要设置授权策略。请完成以下附加步骤:

  1. 在管理控制台中,转到 Security > API > Authorization Servers
  2. 选择您的自定义授权服务器(默认)
  3. Access Policies 选项卡上,确保您至少有一个策略:
    • 如果不存在策略,选择 Add New Access Policy
    • 为其命名,例如“Default Policy”
    • Assign to 设置为 “All clients”
    • 单击 Create Policy
  4. 对于您的策略,确保您至少有一条规则:
    • 如果没有规则,选择 Add Rule
    • 为其命名,例如“Default Rule”
    • Grant type is 设置为 “Authorization Code”
    • 选择 Advanced 并启用 “MFA OOB”
    • User is 设置为 “Any user assigned the app”
    • Scopes requested 设置为 “Any scopes”
    • 选择 Create Rule 有关更多详细信息,请参阅自定义授权服务器文档。

我的新应用凭证在哪里?

在管理控制台中手动创建OIDC本机应用会使用应用程序设置配置您的Okta组织。 创建应用后,您可以在应用的General选项卡上找到配置详细信息:

  • Client ID:在 Client Credentials 部分找到
1
2

  Client ID: 0oab8eb55Kb9jdMIr5d6

注意:您也可以使用Okta CLI客户端或Okta PowerShell模块来自动化此过程。有关设置应用的更多信息,请参阅此指南。

优先选择防钓鱼的身份验证因子

在实现带有推送通知的DirectAuth时,安全性仍然是您的首要任务。每个新的Okta Integrator免费计划账户都默认要求管理员使用Okta Verify配置多重身份验证。在本教程中,我们将保留这些默认设置,因为它们已经支持Okta Verify Push,这是推荐的原生安全身份验证体验因子。 通过Okta Verify的推送通知通过要求用户直接从受信任设备批准登录尝试,提供了强大的防钓鱼保护。结合生物特征验证(Face ID或Touch ID)或设备PIN强制执行,Okta Verify Push确保即使凭据泄露,也只有合法用户才能完成身份验证流程。 默认情况下,推送因子在Integrator免费组织中未启用。现在让我们启用它。 导航到 Security > Authenticators。找到 Okta Verify 并选择 Actions > Edit。在Okta Verify模态框中,找到 Verification options 并选择 Push notification (Android and iOS only)。选择 Save

使用Okta的移动SDK设置iOS项目

在集成Okta DirectAuth和推送通知MFA之前,请确保您的开发环境满足以下要求:

  • Xcode 15.0或更高版本 – 本指南假定您熟悉在Xcode中使用Swift开发iOS应用。
  • Swift 5+ – 所有示例均使用现代Swift语言特性。
  • Swift Package Manager (SPM) – 通过内置于Xcode的SPM管理依赖项。

一旦您的环境准备就绪,在Xcode中创建一个新的iOS项目,并准备将其与Okta的移动库集成。

使用Okta DirectAuth验证您的iOS应用

如果您从头开始,创建一个新的iOS应用:

  1. 打开Xcode
  2. 转到 File > New > Project
  3. 选择 iOS App 并选择 Next
  4. 输入项目名称,例如“okta-mfa-direct-auth”
  5. Interface 设置为 SwiftUI
  6. 选择 Next 并在本地保存您的项目

要将Okta的Direct Authentication SDK集成到您的iOS应用中,我们将使用Swift Package Manager(SPM)——这是在Xcode中管理依赖项的推荐且现代的方式。 请按照以下步骤操作:

  1. 在Xcode中打开您的项目(或根据需要创建一个新项目)
  2. 转到 File > Add Package Dependencies
  3. 在右上角的搜索字段中,输入:https://github.com/okta/okta-mobile-swift 并按Return键。Xcode将自动获取可用的包。
  4. 选择最新版本(推荐)或指定与您的设置兼容的版本
  5. 当提示选择要添加的产品时,确保在 OktaDirectAuthAuthFoundation 旁边选择您的应用目标
  6. 选择 Add Package

这些包提供了使用OAuth 2.0和OpenID Connect通过DirectAuth实现原生身份验证流程所需的所有工具,包括安全的令牌处理和MFA质询管理——无需依赖浏览器会话。 集成完成后,您将在Xcode中项目的 Package Dependencies 部分下看到OktaMobileSwift及其依赖项。

将OIDC配置添加到您的iOS应用

管理配置最简洁、最可扩展的方法是使用存储在应用包中的属性列表文件。 通过以下步骤为您的OIDC和应用配置创建属性列表:

  1. 右键单击项目的根文件夹
  2. 从模板中选择 New File(旧版Xcode版本中的New File)
  3. 确保顶部选择器选择了 iOS
  4. 选择 Property List 模板并选择 Next
  5. 将模板命名为 Okta 并选择 Create 以创建 Okta.plist 文件

您可以通过右键单击并选择 Open As > Source Code 以XML格式编辑文件。将以下代码复制并粘贴到文件中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>scopes</key>
    <string>openid profile offline_access</string>
    <key>redirectUri</key>
    <string>com.okta.{yourOktaDomain}:/callback</string>
    <key>clientId</key>
    <string>{yourClientID}</string>

    <string>{yourOktaDomain}/oauth2/default</string>
    <key>logoutRedirectUri</key>
    <string>com.okta.{yourOktaDomain}:/</string>
</dict>
</plist>

{yourOktaDomain}{yourClientID} 替换为来自您Okta组织的值。 如果在代码中使用类似这样的方式,您可以直接访问DirectAuth共享实例,该实例已经初始化并准备好处理身份验证请求。

在iOS应用中使用Okta DirectAuth添加无需浏览器重定向的身份验证

现在您已经添加了SDK和属性列表文件,让我们为您的应用实现主要的身份验证逻辑。 我们将构建一个名为 AuthService 的专用服务,负责用户登录和注销、刷新令牌以及管理会话状态。 该服务将依赖 OktaDirectAuth 进行原生身份验证,依赖 AuthFoundation 进行安全的令牌处理。 要进行设置,在您的项目文件夹结构下创建一个名为 Auth 的新文件夹,然后添加一个名为 AuthService.swift 的新Swift文件。 在这里,您将定义您的身份验证协议和一个直接与Okta SDK集成的具体类——使其易于在您的SwiftUI或UIKit视图中使用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import AuthFoundation
import OktaDirectAuth
import Observation
import Foundation

protocol AuthServicing {
  // 登录用户的 accessToken
  var accessToken: String? { get }

  // 驱动SwiftUI的状态
  var state: AuthService.State { get }

  // 登录(密码 + Okta Verify 推送)
  func signIn(username: String, password: String) async throws

  // 注销并撤销令牌
  func signOut() async

  // 刷新访问令牌(如果可能)(如果刷新则返回更新后的令牌)
  func refreshTokenIfNeeded() async throws

  // 从Credential中获取userInfo
  func userInfo() async throws -> UserInfo?
}

添加此代码后,您将收到一个错误,提示找不到 AuthService。这是因为我们尚未创建该类。在此代码下方,添加以下 AuthService 类的声明:

1
2
3
4
@Observable
final class AuthService: AuthServicing {

}

完成后,我们接下来需要确认 AuthService 类符合 AuthServicing 协议,并创建 State 枚举,该枚举将保存我们身份验证过程的所有状态。 为此,首先在 AuthService 类中创建 State 枚举,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Observable
final class AuthService: AuthServicing {
  enum State: Equatable {
    case idle
    case authenticating
    case waitingForPush
    case authorized(Token)
    case failed(errorMessage: String)
  }
}

新代码解决了关于 AuthServiceState 枚举的两个错误。我们只剩下一个错误需要修复,即确认该类符合协议。 我们将从上到下开始实现函数。首先添加协议中的两个变量 accessTokenstate。在枚举定义之后,我们将添加属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Observable
final class AuthService: AuthServicing {
  enum State: Equatable {
    case idle
    case authenticating
    case waitingForPush
    case authorized(Token)
    case failed(errorMessage: String)
  }

  private(set) var state: State = .idle

  var accessToken: String? {
    return nil
  }
}

目前,我们将 accessToken 的getter留空,返回 nil,因为我们尚未使用令牌。稍后我们将添加其实现。 接下来,我们将添加一个私有属性来保存对 DirectAuthenticationFlow 实例的引用。 该对象管理整个DirectAuth过程,包括凭据验证、MFA质询和令牌颁发。该对象必须在身份验证步骤之间持久存在。 在现有的 stateaccessToken 属性之间插入以下变量:

1
2
3
4
5
6
private(set) var state: State = .idle
@ObservationIgnored private let flow: DirectAuthenticationFlow?

var accessToken: String? {
  return nil
}

要为 flow 变量分配内存,我们需要为 AuthService 类实现一个初始化器。在其中,我们将使用之前介绍的 PropertyListConfiguration 来分配 flow。在 accessToken getter之后,添加以下函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// MARK: Init

init() {
  // 如果Okta.plist存在,则首选PropertyListConfiguration;否则回退
  if let configuration = try? OAuth2Client.PropertyListConfiguration() {
      self.flow = try? DirectAuthenticationFlow(client: OAuth2Client(configuration))
  } else {
      self.flow = try? DirectAuthenticationFlow()
  }
}

这将尝试从项目文件夹中获取 Okta.plist 文件,如果未找到,将回退到 DirectAuthenticationFlow 的默认初始化器。现在我们已经成功分配了 DirectAuthenticationFlow,可以继续实现协议的下一个函数。 向下移动到协议中的第一个函数,即 signIn(username: String, password: String)。 下面的 signIn 方法使用Okta DirectAuth和Auth Foundation执行完整的身份验证流程。 它使用用户名和密码对用户进行身份验证,处理MFA质询(本例中为Okta Verify Push),并安全存储生成的令牌以供未来的API调用。在我们刚刚添加的 Init 下方添加以下代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// MARK: AuthServicing
func signIn(username: String, password: String) {
  Task { @MainActor in
    // 1️⃣ 开始登录过程
    // 更新UI状态,并使用用户名/密码开始DirectAuth流程。
    state = .authenticating
    do {
      let result = try await flow?.start(username, with: .password(password))

      switch result {
        // 2️⃣ 处理成功的身份验证
        // Okta已验证凭据,返回访问/刷新/ID令牌。
      case .success(let token):
        let newCred = try Credential.store(token)
        Credential.default = newCred
        state = .authorized(token)

        // 3️⃣ 使用推送通知处理MFA
        // Okta需要MFA,等待通过Okta Verify的推送批准。
      case .mfaRequired:
        state = .waitingForPush
        let status = try await flow?.resume(with: .oob(channel: .push))
        if case let .success(token) = status {
          Credential.default = try Credential.store(token)
          state = .authorized(token)
        }
      default:
        break
      }
    } catch {
      // 4️⃣ 优雅地处理错误
      // 更新状态,为UI显示描述性错误消息。
      state = .failed(errorMessage: error.localizedDescription)
    }
  }
}

让我们逐步分解正在发生的事情:

  1. 开始登录过程 当函数被调用时,它会启动一个新的异步Task,并将UI状态设置为 .authenticating。 然后使用提供的用户名和密码启动DirectAuth流程: let result = try await flow?.start(username, with: .password(password)) 这将用户的凭据发送到Okta的Direct Authentication API并等待响应。
  2. 处理成功的身份验证 如果Okta验证了凭据且不需要额外的验证,结果将是 .success(token)。 返回的 Token 对象包含访问、刷新和ID令牌。 我们使用AuthFoundation安全地持久化凭据: let newCred = try Credential.store(token)Credential.default = newCred 然后将状态设置为 .authorized(token),标记用户为已验证,并更新应用状态,允许您的UI过渡到已登录体验。
  3. 使用推送通知处理MFA 如果Okta确定需要MFA质询,结果将是 .mfaRequired。 应用将其状态更新为 .waitingForPush,提示用户在Okta Verify应用中批准登录: state = .waitingForPush let status = try await flow?.resume(with: .oob(channel: .push)) .oob(channel: .push) 参数通过等待Okta Verify的推送批准事件来恢复身份验证流程。 一旦用户批准,Okta将返回一个新令牌:
    1
    2
    3
    4
    
    if case let .success(token) = status {
        Credential.default = try Credential.store(token)
        state = .authorized(token)
    }
    
  4. 处理错误 如果任何步骤失败(例如,无效凭据、网络问题或推送超时),catch块会更新UI以显示错误消息: state = .failed(errorMessage: error.localizedDescription)

iOS中的安全、原生登录 此函数演示了使用Okta DirectAuth的完整原生登录体验,无需Web视图,无需重定向。 它在应用的Swift层内对用户进行身份验证、安全地管理令牌存储并处理基于推送的MFA——使身份验证流程快速、安全且无摩擦。

使用DirectAuth时注销用户

协议中的下一个方法是 signOut 方法。此方法提供了一种干净、安全的方式将用户从应用中注销。 它从Okta撤销用户的活跃令牌并重置本地身份验证状态,确保设备上不留存过时的凭据。在 signIn 方法下方添加以下代码:

1
2
3
4
5
6
7
func signOut() async {
  if let credential = Credential.default {
    try? await credential.revoke()
  }
  Credential.default = nil
  state = .idle
}

让我们看看每一步做了什么:

  1. 检查现有凭据 if let credential = Credential.default { 该方法首先检查内存中是否存在存储的凭据(令牌)。 Credential.default 表示之前在登录期间创建的当前已验证会话。
  2. 从Okta撤销令牌 try? await credential.revoke() 这行代码告诉Okta使与该凭据关联的访问令牌和刷新令牌失效。 调用 revoke() 确保用户的会话在本地和授权服务器上终止,防止使用这些令牌进行进一步的API访问。 使用 try? 运算符是为了安全地忽略任何错误(例如,注销期间的网络故障),因为令牌撤销是一项尽力而为的操作。
  3. 清除本地凭据数据 Credential.default = nil 撤销令牌后,应用清除本地凭据对象。 这会从内存中移除任何敏感的身份验证数据,确保设备上不再保留有效令牌。
  4. 重置身份验证状态 state = .idle 最后,应用将其内部状态更新回 .idle,这告诉UI用户现在已注销,可以开始新的会话。 您可以使用此状态触发回登录屏幕的过渡或关闭已验证功能。

协议确认几乎完成,我们只剩下两个函数需要实现。

安全刷新访问令牌

Okta颁发的访问令牌具有有限的生命周期,以降低在泄露时被滥用的风险。无法维护密钥的OAuth客户端(如移动应用)需要较短的访问令牌生命周期以确保安全。 为了保持无缝的用户体验,您的应用应在令牌过期前自动刷新它们。 refreshTokenIfNeeded() 方法使用AuthFoundation内置的令牌管理API安全地处理此过程。 让我们看看它的作用。在 signOut 方法之后添加以下代码:

1
2
3
4
func refreshTokenIfNeeded() async throws {
  guard let credential = Credential.default else { return }
  try await credential.refresh()
}
  1. 检查现有凭据 guard let credential = Credential.default else { return } 在尝试刷新令牌之前,该方法检查是否存在有效的凭据。 如果未存储凭据(例如,用户尚未登录或已注销),该方法将提前退出。
  2. 刷新令牌 try await credential.refresh() 这行代码告诉Okta用刷新令牌交换新的访问令牌和ID令牌。 refresh() 方法会自动使用新令牌更新 Credential 对象,并使用AuthFoundation安全地持久化它们。 如果刷新令牌已过期或无效,此调用将抛出错误——允许您的应用检测问题并提示用户重新登录。

显示已验证用户的信息

最后,让我们看看 userInfo() 函数。身份验证后,您的应用可以从Okta使用标准的OIDC端点访问用户的个人资料信息——例如他们的姓名、电子邮件或用户ID。 userInfo() 方法从ID令牌或通过调用授权服务器的 /userinfo 端点检索此数据。ID令牌不一定包含所有个人资料信息,因为ID令牌有意设计为轻量级。 以下是它的工作原理。在 refreshTokenIfNeeded() 的末尾添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func userInfo() async throws -> UserInfo? {
  if let userInfo = Credential.default?.userInfo {
    return userInfo
  } else {
    do {
      guard let userInfo = try await Credential.default?.userInfo() else {
        return nil
      }
      return userInfo
    } catch {
      return nil
    }
  }
}
  1. 返回缓存的用户信息 if let userInfo = Credential.default?.userInfo { return userInfo } 如果用户的个人资料信息已获取并存储在内存中,该方法会立即返回。 这避免了不必要的网络调用,提供了快速、响应迅速的用户体验。
  2. 获取用户信息
    1
    2
    3
    4
    
    guard let userInfo = try await Credential.default?.userInfo() else {
        return nil
    }
    return userInfo
    
    如果缓存的数据不可用,该方法直接从Okta使用UserInfo端点获取。 此端点返回标准的OpenID Connect声明,例如:
    • sub(用户的唯一ID)
    • name
    • email
    • preferred_username
    • 等等… AuthFoundation SDK会为您处理请求和解析,返回一个 UserInfo 对象。
  3. 优雅地处理错误 catch { return nil } 如果请求失败(例如,由于网络问题或令牌过期),函数返回 nil。 这可以防止您的应用崩溃,并允许您通过显示默认用户状态或提示重新身份验证来处理错误。

完成此实现后,您已解决了所有错误,应该能够构建应用了。🎉

构建SwiftUI视图以显示已验证状态

现在我们已经构建了 AuthService 来处理登录、注销、令牌管理和用户信息检索,让我们看看如何将其集成到应用的UI中。 为了保持架构的一致性,将默认的 ContentView 重命名为 AuthView 并更新所有引用。 这明确了视图的用途——它将作为主要的身份验证界面。 然后,在您的项目文件夹下创建一个 Views 文件夹,将 AuthView 拖放到新创建的文件夹中,并在同一文件夹中创建一个名为 AuthViewModel.swift 的新文件。 AuthViewModel 将封装所有与身份验证相关的状态和操作,充当视图与底层 AuthService 之间的通信层。 在 AuthViewModel.swift 中添加以下代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
import Foundation
import Observation
import AuthFoundation

/// `AuthViewModel` 充当您的应用UI与身份验证层(`AuthService`)之间的桥梁。
/// 它协调用户操作,例如登录、注销、刷新令牌和获取用户个人资料数据。
/// 此类使用Swift的 `@Observable` 宏,以便您的SwiftUI视图可以自动响应状态更改。
@Observable
final class AuthViewModel {
  // MARK: - 依赖项

  /// 负责处理DirectAuth登录、基于推送的MFA、令牌管理和用户信息检索的身份验证服务。
  private let authService: AuthServicing

  // MARK: - UI状态属性

  /// 存储用户的令牌,可用于与验证用户身份的后端服务进行安全通信。
  var accessToken: String?

  /// 表示加载状态。当后台操作正在运行时(例如登录、注销或令牌刷新)设置为 `true`,以显示进度指示器。
  var isLoading: Bool = false

  /// 保存应显示在UI中的任何人类可读错误消息(例如,无效凭据或网络错误)。
  var errorMessage: String?

  /// username和password属性绑定到UI中的文本字段。
  /// 当用户输入时,这些值会自动更新,这得益于SwiftUI的反应式数据绑定。
  /// 然后,视图模型在用户提交表单时使用它们执行DirectAuth登录。
  var username: String = ""
  var password: String = ""

  /// 公开当前身份验证状态(idle、authenticating、waitingForPush、authorized、failed),由 `AuthService.State` 枚举定义。视图可以使用它来显示正确的UI。
  var state: AuthService.State {
    authService.state
  }

  // MARK: - 初始化

  /// 使用 `AuthService` 的默认实例初始化视图模型。
  /// 您可以为测试注入模拟的 `AuthServicing` 实现。
  init(authService: AuthServicing = AuthService()) {
    self.authService = authService
  }

  // MARK: - 身份验证操作

  /// 尝试使用提供的凭据对用户进行身份验证。
  /// 这会触发完整的DirectAuth流程——包括密码验证、推送通知MFA(如果需要)以及通过AuthFoundation安全存储令牌。
  @MainActor
  func signIn() async {
    setLoading(true)
    defer { setLoading(false) }

    do {
      try await authService.signIn(username: username, password: password)
      accessToken = authService.accessToken
    } catch {
      errorMessage = error.localizedDescription
    }
  }

  /// 通过撤销活动令牌、清除本地凭据和重置应用的身份验证状态来注销用户。
  @MainActor
  func signOut() async {
    setLoading(true)
    defer { setLoading(false) }

    await authService.signOut()
  }

  // MARK: - 令牌处理

  /// 使用用户的刷新令牌刷新用户的访问令牌。
  /// 这允许应用在访问令牌过期后保持有效会话,而无需要求用户再次登录。
  @MainActor
  func refreshToken() async {
    setLoading(true)
    defer { setLoading(false) }

    do {
      try await authService.refreshTokenIfNeeded()
      accessToken = authService.accessToken
    } catch {
      errorMessage = error.localizedDescription
    }
  }

  // MARK: - 用户信息检索

  /// 从Okta获取已验证用户的个人资料信息。
  /// 返回一个包含标准OIDC声明(如 `name`、`email` 和 `sub`)的 `UserInfo` 对象。
  /// 如果获取失败(例如,由于令牌过期或网络问题),则返回 `nil`。
  @MainActor
  func fetchUserInfo() async -> UserInfo? {
    do {
      let userInfo = try await authService.userInfo()
      return userInfo
    } catch {
      errorMessage = error.localizedDescription
      return nil
    }
  }

  // MARK: - UI辅助函数

  /// 更新 `isLoading` 属性。用于在后台工作进行时在SwiftUI视图中显示或隐藏加载指示器。
  private func setLoading(_ value: Bool) {
    isLoading = value
  }
}

有了视图模型后,下一步是将其绑定到您的SwiftUI视图。 AuthView 将观察 AuthViewModel,随着身份验证状态的变化自动更新。 它将在用户通过身份验证时显示用户的ID令牌,并提供用于登录、注销和刷新令牌的控件。 打开 AuthView.swift,删除现有的模板代码,并插入以下实现:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import SwiftUI
import AuthFoundation

/// `UserInfo` 的简单包装器,用于在全屏模态中显示用户个人资料数据。
/// 符合 `Identifiable`,以便与 `.fullScreenCover(item:)` 一起使用。
struct UserInfoModel: Identifiable {
  let id = UUID()
  let user: UserInfo
}

/// 用于管理身份验证体验的主要SwiftUI视图。
/// 该视图观察 `AuthViewModel`,根据当前身份验证流程显示不同的UI状态,
/// 并提供用于登录、注销、刷新令牌以及查看用户或令牌信息的控件。
struct AuthView: View {

  // MARK: - 视图模型

  /// 管理所有身份验证逻辑和状态转换的视图模型。
  /// 它使用Swift Observation框架的 `@Observable`,因此此处的更改会自动触发UI更新。
  @State private var viewModel = AuthViewModel()

  // MARK: - 状态和呈现

  /// 保存当前获取的用户信息(如果可用)。
  /// 当设置此值时,`UserInfoView` 将作为全屏表单显示。
  @State private var userInfo: UserInfoModel?

  /// 控制是否将令牌信息屏幕作为全屏模态显示。
  @State private var showTokenInfo = false

  // MARK: - 视图主体

  var body: some View {
    VStack {
      // 根据当前身份验证状态渲染UI。
      // 每种情况对应DirectAuth流程的不同阶段。
      switch viewModel.state {
      case .idle, .failed:
        loginForm
      case .authenticating:
        ProgressView("Signing in...")
      case .waitingForPush:
        // 等待Okta Verify推送批准
        WaitingForPushView {
          Task { await viewModel.signOut() }
        }
      case .authorized:
        successView
      }
    }
    .padding()
  }
}

// MARK: - 登录表单视图
private extension AuthView {
  /// 当用户未通过身份验证时显示的初始登录表单。
  /// 捕获用户名和密码输入并触发DirectAuth登录流程。
  private var loginForm: some View {
    VStack(spacing: 16) {
      Text("Okta DirectAuth (Password + Okta Verify Push)")
        .font(.headline)

      // 电子邮件输入字段(绑定到视图模型的username属性)
      TextField("Email", text: $viewModel.username)
        .keyboardType(.emailAddress)
        .textContentType(.username)
        .textInputAutocapitalization(.never)
        .autocorrectionDisabled()

      // 安全密码输入字段
      SecureField("Password", text: $viewModel.password)
        .textContentType(.password)

      // 通过DirectAuth和推送MFA触发身份验证
      Button("Sign In") {
        Task { await viewModel.signIn() }
      }
      .buttonStyle(.borderedProminent)
      .disabled(viewModel.username.isEmpty || viewModel.password.isEmpty)

      // 如果登录失败,显示错误消息
      if case .failed(let message) = viewModel.state {
        Text(message)
          .foregroundColor(.red)
          .font(.footnote)
      }
    }
  }
}

// MARK: - 已授权状态视图
private extension AuthView {
  /// 在用户成功登录并完成MFA后显示。
  /// 显示用户的ID令牌,并提供用于令牌刷新、用户信息、令牌详细信息和注销的操作。
  private var successView: some View {
    VStack(spacing: 16) {
      Text("Signed in 🎉")
        .font(.title2)
        .bold()

      // 可滚动的ID令牌显示(用于演示目的)
      ScrollView {
        Text(Credential.default?.token.idToken?.rawValue ?? "(no id token)")
          .font(.footnote)
          .textSelection(.enabled)
          .padding()
          .background(.thinMaterial)
          .cornerRadius(8)
      }
      .frame(maxHeight: 220)

      // 已验证用户操作
      signoutButton
    }
    .padding()
  }
}

// MARK: - 操作按钮
private extension AuthView {
  /// 注销用户,撤销令牌并返回到登录表单。
  var signoutButton: some View {
    Button("Sign Out") {
      Task { await viewModel.signOut() }
    }
    .font(.system(size: 14))
  }
}

添加此代码后,您将收到一个错误,指出在当前作用域中找不到 WaitingForPushView。要解决此问题,我们需要接下来添加该视图。在 Views 文件夹中添加一个新的空Swift文件,并将其命名为 WaitingForPushView。完成后,在其中添加以下实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import SwiftUI

struct WaitingForPushView: View {
  let onCancel: () -> Void

  var body: some View {
    VStack(spacing: 16) {
      ProgressView()
      Text("Approve the Okta Verify push on your device.")
        .multilineTextAlignment(.center)

      Button("Cancel", action: onCancel)
    }
    .padding()
  }
}

现在您可以在模拟器上运行应用程序,它应该首先为您提供使用用户名和密码登录的选项。选择SignIn后,它将重定向到“等待推送通知”屏幕,并保持活动状态,直到您从Okta Verify应用程序确认请求。如果您已登录,您将看到访问令牌和注销按钮。

读取ID令牌信息

一旦您的应用使用Okta DirectAuth对用户进行身份验证,生成的凭据将通过AuthFoundation安全地存储在设备的钥匙串中。 这些凭据包括访问、ID和(可选)刷新令牌——所有这些对于安全调用API或验证用户身份都至关重要。 在本节中,我们将创建一个骨架 TokenInfoView,它从 Credential.default 读取当前令牌,并以对开发人员友好的格式显示它们。 此视图有助于可视化存储中的凭据并检查作用域。它还有助于验证身份验证流程是否正常工作。 在 Views 文件夹中创建一个新的Swift文件,并将其命名为 TokenInfoView。添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import SwiftUI
import AuthFoundation

/// 显示存储在当前的 `Credential.default` 实例中的令牌的详细信息。
/// 此视图有助于调试和验证您的DirectAuth流程——确认令牌是否正确颁发、存储和刷新。
///
/// ⚠️ **重要提示:** 避免在生产应用中显示完整的令牌字符串。令牌应被视为敏感机密。
struct TokenInfoView: View {

  /// 检索由 `AuthFoundation` 管理的当前凭据对象。
  /// 如果用户已登录,这将包含其访问、ID和刷新令牌。
  private var credential: Credential? { Credential.default }

  /// 用于在点击关闭按钮时关闭当前视图。
  @Environment(\.dismiss) var dismiss

  var body: some View {
    ScrollView {
        VStack(alignment: .leading, spacing: 20) {

          // MARK: - 关闭按钮
          // 点击时关闭令牌信息视图。
          Button {
            dismiss()
          } label: {
            Image(systemName: "xmark.circle.fill")
              .resizable()
              .foregroundStyle(.black)
              .frame(width: 40, height: 40)
              .padding(.leading, 10)
          }

          // MARK: - 令牌显示
          // 将令牌信息显示为格式化的等宽文本。
          // 如果没有可用的凭据,则显示“No token found”消息。
          Text(credential?.toString() ?? "No token found")
            .font(.system(.body, design: .monospaced))
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
    .background(Color(.systemGroupedBackground))
    .navigationTitle("Token Info")
    .navigationBarTitleDisplayMode(.inline)
  }
}

// MARK: - 凭据显示辅助函数

extension Credential {
  /// 返回存储的令牌值的格式化字符串表示形式。
  /// 包括访问、ID和刷新令牌及其关联的作用域。
  ///
  /// - 返回:适用于在 `TokenInfoView` 中调试和显示的多行字符串。
  func toString() -> String {
    var result = ""

    result.append("Token
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计