在iOS应用中实现基于推送通知的安全身份验证

本文详细介绍了如何在iOS应用中集成Okta DirectAuth和推送通知MFA,实现无需浏览器重定向的原生、钓鱼攻击防护的身份验证流程,涵盖SDK集成、认证服务构建及SwiftUI界面开发。

在iOS设备上通过推送通知实现安全身份验证

构建安全且无缝的登录体验是当今iOS开发者的核心挑战。用户期望身份验证既快速即时,又能通过多因素身份验证等强大保护措施来保护他们。借助Okta的DirectAuth和推送通知支持,您可以两者兼得——在无需离开应用的情况下,提供原生、防钓鱼的MFA流程。

在本文中,我们将引导您完成以下步骤:

  • 设置您的Okta开发者账户
  • 为DirectAuth和推送通知因素配置您的Okta组织
  • 启用您的iOS应用以原生驱动DirectAuth流程
  • 借助DirectAuth的支持创建AuthService
  • 构建一个完全可运行的SwiftUI演示,利用AuthService

目录

  • 使用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 Free Plan账户。要获取一个,请注册一个Integrator账户。拥有账户后,登录到您的Integrator账户。接下来,在管理控制台中:

  1. 转到 应用程序 > 应用程序
  2. 选择 创建应用集成
  3. 选择 OIDC - OpenID Connect 作为登录方法
  4. 选择 Native Application 作为应用程序类型,然后选择 下一步
  5. 输入应用集成名称
  6. 配置重定向URI:
    • 重定向 URI:com.okta.{yourOktaDomain}:/callback
    • 注销后重定向 URI:com.okta.{yourOktaDomain}:/(其中 {yourOktaDomain}.okta.com 是您的Okta域名)。您的域名被反转以提供在设备上打开应用的唯一方案。
  7. 选择 高级 v.
    • 选择 OOB 和 MFA OOB 授权类型。
  8. 在 受控访问 部分,选择适当的访问级别
  9. 选择 保存

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

  1. 在管理控制台中,转到 安全 > API > 授权服务器
  2. 选择您的自定义授权服务器(默认)
  3. 在 访问策略 选项卡上,确保您至少有一个策略:
    • 如果不存在策略,请选择 添加新访问策略
    • 为其命名,如“默认策略”
    • 将 分配对象 设置为“所有客户端”
    • 单击 创建策略
  4. 对于您的策略,确保您至少有一条规则:
    • 如果不存在规则,请选择 添加规则
    • 为其命名,如“默认规则”
    • 将 授权类型 设置为“授权码”
    • 选择 高级 并启用“MFA OOB”
    • 将 用户 设置为“任何分配了该应用的用户”
    • 将 请求的作用域 设置为“任何作用域”
    • 选择 创建规则

更多详情,请参阅自定义授权服务器文档。

我的新应用的凭证在哪里?

在管理控制台中手动创建OIDC原生应用会使用应用设置配置您的Okta组织。创建应用后,您可以在应用的“常规”选项卡上找到配置详细信息:

  • 客户端ID:位于 客户端凭据 部分
  • 颁发者:通过选择导航窗格中的 安全 > API 显示的授权服务器的颁发者URI字段中找到。

例如:

1
2
颁发者:    https://dev-133337.okta.com/oauth2/default
客户端 ID: 0oab8eb55Kb9jdMIr5d6

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

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

在实现带有推送通知的DirectAuth时,安全性仍然是您的首要任务。每个新的Okta Integrator Free Plan账户都默认要求管理员使用Okta Verify配置多因素身份验证。我们将在本教程中保留这些默认设置,因为它们已经支持Okta Verify推送,这是原生安全身份验证体验的推荐因素。

通过Okta Verify的推送通知通过要求用户直接从受信任的设备批准登录尝试,提供了强大的防钓鱼保护。结合生物特征验证或设备PIN强制执行,Okta Verify推送确保即使凭据被泄露,也只有合法用户才能完成身份验证流程。

默认情况下,Integrator Free组织中未启用推送因素。现在让我们启用它。

导航到 安全 > 验证器。找到 Okta Verify 并选择 操作 > 编辑。在Okta Verify模态窗口中,找到 验证选项 并选择 推送通知(仅限Android和iOS)。选择 保存。

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

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

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

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

使用Okta DirectAuth对您的iOS应用进行身份验证

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

  1. 打开Xcode
  2. 转到 文件 > 新建 > 项目
  3. 选择 iOS App 并选择 下一步
  4. 输入项目名称,例如“okta-mfa-direct-auth”
  5. 将 界面 设置为 SwiftUI
  6. 选择 下一步 并在本地保存您的项目

要将Okta的直接身份验证SDK集成到您的iOS应用中,我们将使用Swift Package Manager——这是在Xcode中管理依赖项的推荐且现代的方式。

请按照以下步骤操作:

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

这些包提供了您实现使用OAuth 2.0和OpenID Connect的原生身份验证流程所需的所有工具,包括安全的令牌处理和MFA质询管理——而无需依赖浏览器会话。

集成完成后,您将在Xcode中项目的“包依赖项”部分看到OktaMobileSwift及其依赖项。

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

管理配置最简洁且最可扩展的方法是使用存储在应用包中的Okta属性列表文件。

通过以下步骤为您的OIDC和应用配置创建属性列表:

  1. 右键单击项目的根文件夹
  2. 从模板中选择 新建文件(在旧版Xcode版本中为“新建文件”)
  3. 确保在顶部的选择器中选择了 iOS
  4. 选择 属性列表 模板并选择 下一步
  5. 将模板命名为 Okta 并选择 创建 以创建 Okta.plist 文件

您可以右键单击并选择 打开方式 > 源代码 以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 {
  // 已登录用户的访问令牌
  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中获取用户信息
  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)
  }
}

新代码解决了关于AuthService和State枚举的两个错误。我们只剩下一个错误需要修复,即让该类遵循协议。

我们将从上到下开始实现函数。首先,我们从协议中添加两个变量,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推送),并安全地存储生成的令牌以供将来的API调用使用。在我们刚刚添加的初始化器下添加以下代码。

 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的直接身份验证API并等待响应。

2. 处理成功的身份验证 如果Okta验证了凭据且不需要额外的验证,结果将是.success(token)。 返回的Token对象包含访问、刷新和ID令牌。 我们使用AuthFoundation安全地持久化凭据:

1
2
3
let newCred = try Credential.store(token)
Credential.default = newCred
state = .authorized(token)

这标志着用户已通过身份验证并更新应用状态,允许您的UI过渡到已登录体验。

3. 处理带推送通知的MFA 如果Okta确定需要MFA质询,结果将是.mfaRequired。 应用将其状态更新为.waitingForPush,提示用户在其Okta Verify应用上批准登录:

1
2
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——使身份验证流程快速、安全且无缝。

下图说明了在使用Okta DirectAuth与推送通知身份验证因素时,身份验证流程在底层的工作原理:

在使用DirectAuth时注销用户

协议函数中的下一个是注销方法。此方法提供了一种干净安全的方式来将用户注销应用。它从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()函数。身份验证后,您的应用可以使用标准OIDC端点从Okta访问用户的个人资料信息——例如他们的姓名、电子邮件或用户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
guard let userInfo = try await Credential.default?.userInfo() else {
  return nil
}

如果缓存的数据不可用,该方法使用UserInfo端点直接从Okta获取。此端点返回标准的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
111
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?

  /// 用户名和密码属性绑定到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
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和Push 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 App的请求。如果您已登录,您将看到访问令牌和一个注销按钮。

读取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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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: - 令牌显示
          // 以格式化的等宽文本显示令牌信息。
          // 如果没有可用的凭据,则显示“未找到令牌”消息。
          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和刷新令牌以及它们关联的作用域。
  ///
  /// - Returns: 适合在`TokenInfoView`中调试和显示的多行字符串。
  func toString() -> String {
    var result = ""

    result.append("Token type: \(token.tokenType)")
    result.append("\n\n")

    result.append("Access Token: \(token.accessToken)")
    result.append("\n\n")

    result.append("Scopes: \(token.scope?.joined(separator: ",") ?? "No scopes found")")
    result.append("\n\n")

    if let idToken = token.idToken {
      result.append("ID Token: \(idToken.rawValue)")
      result.append("\n\n")
    }

    if let refreshToken = token.refreshToken {
      result.append("Refresh Token: \(refreshToken)")
      result.append("\n\n")
    }

    return result
  }
}

要在屏幕上查看此内容,我们需要指示SwiftUI呈现它。我们在AuthView中为此目的添加了State变量

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计