目录
Windows Vista 在平台集成方面为开发人员提供了许多新的机会。新的凭据提供程序模型是变动最大的方面之一,由于它的出现,实现操作系统支持的新用户身份验证方案变得容易了许多。它已取代了 GINA(图形标识与身份验证)模型,而直言不讳地说,后者一向因为开发人员难以理解和实现以及昂贵的 Microsoft 支持费用而广为诟病。
那么 Windows® 登录插件接口的一个变化竟会如此令人兴奋,其原因何在?用户打开计算机时首先看到的是登录屏幕。由于登录体验是由凭据提供程序来控制和管理的,这使得自定义登录体验以及集成最符合组织需要的身份验证方法变得容易了许多。简而言之,凭据提供程序为开发和实现更好、更可靠的安全性提供了一种更容易的方式。
新旧两种体系结构的比较
我不想过细地阐述基于 GINA 的登录体系结构。不过,花点时间比较一下这两个体系结构,帮助您更好地理解新的体系结构以及其中的变动,这也很值当。
在 Windows Vista™ 之前的环境中,每个会话都有一个 winlogon 实例,它负责控制该会话的交互式登录序列。(图 1 显示了 Windows XP 和 Windows Server® 2003 旧的登录体系结构。)在刚启动的系统中,控制台位置的交互式登录始终在会话 0 中执行。会话 0 承载运行系统服务以及其他关键进程,包括“本地安全机构”(Local Security Authority) 进程。(换句话说,在会话 0 中运行的许多进程都没有在图 1 中显示出来。)
图 1 GINA 登录体系结构
计算机上已注册的 GINA 加载到 winlogon 进程空间中。(还可能加载一个称作“GINA 链接”的配置,但测试和支持这样的复杂配置很困难。)最后,GINA 调用 LogonUser 以及相关的身份验证 API。
在 Windows Vista 中,会话 0 不再用于交互式登录(请参见图 2)。这有利于提高安全性,因为现在已有一个会话边界将所有的计算机进程与各个用户的进程分隔开来。此外,现在对内核全局命名空间的控制也更加严格,因为默认情况下由用户应用程序创建的对象已不在内核全局命名空间之内。
图 2 新的登录体系结构
除会话 0 之外的每一个会话仍会有一个 winlogon 实例。图 2 显示,系统中已注册了几个凭据提供程序,并已通过新的 LogonUI 进程加载。
在由哪个组件负责显示登录图形界面方面也有一个重要的改动。以前,这是由 GINA 来处理的,因此,显示界面的工作可能一直由第三方组件来完成。在新的体系结构中,这是由操作系统的一个内置组件 LogonUI 来负责完成的。
那么每个提供程序的用户提示行为在新的模型中是如何实现的呢?“凭据提供程序”体系结构要求每个提供程序都要列举说明它所需要的 UI 元素。例如,在某个指定的方案中,提供程序可能会向 LogonUI 表明它需要两个编辑框、两个标题、一个复选框和一个位图。然后,LogonUI 为凭据提供程序显示这些控件。这对实现以前讨论的目标大有帮助,即用一致的外观和方法来广泛支持不断修改完善的用户验证方案。
负责“凭据提供程序”开发的 Microsoft 开发团队原以为外部开发人员会更愿意基于 COM 来开发插件模型。然而,在 Windows Vista 开发周期的早期阶段,新接口最初的内部设计(类似于 GINA)完全基于 LoadLibrary 和函数指针。之后,基于 COM 的重新设计吸取了第一次的教训,使得设计出来的界面更加简洁和易用。现在我们转到示例代码,来帮助引导我们深入了解凭据提供程序接口。
混合凭据提供程序
此新插件模型的计时功能臻于完美(当然,或许早就应该有这样的功能了)。现在,开发人员可以更轻松地满足多因素身份验证方案需求,同时提供与 Microsoft 原有的相一致的登录体验。
尽管如此,新的接口仍显得相当抽象。有关它的描述说明也同样令人费解,让人感到乏味!要想了解它,一个让人能提起兴趣的方式就是体验一下新凭据提供程序的设计、开发和测试过程。而且,这可以很好地弥补 Microsoft 提供的现有文档的不足 - 有关指针方面的内容,请参阅侧栏“其他资源”。
我创建了一个示例,即“混合凭据提供程序”,它会演示一些全新的功能。混合凭据提供程序允许将用户名、密码和域名存储在智能卡上。插入智能卡后,用户会自动登录。(可从《MSDN® 杂志》网站下载示例代码。)我没有重新编写代码,而是将以下三个来源的代码合在一起:
- Microsoft® Windows SDK 中提供的基于密码的凭据提供程序示例。
- 以前的 PropCert 示例,同样来自 SDK。其核心是一个用于读取基于证书的智能卡凭据的 Win32® 线程。
- (刊载于 2006 年 11 月发行的《MSDN 杂志》)中包含的示例代码。这篇文章讨论了如何通过托管代码与 Windows 智能卡子系统建立接口。
对于我在 2006 年 11 月发表的那篇文章中所提供的示例代码,我需要作进一步的说明。凭据提供程序体系结构及其主机只支持本机代码。尽管我的第一篇文章讲的主要是托管代码,但其中也包含了一个本机帮助程序 DLL,以便于公开新的智能卡模块接口。混合凭据提供程序便基于这个帮助程序 DLL。如需该 DLL 的完整源代码,同样可以通过 2006 年 11 月发表的那篇文章随附的下载内容中获得。
总之,混合凭据提供程序的代码中有很大一部分都不是新写的。其最终结果是将测试和调试时间减到最少。实际上,核心调试阶段花费了不到一天的时间,这证明了新接口的易用性。
现在详细地讨论一下我使用此示例凭据提供程序所要执行的操作。
要求
构思此混合凭据提供程序时,我希望达到以下这些要求:
- 使之基于智能卡运行
- 最大限度地增加代码的重用
- 最大限度地减少额外配置和基础结构需求
因此我采用了我的所谓混合方法,换言之就是密码(为了安全起见)加智能卡(为了方便起见)。由于混合提供程序的概念是基于用户名和密码的,因此我将从 Platform SDK 分离出来的密码提供程序示例作为我设计该方法的起点。然后,我添加了来自 SDK 的 PropCert 示例;其中包括了用于枚举智能卡读卡器、卡和数字证书的逻辑过程。我觉得我要做的就是,将 PropCert 中基于证书的逻辑代码替换为用于读取我自己的凭据数据的一些新代码,然后简单地将这两个示例连在一起就可以了!
由于我们将从智能卡读取密码登录信息,这意味着还有一个需求:一个使用该凭据初始化智能卡的工具。初始化工具将放在最后讨论。
根据这些要求,我们来看一看凭据提供程序体系结构的设计以及它如何影响我的示例代码设计。
设计
我们先从运行时凭据提供程序的角度来讨论一下凭据提供程序体系结构的设计。
虽然我尚未详细讨论混合示例,但我将使用它作为分析运行中的新凭据提供程序体系结构的基础。为了便于讨论,我的示例代码包含了调试跟踪。此跟踪包含从每个已实现的凭据提供程序例程进行的 OutputDebugString 调用。在这些跟踪调用中,我使用两个缩写。对新的 ICredentialProvider 接口(请参见图 3)的调用以“Provider::”开头。对 ICredentialProviderCredential 接口(请参见图 4)的调用以“Credential::”开头。请注意,所有凭据提供程序相关接口都是在新的公共头文件 credentialprovider.h 中定义的。
Figure 4 ICredentialProviderCredential 接口
ICredentialProviderCredential : public IUnknown
{
HRESULT STDMETHODCALLTYPE Advise(
/* [in] */ ICredentialProviderCredentialEvents *pcpce);
HRESULT STDMETHODCALLTYPE UnAdvise( void);
HRESULT STDMETHODCALLTYPE SetSelected(
/* [out] */ BOOL *pbAutoLogon);
HRESULT STDMETHODCALLTYPE SetDeselected( void);
HRESULT STDMETHODCALLTYPE GetFieldState(
/* [in] */ DWORD dwFieldID,
/* [out] */ CREDENTIAL_PROVIDER_FIELD_STATE *pcpfs,
/* [out] */ CREDENTIAL_PROVIDER_FIELD_INTERACTIVE_STATE *pcpfis);
HRESULT STDMETHODCALLTYPE GetStringValue(
/* [in] */ DWORD dwFieldID,
/* [string][out] */ LPWSTR *ppsz);
HRESULT STDMETHODCALLTYPE GetBitmapValue(
/* [in] */ DWORD dwFieldID,
/* [out] */ HBITMAP *phbmp);
HRESULT STDMETHODCALLTYPE GetCheckboxValue(
/* [in] */ DWORD dwFieldID,
/* [out] */ BOOL *pbChecked,
/* [string][out] */ LPWSTR *ppszLabel);
HRESULT STDMETHODCALLTYPE GetSubmitButtonValue(
/* [in] */ DWORD dwFieldID,
/* [out] */ DWORD *pdwAdjacentTo);
HRESULT STDMETHODCALLTYPE GetComboBoxValueCount(
/* [in] */ DWORD dwFieldID,
/* [out] */ DWORD *pcItems,
/* [out] */ DWORD *pdwSelectedItem);
HRESULT STDMETHODCALLTYPE GetComboBoxValueAt(
/* [in] */ DWORD dwFieldID,
DWORD dwItem,
/* [string][out] */ LPWSTR *ppszItem);
HRESULT STDMETHODCALLTYPE SetStringValue(
/* [in] */ DWORD dwFieldID,
/* [string][in] */ LPCWSTR psz);
HRESULT STDMETHODCALLTYPE SetCheckboxValue(
/* [in] */ DWORD dwFieldID,
/* [in] */ BOOL bChecked);
HRESULT STDMETHODCALLTYPE SetComboBoxSelectedValue(
/* [in] */ DWORD dwFieldID,
/* [in] */ DWORD dwSelectedItem);
HRESULT STDMETHODCALLTYPE CommandLinkClicked(
/* [in] */ DWORD dwFieldID);
HRESULT STDMETHODCALLTYPE GetSerialization(
/* [out] */ CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE
*pcpgsr,
/* [out] */ CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION *pcpcs,
/* [out] */ LPWSTR *ppszOptionalStatusText,
/* [out] */ CREDENTIAL_PROVIDER_STATUS_ICON
*pcpsiOptionalStatusIcon);
HRESULT STDMETHODCALLTYPE ReportResult(
/* [in] */ NTSTATUS ntsStatus,
/* [in] */ NTSTATUS ntsSubstatus,
/* [out] */ LPWSTR *ppszOptionalStatusText,
/* [out] */ CREDENTIAL_PROVIDER_STATUS_ICON *pcpsiOptionalStatusIcon);
};
Figure 3 ICredentialProvider 接口
ICredentialProvider : public IUnknown
{
HRESULT STDMETHODCALLTYPE SetUsageScenario(
/* [in] */ CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
/* [in] */ DWORD dwFlags);
HRESULT STDMETHODCALLTYPE SetSerialization(
/* [in] */ const CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION
*pcpcs);
HRESULT STDMETHODCALLTYPE Advise(
/* [in] */ ICredentialProviderEvents *pcpe,
/* [in] */ UINT_PTR upAdviseContext);
HRESULT STDMETHODCALLTYPE UnAdvise( void);
HRESULT STDMETHODCALLTYPE GetFieldDescriptorCount(
/* [out] */ DWORD *pdwCount);
HRESULT STDMETHODCALLTYPE GetFieldDescriptorAt(
/* [in] */ DWORD dwIndex,
/* [out] */ CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR **ppcpfd);
HRESULT STDMETHODCALLTYPE GetCredentialCount(
/* [out] */ DWORD *pdwCount,
/* [out] */ DWORD *pdwDefault,
/* [out] */ BOOL *pbAutoLogonWithDefault);
HRESULT STDMETHODCALLTYPE GetCredentialAt(
/* [in] */ DWORD dwIndex,
/* [out] */ ICredentialProviderCredential **ppcpc);
};
了解这些缩写后,我们来看图 5 中的调试事件列表,这些事件是在某一示例方案期间发生的(我会详细介绍其中的大部分事件)。用于生成调用序列的方案非常简单。首先将 Windows Vista 工作站加入域。然后用您的用户名、密码和域名配置智能卡。再将智能卡插入连接到该工作站的读卡器。然后重新启动系统。
Figure 5 混合凭据提供程序调用序列
1. [The system boots]
2. [LogonUI.exe process is created]
3. [Credential provider DLLs are loaded]
4. Provider::CreateInstance
5. [User presses Ctrl+Alt+Del]
6. Provider::SetUsageScenario (CPUS_LOGON)
7. Credential::Initialize
8. Provider::Advise
9. Provider::GetCredentialCount
10. Provider::GetCredentialAt (dwIndex = 0)
11. Provider::GetFieldDescriptorCount
12. Provider::GetFieldDescriptorAt (dwIndex = 0)
13. Provider::GetFieldDescriptorAt (dwIndex = 1)
14. Provider::GetFieldDescriptorAt (dwIndex = 2)
15. Provider::GetFieldDescriptorAt (dwIndex = 3)
16. Provider::GetFieldDescriptorAt (dwIndex = 4)
17. Credential::GetBitmapValue (dwFieldID = 0; tile image)
18. Credential::GetStringValue (dwFieldID = 1; user name field)
19. Credential::GetFieldState (dwFieldID = 1; user name field)
20. Credential::GetStringValue (dwFieldID = 2; password field)
21. Credential::GetFieldState (dwFieldID = 2; password field)
22. Credential::GetSubmitButtonValue (dwFieldID = 3; submit button)
23. Credential::GetFieldState (dwFieldID = 3; submit button)
24. Credential::GetStringValue (dwFieldID = 4; domain name field)
25. Credential::GetFieldState (dwFieldID = 4; domain name field)
26. Credential::Advise
27. Credential::GetSerialization
28. Credential::UnAdvise
29. Provider::UnAdvise
30. [The WinLogon process calls LogonUser]
31. Credential::Advise
32. Credential::ReportResult (ntsStatus = 0)
33. Credential::UnAdvise
首先,winlogon 启动控制台会话 LogonUI 进程。创建后,LogonUI 枚举在 HKLM\Software\Microsoft\Windows\CurrentVersion\Auntication\Credential Providers 下注册的所有凭据提供程序。每个提供程序 DLL 会被加载,并接收到一个 Provider::CreateInstance 调用。对于混合凭据提供程序,这将创建一个 CHybridProvider。(请参见图 5 中的步骤 1 到 4。)
用户现在将看到登录屏幕。假定用户按了 Ctrl+Alt+Delete 并且每个提供程序都收到了 Provider::SetUsageScenario CPUS_LOGON 通知。这向提供程序表明,用户想进行交互式登录。现在,该混合凭据提供程序将尝试从所插入的任何智能卡中读取凭据。如果找到了一个可读的智能卡,会将一个 CHybridCredential 实例化并将其与当前的 CHybridProvider 关联。然后将有一个对 Credential::Initialize 的调用。 (请参见图 5 中的步骤 5 到 7。)
LogonUI 随后为每个加载的提供程序调用 Provider::Advise。Advise 的目的是为提供程序提供一种机制,将对可见的 UI 元素(当前还未创建)所做的任何预期的更改通知给 LogonUI。内置的智能卡提供程序给出了关于如何使用该机制的一个很好的例子。初始化之后,无论何时插入卡都会增大可用凭据数,而取出卡则会减小该数字。发生此类情况时,将通过这种机制通知 LogonUI:
ICredentialProviderEvents : public IUnknown
{
HRESULT STDMETHODCALLTYPE CredentialsChanged(
/* [in] */ UINT_PTR upAdviseContext);
};
为简单起见,混合凭据提供程序不对卡的插入和取出进行动态处理。因此,它不跟踪通过 Advise 传递给它的 ICredentialProviderEvents 接口。
LogonUI 执行的下一个接口调用是调用 Provider::GetCredentialCount,即图 5 中的步骤 9。如果创建了混合凭据(由于插入的智能卡),混合凭据提供程序将执行一些操作。它首先将 GetCredentialCount *pdwCount 输出参数设置为 1。该值指的是提供程序要枚举的凭据图块数。(混合凭据提供程序只能处理 1 个。)首次安装 Windows Vista 并加入域时,您可以根据显示的图块数推断 Microsoft 密码凭据提供程序将什么 pdwCount 值返回到 LogonUI。
混合凭据提供程序然后将 GetCredentialCount *pdwDefault 输出参数设置为 0。该值是一个从 0 开始的索引值,用于对每个提供程序假定要维护的凭据数组进行索引。如何实现提供程序跟踪其凭据由实施人员来完成,而在一组给定凭据对象的生存期内会一直对索引进行维护。
多个提供程序枚举一个默认凭据是完全可能的。例如,在当前的方案中,可以预期内置的密码凭据提供程序将枚举它自己的一个默认凭据。LogonUI 如何提示用户从多个默认和非默认凭据中作出选择而不会使用户无从下手?一般来讲,对于每一个凭据,都会向用户显示一个图块,并且会将焦点设置到代表默认凭据的那个图块。在存在多个默认凭据的情况下,实际的默认凭据是在枚举各个默认凭据时通过一系列优先规则选出的。对于各个凭据而言,如果已有一个没有自动登录的默认凭据,并且此凭据将要执行自动登录,则它将成为默认凭据。如果此凭据来自最后登录 (LLO) 提供程序并且尚还没有自动登录的默认凭据,则此凭据将成为默认凭据。最后,如果还没有默认凭据,则此凭据将成为默认凭据。尽管说了这么多,我的混合凭据提供程序的自动登录语义使得该讨论没有什么实际意义。只要枚举的混合凭据包含有效的登录信息,用户就永远都看不到任何图块。下面我将对此稍作解释。
我已提到了与优先规则有关的最后登录提供程序,但应指出,LLO 的意义会根据用户是否正在登录或者它是否是登录后的情况(如桌面锁定或密码更改)而变化。登录时,LLO 提供程序是用于最后的控制台登录的最后一个提供程序。登录后,LLO 提供程序只是用于登录到那个会话的提供程序。其原则就是如果始终用智能卡登录,则智能卡凭据提供程序默认图块将在重新启动后成为默认凭据。但如果因智能卡丢失而使用密码登录,则解锁时密码凭据提供程序的图块将成为该会话的默认凭据。
混合凭据提供程序始终都会将 *pbAutoLogonWithDefault 输出参数设置为 TRUE。这用于向 LogonUI 发送通知,指示它应立即查询此提供程序的默认凭据以获得登录信息,而无需先向用户发送提示。请注意,通过使用可以存储在注册表中的密码自动登录信息(可选),内置的密码凭据提供程序也具有相同的功能。实际上,如果 Windows Vista 检测到该计算机上只有一个用户并且没有密码,则这就是默认行为。对于有多个凭据提供程序将 *pbAutoLogonWithDefault 设置为 TRUE 的情况,LogonUI 的行为尚不明确。
执行了 GetCredentialCount 调用之后,LogonUI 将调用 Provider::GetCredentialAt。对于混合凭据提供程序,此例程最多调用一次,它反映此提供程序的最大凭据计数。作为响应,提供程序会返回该凭据实例的一个与请求的索引对应的 ICredentialProviderCredential 指针。
接下来,LogonUI 调用 Provider::GetFieldDescriptorCount,提供程序通过此调用返回在其凭据中可以找到的 UI 元素的最大数目。例如,我的密码凭据提供程序示例有五个域:一个位图、一个用户名输入域、一个密码输入域、一个提交按钮和一个域名输入域。即使实际上这些元素从不显示,您仍可以看到混合凭据提供程序中保存了这些元素。这将完成图 5 中的步骤 11。
LogonUI 然后将为每个 UI 元素分别调用一次 Provider::GetFieldDescriptorAt,以便检索其类型。例如,执行了对应于位图索引的调用之后,该示例将返回 CREDENTIAL_PROVIDER_FIELD_TYPE CPFT_TILE_IMAGE。混合凭据提供程序中未使用的一个功能是与只读文本域相对的可写文本域。如果修改了混合凭据提供程序来提示用户输入智能卡 PIN,则此功能将通过 CPFT_PASSWORD_TEXT 来完成。可以显示从智能卡读取的用户名,以便提供某些上下文来提示用户输入信息。但就技术而言,用户名应为只读,因为它已绑定到同样存储在卡上的密码。因此,可能会使用 CPFT_LARGE_TEXT 字段类型(与 CPFT_EDIT_TEXT 相对)。(有关选项的完整列表,请参见 credentialprovider.h。)
完成字段描述符的枚举之后,LogonUI 将根据每个凭据字段的类型对凭据提供程序执行一系列调用。例如,对于 CPFT_TILE_IMAGE 字段类型,LogonUI 接下来将调用 Credential::GetBitmapValue。对于 CPFT_LARGE_TEXT 之类的用于用户名编辑框的文本值,随后会调用 Credential::GetStringValue 和 Credential::GetFieldState。
由于已从智能卡读取了我的混合凭据提供程序所需的所有登录信息(用户名、密码和域名),此时可以获得对应于每个文本字段的字符串,这些字符串通过 GetStringValue 的 ppwz 输出参数返回。此时,作为对 GetStringValue 的响应,其他提供程序则可能会返回 NULL 字符串值,因为用户还没来得及键入任何内容。请注意可能造成混淆的一点:文本字段名是通过 GetFieldDescriptorAt 检索的,而字段中当前的文本值则是通过 GetStringValue 检索的。(字段名或字段标签将显示为空编辑控件中的提示文本。)
在完全描述了各种 UI 元素之后,LogonUI 将调用 Credential::Advise。(请参见图 5 中的步骤 26。)它与前面调用的 Provider::Advise 接口的作用类似;每个凭据都可以将影响 LogonUI 的 UI 元素状态的相关更改异步通知给 LogonUI。例如,在取消选择示例密码凭据提供程序的某一凭据图块时,提供程序便使用这一机制。在这种情况下,凭据对象将使用 ICredentialProviderCredentialEvents SetFieldString(请参见图 6)来清除密码域。这与在 Windows XP 登录屏幕中只键入部分密码然后便暂停时所发生的情况类似。最终,登录对话框将超时,文本将被清除。
Figure 6 ICredentialProviderEvents 接口
ICredentialProviderCredentialEvents : public IUnknown
{
HRESULT STDMETHODCALLTYPE SetFieldState(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [in] */ CREDENTIAL_PROVIDER_FIELD_STATE cpfs);
HRESULT STDMETHODCALLTYPE SetFieldInteractiveState(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [in] */ CREDENTIAL_PROVIDER_FIELD_INTERACTIVE_STATE cpfis);
HRESULT STDMETHODCALLTYPE SetFieldString(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [unique][string][in] */ LPCWSTR psz);
HRESULT STDMETHODCALLTYPE SetFieldCheckbox(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [in] */ BOOL bChecked,
/* [in] */ LPCWSTR pszLabel);
HRESULT STDMETHODCALLTYPE SetFieldBitmap(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [in] */ HBITMAP hbmp);
HRESULT STDMETHODCALLTYPE SetFieldComboBoxSelectedItem(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [in] */ DWORD dwSelectedItem);
HRESULT STDMETHODCALLTYPE DeleteFieldComboBoxItem(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [in] */ DWORD dwItem);
HRESULT STDMETHODCALLTYPE AppendFieldComboBoxItem(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [string][in] */ LPCWSTR pszItem);
HRESULT STDMETHODCALLTYPE SetFieldSubmitButton(
/* [in] */ ICredentialProviderCredential *pcpc,
/* [in] */ DWORD dwFieldID,
/* [in] */ DWORD dwAdjacentTo);
HRESULT STDMETHODCALLTYPE OnCreatingWindow(
/* [out] */ HWND *phwndOwner);
};
就完成用户身份验证来说,下一个调用最为让人感兴趣。由于 GetCredentialCount 的 *pbAutoLogonWithDefault 参数已设置为 TRUE,LogonUI 知道默认凭据应已包含验证用户身份所需的足够数据(即使尚未显示任何 UI 元素,因而也没有收集任何用户输入信息)。这种情况下会调用 Credential::GetSerialization 例程来检索用户名、密码和域名(可选)。凭据提供程序通过将这三项内容排列调整为 Kerberos 所需的格式来为该例程准备返回值。一旦已序列化的凭据准备就绪,凭据提供程序便会通过 CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE 类型输出参数通知 LogonUI 要返回一个完整凭据。这一差别是由值 CPGSR_RETURN_CREDENTIAL_FINISHED 造成的。请再看一看 credentialprovider.h,以及示例代码中 GetSerialization 的实现。这将完成图 5 中的步骤 27。
执行 GetSerialization 之后,LogonUI 将经排列调整后的凭据传递给 winlogon,winlogon 然后通过调用 LogonUser 再将它传递给“本地安全机构”(LSA) 进程。在此之前,LogonUI 会调用 Credential::UnAdvise 和 Provider::UnAdvise 来通知这两个实体当前尚未接受有关它们各自的事件接口的通知。若登录尝试挂起,则 UI 更改将没有意义(理想的情形是,用户接下来将看到他的桌面)。其他资源
winlogon 获得 LogonUser 的结果之后,它会把此结果回传给 LogonUI,然后通知凭据实例(即执行 GetSerialization 后仍旧拥有焦点的那个实例)。但在凭据收到从 LogonUser 返回的状态代码以前,它会再次获得一个用于进行 UI 元素更改的回调接口。(请参见图 5 中的步骤 31。)
尝试进行身份验证后,其结果通过 Credential::ReportResult 例程返回给凭据。提供程序(或其凭据对象)为什么会关注身份验证的尝试结果?为什么此时它会开始作出 UI 更改?
许多相关的 ReportResult 情形是由身份验证失败造成的结果。一个典型的例子就是用户密码过期。如果用户的密码即将过期,通过 ReportResult ntsSubstatus 参数返回的身份验证子状态会指示这一点。作为响应,内置的密码凭据提供程序会提示用户(可选)更改密码。此提示以及密码更改对话框本身需要不同的 UI 元素。因此,密码凭据提供程序会利用 ICredentialProviderCredentialEvents 接口指针将必需的更改应用到其提示字段。
身份验证成功后,凭据提供程序可能执行一些有趣的操作。例如,内置的智能卡凭据提供程序会使用此成功通知来提示开始监视将用于身份验证的卡取出的操作。其目的是为了实施“取出即锁定”会话策略(可选)。一旦凭据处理序列完成,LogonUI 将通过 Credential::UnAdvise 通知凭据提供程序减少其对 ICredentialProviderCredentialEvents 接口指针的引用。
混合凭据提供程序
现在我已讨论了新的凭据提供程序体系结构及其使用方式,接下来我们将更详细地探讨混合凭据提供程序示例的设计。回想一下图 2 所示的 Windows Vista 交互式登录体系结构高级布局。图 7 对该图表进行了扩充,包含了 Windows 智能卡 API 堆栈,并将焦点放在了新的凭据提供程序上。
图 7 关键之处在于说明混合凭据提供程序与 Windows 智能卡 API 的接口既可以是直接的,也可以是间接的。直接接口是通过公共例程来实现的,如 SCardEstablishContext 和 SCardListReaders,它们支持智能卡的检测。间接接口是通过卡模块 API 来实现的,它们使凭据提供程序能够方便地从卡中读取用户凭据文件,而不必使用专门用来对卡进行操作的低级命令。对于本例,几乎所有与智能卡相关的逻辑都是通过一个称为 ScHelp.libary 的帮助程序库来实现的。(我将在后面的“实现”部分对此进行讨论。)混合凭据提供程序的自动登录行为为整体了解凭据提供程序体系结构的功能和微妙之处提供了一个有趣视角。
图 7 混合凭据提供程序
首先要提到的是,我所说的自动登录是什么意思?正如我们所看到的,自动登录的意思就是,如果混合凭据可用,则根本不会向用户显示提示对话框。在这种情况下,当用户按下 Ctrl+Alt+Delete 时,将自动尝试进行身份验证。
这样实现的自动登录行为可能会使某些用户感到迷惑。例如,它不同于内置的密码提供程序,在默认情况下,内置的密码提供程序始终都会显示图块,即使是在未加入域的情况下也是如此(在这种情况下,不会提示用户输入密码)。
更改混合凭据提供程序,要求用户在所有情况下都要先单击图块,这一点很容易做到。请注意,当前实现的 GetCredentialCount 会将 *pbAutoLogonWithDefault 设置为 TRUE。但您应将其设为 FALSE。现在,提供程序至少应该可以显示一个图块(除非其他提供程序用无图块自动登录覆盖了它)。
用户单击图块时,LogonUI 将调用提供程序的 ICredentialProviderCredential::SetSelected 方法。作为响应,凭据类会将 *pbAutoLogon 设置为 TRUE,触发 LogonUI 调用 ICredentialProviderCredential::GetSerialization 并随后尝试进行身份验证,而不会首先显示任何 UI 元素更改。换句话说,如果身份验证成功,用户接下来将看到其桌面。
混合方式的实现
将密码凭据提供程序转换为我的混合实现方式,所需的更改很少。 使用图形比较工具(如 windiff.exe)比较 SDK CSampleProvider.cpp 和我的 CHybridProvider.cpp;以同样的方式比较 CSampleCredential.cpp 和 CHybridProvider.cpp。可以看到大部分修改过的代码行都是将“Sample”全局替换为“Hybrid”时的修改结果!
CHybridProvider.cpp 的最大变动是它对 SetUsageScenario 的处理。为响应此调用,提供程序会尝试从智能卡中读取凭据。这是通过 ScHelp 库中的 ScHelpInit 例程完成的,它实现了大部分智能卡逻辑处理。ScHelpInit 会连接到智能卡子系统,查找最先插入的卡,如果找到则分析凭据,并返回其中包含的字符串。
CHybridCredential.cpp 的主要变动在于它对作为插入的智能卡的凭据文件的一部分来读取的域名字符串(可选)的处理方式。在 GetSerialization 中,如果域名是从卡中读取的,在要传递给 Kerberos 的已序列化的身份验证数据中会使用此域名。否则,将使用调用公共 GetComputerName 后返回的结果。
将 PropCert 示例转换为 ScHelp 库,所做的更改比较多。关于此帮助程序 (helper) 库,有几个方面值得注意。
首先,PropCert 的主线程例程现在是同步调用;而不使用单独的线程。然而,与卡相关的操作应异步进行,而进行此更改相对来说也比较简单。实际上,如果扩展混合凭据提供程序是为了让用户能够从多个凭据图块中作出选择,此更改将是十分关键的。在这种情况下,您会希望提供程序在后台读取智能卡数据的同时立即枚举图块,因为一些老式卡上的 I/O 操作可能相当的慢。请注意,要圆满实现此更改,您需要建立某种通知机制,使智能卡线程能够将凭据可用性的变化情况通知给提供程序。提供程序然后可以通过 CredentialsChanged 将同样的信息通知给 LogonUI。
ScHelp.lib 中的其余逻辑包括例程 _ReadCreds、_Connect、_UnpackCred(请参见图 8)以及 ScHelpPackCred。后两个用于反序列化和序列化存储在智能卡上的密码凭据文件。前两个用于实现我已简要介绍过的逻辑:枚举智能卡读卡器和卡,获得对最先枚举的卡的读取锁定,绑定到对应于该卡的卡模块,以及从卡上读取凭据文件(如果有)。
Figure 8 UnpackedCred 函数
// Break down the credential byte array
DWORD WINAPI UnpackCred(
__in_bcount(cbCred) PBYTE pbCred,
__in DWORD cbCred,
__out LPWSTR *ppwszUserName,
__out LPWSTR *ppwszPassword,
__out LPWSTR *ppwszDomainName)
{
DWORD status = ERROR_SUCCESS;
DWORD cbUserName = 0;
DWORD cbPassword = 0;
DWORD cbDomainName = 0;
DWORD cbCurrent = 0;
*ppwszUserName = NULL;
*ppwszPassword = NULL;
*ppwszDomainName = NULL;
try
{
// Read the user name
cbUserName = (DWORD) sizeof(WCHAR) * (1 + wcslen(
(LPWSTR) (pbCred + cbCurrent)));
if (cbUserName > cbCred - cbCurrent)
{
status = ERROR_INVALID_PARAMETER;
leave;
}
if (NULL == (*ppwszUserName = (LPWSTR) Alloc(cbUserName)))
{
status = ERROR_NOT_ENOUGH_MEMORY;
leave;
}
if (FAILED(StringCbCopy(
*ppwszUserName, cbUserName, (LPWSTR) (pbCred + cbCurrent))))
{
status = ERROR_INSUFFICIENT_BUFFER;
leave;
}
cbCurrent += cbUserName;
// Read the password
cbPassword = (DWORD) sizeof(WCHAR) * (1 + wcslen(
(LPWSTR) (pbCred + cbCurrent)));
if (cbPassword > cbCred - cbCurrent)
{
status = ERROR_INVALID_PARAMETER;
leave;
}
if (NULL == (*ppwszPassword = (LPWSTR) Alloc(cbPassword)))
{
status = ERROR_NOT_ENOUGH_MEMORY;
leave;
}
if (FAILED(StringCbCopy(
*ppwszPassword, cbPassword, (LPWSTR) (pbCred + cbCurrent))))
{
status = ERROR_INSUFFICIENT_BUFFER;
leave;
}
cbCurrent += cbPassword;
// Read the domain name (if any)
cbDomainName = (DWORD) sizeof(WCHAR) * (1 + wcslen(
(LPWSTR) (pbCred + cbCurrent)));
if (sizeof(WCHAR) == cbDomainName)leave;
else if (cbDomainName > cbCred - cbCurrent)
{
status = ERROR_INVALID_PARAMETER;
leave;
}
if (NULL == (*ppwszDomainName = (LPWSTR) Alloc(cbDomainName)))
{
status = ERROR_NOT_ENOUGH_MEMORY;
leave;
}
if (FAILED(StringCbCopy(*ppwszDomainName, cbDomainName,
(LPWSTR) (pbCred + cbCurrent))))
{
status = ERROR_INSUFFICIENT_BUFFER;
leave;
}
}
finally {}
return status;
}
改进的可能性
尽管我的混合凭据提供程序的设计以及我在实现过程中重用了相当多的代码符合我先前的设计要求,但实现过程的某些方面仍妨碍了在当前状态下部署该提供程序。我已经讨论了这样一个缺点,即应从智能卡异步读取数据。现在我将按严重性从重到轻的顺序讨论一下其余的改进可能性。
首先,用户凭据没有安全地存储在智能卡上。理想的情形是,应当只在提供了正确的 PIN 之后才可以读取存储用户密码的卡文件。然而,当前卡模块接口中的一个限制使得这种理想情况难以实现。也就是说,预定义的卡文件访问条件集中不包含这样一个“用户只读”选项。(我实施了这个卡模块设计决策,而今又因其所产生的这个限制而给我带来不便,我想这就是所谓的“理想的赏罚”吧!)我希望产品团队在后续版本中会扩展卡模块接口。
同时,智能卡密码文件的加密方式应为:如果卡被盗,解密码时必须输入 PIN。这可以通过 Crypto API 以及通过新的 Windows Vista CNG(“下一代”的 Crypto API)轻松实现。在卡上创建和存储 RSA 密钥对可以满足目前的需要,但就加密的最佳实践做法而言,并不推荐使用 RSA 公钥直接加密密码文件,而是建议使用对称密钥和算法,如高级加密标准 (AES)。RSA 密钥将改为对对称密钥进行加密。
为了完成加密密码文件的设计,您可能会修改现有密码文件格式,以包含相关的加密密钥。如果采用这种方法,一定要考虑当沿袭使用原有的加密算法和密钥大小时,可能会带来的问题。设想一下,所选择的算法终有一天会被破解。而且,到此为止所讨论的设计不包含密码安全方面的数据完整性检查。这看似无关紧要,因为理论上攻击者需要知道 PIN 才能修改卡上的内容。但我认为这样一个功能是深层防御的一个必不可少的方面。
对于当前的实现而言,第二个限制是它对每张卡只支持一个凭据。我们来看图 8 所显示的示例代码中的 ScHelp.cpp!_UnpackCred 例程。它对密码文件执行简单的反序列化,该密码文件可能是从卡中读取的。整体的凭据分析逻辑对每张卡将只处理一个凭据。然而,有些用户在工作时可能需要使用多个不同的域凭据。您是通过扩展提供程序来支持这种需要,还是发给这些用户多张卡呢?前者会增加实现过程的复杂性,后者则会增加部署管理的复杂性。
顺便说明,我已尝试了在 _UnpackCred 中演示安全的缓冲区分析技术。(请再看一下图 8。)假定攻击者能够制造一张恶意智能卡,并将它插入到您网络上的工作站中。在应用程序级别,应对此种威胁的主要缓解措施是,不要假定从卡中读取的数据都是有效的,不要假定嵌入的字符计数都是正确的,也不要假定字符串格式都合乎规范。只需检查并确认每个字符串开头的元素计数不超过凭据的未分析部分的长度,并且任何字符串的实际长度都不超过为其分配的缓冲区。
对于当前的混合凭据提供程序,我要提到的最后一个限制就是每台计算机上只支持一个智能卡读卡器。例如,如果我用附带的两个智能卡读卡器启动一个系统,并且每个智能卡读卡器都已插入智能卡,每张卡也都用不同的凭据进行了初始化,则哪张卡优先将取决于智能卡子系统枚举读卡器的顺序。解决这一问题需要更改在 ScHelp.h 中定义的 SCHELP_CONTEXT 结构的语义:
typedef struct _SCHELP_CONTEXT
{
LPWSTR wszUserName;
LPWSTR wszPassword;
LPWSTR wszDomainName;
} SCHELP_CONTEXT, *PSCHELP_CONTEXT;
SCHELP_CONTEXT 结构定义了凭据提供程序代码和 ScHelp 代码之间的数据交换。很明显,该结构只支持一个凭据;可以引入一个简单数组或单链表来增强该结构。如果需要此功能,不要忘记还要修改 CSampleProvider 的 _rgpCredentials 成员的处理过程,因为当前它是写到代码文件中的,对于每个提供程序实例只支持一个凭据。
测试和调试
前面我提到过,测试我的凭据提供程序相对比较容易。关于我的测试策略,我知道我希望能够将用户模式的调试器附加到 LogonUI 以便获得最大的灵活性,一是为了实时调试我的示例代码,二是为了生成我在前面讨论过的跟踪信息。我也知道,由于 LogonUI 是以 System 身份运行的,并且我所针对的交互式登录方案只能从安全桌面访问,因此,编写一个简单的独立测试程序来实践各种凭据提供程序 COM 接口将会节省时间。不过,鉴于我的技术背景,我决定跳过编写测试程序这一步,而继续进行实时调试。但我不推荐这种方式。
由于没有编写测试程序,这样我就有了更多的压力,必须设置一个可靠的调试环境。如果您不是经常进行内核模式开发,则不太容易配置好这种基于内核调试器的测试环境。从高级别的开发角度讲,以下是完成这项工作的最好方式。
首先,在标准内核调试配置中设置两台计算机。一台是一个可靠的开发系统(调试器),另一台是 Windows Vista 测试系统(调试对象)。它们应通过串行电缆连接。
不要忘记为调试对象配置加载 Windows XP 的安全启动分区。配置具体来说就是让两台计算机通过串行电缆相互通信,这一过程可能需要反复的尝试。测试串行连接的最好方法是什么呢?将两台计算机都启动到 Windows XP 系统并运行“超级终端”(依次选择“所有程序”|“附件”|“通讯”|“超级终端”)。在两台计算机上,将程序指向所使用的串行端口并选择向调试器传送数据的速率。如果在一台计算机的“超级终端”窗口中键入的字符也出现在另一台计算机上,则配置完成。否则,请尝试使用其他串行端口、连接速度或电缆。
为了说明配置安全启动区的第二个原因,我需要稍作快进。如果发现被测试的凭据提供程序导致主机 LogonUI 进程停止运行或死锁,那么您将无法继续登录 Windows Vista。我最初对 HybridCredProv.dll 进行生成配置时就出现了这一问题。当时我所用的运行库是可再发行组件 msvcr80.dll。我的第一个错误是没有将此二进制文件复制到调试对象的 system32 目录中。不过,该错误只是阻止我加载凭据提供程序。
接下来在重新启动到 Windows Vista 时,我陷入了迷惑。这次,我看到了 LogonUI 启动,也看到加载了我的凭据提供程序,但我一直没看到任何 UI 出现。在调试器中,我发现 msvcr80.dll 启动代码与另一个线程在进程加载程序锁定上出现了死锁。我没有仔细琢磨这一问题,而是修改了 HybridCredProv.dll 的生成配置,使用一个静态链接的运行库。
总之,安全启动分区为在凭据提供程序测试过程中解决配置问题提供了一个有用方法。
我们再回到调试配置。下一步是在两个系统上安装来自 Microsoft 的最新调试器包(包含 ntsd.exe 和 i386kd.exe 的包)。安装 Windows Vista 的公共调试符号。(对于调试对象,建议将此符号完整复制到本地。)有些人可能认为这一步没有必要,但如果在进行实时调试时发现由于缺少符号而导致数据不足是不是会更令人烦恼呢?我根据自己的经验提出以下几点:
- 我发现始终保持完全的堆栈跟踪很重要。
- 即使是保持近似的堆栈跟踪,某些操作系统符号文件(如 ntdll.pdb)可能也是必需的。
- 某些系统级调试方案可能会以意想不到的方式限制网络访问,因此需要使用本地符号。
使用系统注册表中的 Image File Execution Options(映像文件执行选项)配置 LogonUI.exe,使之在用户模式调试器 (ntsd.exe) 中启动。该调试器然后会将其输出重定向到内核调试器。这就是需要后者的原因 - 否则,由于只能从安全桌面上看到该应用程序,您将无法从测试计算机的控制台以可靠的方式与该应用程序以及所连接的调试器进行交互。
应注意的是,您可以采用以下两种方式指定将符号加载到 ntsd.exe 的路径:通过 -y 命令行选项或通过 _NT_SYMBOL_PATH 环境变量。配置 Image File Execution Options 时建议使用前者。不过我更喜欢后者,因为它允许我在我的测试计算机上对它进行系统级的一次性设置。
最后,请注意,如果您的凭据提供程序已无法使用,并且您需要重新获得对计算机的访问权,请启动到安全模式,Windows Vista 将只加载密码提供程序,如果启动到带网络连接的安全模式,则还会加载智能卡凭据提供程序。(出于高安全性的考虑,有一个策略可以关闭这一回退行为。)启动到安全模式后,您可以根据情况编辑注册表。
智能卡和初始化
当然,混合凭据提供程序的任何一个重要测试都需要凭据。也就是说,智能卡必须带有包含用户名、密码和域名(可选)且格式合乎规范的凭据文件。为此,示例代码下载中包含了一个称为 WriteCred.exe 的测试实用工具。若要用您的凭据信息初始化插入默认读卡器的智能卡,请使用以下命令行选项:
WriteCred.exe -p -u -d -w
与混合凭据提供程序的行为不同,当前版本的 WriteCred 假定域名参数不可选。另外请注意,密码参数应是与用户名对应的 Windows 登录密码,而 PIN 参数是将凭据数据文件写入智能卡的必需参数。
最后,关于测试,WriteCred 工具以及混合凭据提供程序自身只支持与 Microsoft 基本智能卡加密提供程序 (Base Smart Card Crypto Provider) 兼容的智能卡。要获得兼容智能卡的最新列表,最好的方式是在网络上搜索“card module smart cards”(卡模块智能卡)。
Dan Griffin 是华盛顿州西雅图市的一位软件安全咨询师。他曾经在 Microsoft 公司的 Windows 安全性开发团队工作过七年。您可以通过 http://www.jwsecure.com/ 与 Dan 联系。他对 Microsoft 的 Brian McNeill 和 Eric Perlin 针对本文提出的反馈意见表示感谢。