在 XP 时代,微软提供了 GINA 的接口来让我们对系统登录界面进行定制,在 Win7 以上系统中,微软提供了另外一种接口,就是 Credential Providers, Credential Providers 译为凭据提供者/商。所谓凭据就是提供了一种身份认证的方式,原有的 Windows 身份认证方式就是开机的时候需要你输入密码。而通过微软提供这套接口你可以实现丰富的身份认证方式,比如指纹、USB-Key等任意修改 Windows 系统登录时的界面。比较有名的有些一些应该了解过的产品就是 ThinkPad 的指纹解锁功能,通过验证指纹来解锁计算机,当你了解了实现机制以后,硬件条件允许的情况下,你也可以做一套自己的身份认证系统。
它长什么样子?
在了解它之前我们要看看使用它到底能做什么功能。下图是微软官方提供的例子,显示了所有提供的控件,包括图片、下拉框、文本输入框、密码输入框、超链接按钮等。
如何下载示例?
微软提供了两个版本,一个是最初的 Win7 版本,另一个 v2 版本中仅增加了一种动态更新界面数据的接口也就是支持 Win8 以上系统(包含现在的 Win10),下载地址:
Win7: Samples/Win7Samples/security/credentialproviders
Win8: Samples/CredentialProvider
如何编译运行?
我们以 Win7 的 Samples 为例,代码与 v2 版本通用,Clone 下最新代码后,双击运行 Samples\Win7Samples\security\credentialproviders\CredentialProviderSamples.sln
,这个项目是一个 vs2008 的工程。工程中一共有 5 个项目:
其中 SampleAllControlsCredentialProvider
项目包含了所有控件的示例,我们也将从这个项目入手来讲解。编译 SampleAllControlsCredentialProvider
项目会得到一个 SampleAllControlsCredentialProvider.dll
的动态库文件。将这个文件拷贝到虚拟机中已经安装好的 Win7 系统中的 System32 目录下(注意如果是 64 位系统请编译成 64 位版本放到 System32 目录下):
拷贝文件到该目录下以后,我们再打开项目的目录,可以看到一个 Register.reg
的注册表文件,这个注册表就是将你编译好的动态库加载到系统的注册表文件。将它也复制到虚拟机的系统中导入,导入完成后,在虚拟机中按下 CTRL+ALT+INSERT 锁屏。此时你就能看到我们文章最前面的效果了。
调用顺序
我们已经看到微软给出示例的运行效果了,作为程序开发人员你还要了解清楚它的工作原理以便我们能进一步的去完善修改它的功能与我们的业务整合。我们首先要了解的是我们自己编译的凭据文件(.dll)文件是如何被调用起来的。这些内容微软官网是有详细介绍的,不过说实话我自己文化程度不高,看着有一些吃力。所以还是自己总结了一下。
我们写好的 dll 文件的路径在写入到注册表 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers
下的时候,每次你进行锁屏、开机登录的时候,这个 dll 文件都会被系统的 LogonUI.exe 进程加载起来展现给最终用户,而界面上显示什么内容、显示哪些控件就是由我们自己编写的 dll 决定的了。
你可以理解这就是一个 COM 组件,我们去实现微软规定好的对应的函数,Windows 系统调用起来,在登录、登录成功等不同阶段执行不同的功能。那么我们就要来看一下都有哪些接口,以及这些接口的调用顺序是怎么样的。
接口文档:
ICredentialProvider interface
ICredentialProviderCredential interface
ICredentialProviderCredential2 interface
ICredentialProviderCredentialEvents interface
.. 还有更多不一一列举,点开网页里面就能找到。
其实在我们刚才看的例子中就已经实现了 v1 版本的所有接口了,我们只要拿这个例子来改造就可以了。首先在两个类的所有的函数入口处加上我们的调试信息,如下所示:
加上调试信息以后,我们将编译后的 dll 替换到系统中,执行一次锁屏、和解锁的过程,来看一下系统是如何调用我们编写好的接口的:
通过 dbgview 我们看到调用的函数还是比较乱的,我们稍作整理一下,你就会发现,其实是非常有逻辑的。
[932] CSample_CreateInstance // 最先被调用的函数,实例化一个 Provider 对象
[932] CSampleProvider::SetUsageScenario // 设置使用场景,这个函数很关键,后面我们介绍
[932] CSampleCredential::Initialize // 初始化 Credential 类中的对象
[932] CSampleProvider::Advise // 一个拓展接口,我们一直没有用到过,可以看看文档介绍
[932] CSampleProvider::GetCredentialCount // 自定义一些凭据的信息,比如是否自动登录等
[932] CSampleProvider::GetCredentialAt // 一系列初始化字段的函数,暂时无需关心
[932] CSampleProvider::GetFieldDescriptorCount
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleProvider::GetFieldDescriptorAt
[932] CSampleCredential::GetBitmapValue // 初始化图片控件
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetStringValue // 初始化各种文本控件
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetStringValue
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetStringValue
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetStringValue
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetSubmitButtonValue // 初始化提交按钮
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetCheckboxValue // 初始化 Checkbox 控件
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetComboBoxValueCount // 获取 Combobox 最大显示数据的数量,代码里设置为3
[932] CSampleCredential::GetComboBoxValueAt // 获取第一个数据
[932] CSampleCredential::GetComboBoxValueAt // 获取第二个数据
[932] CSampleCredential::GetComboBoxValueAt // 获取第三个数据
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::GetStringValue
[932] CSampleCredential::GetFieldState
[932] CSampleCredential::Advise
[932] CSampleCredential::SetSelected // 可以控制控件显示状态等其他信息,并可以控制是否自动登录
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::SetStringValue
[932] CSampleCredential::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::GetSerialization // 点击登录按钮后出发的函数
[932] CSampleCredential::UnAdvise
[932] CSampleProvider::UnAdvise
[932] CSampleCredential::Advise
[932] CSampleCredential::ReportResult // 登录成功或失败返回调用的函数
[932] CSampleCredential::UnAdvise
上一篇中我们介绍了凭据的加载和代码中函数的调用顺序,接下来我们就要了解一下一些关键函数在代码中起到什么作用了。了解清楚这些以后我们才能定制出我们自己需要功能。
CSampleProvider::SetUsageScenario
这个函数非常重要,在凭据被加载起来以后,由微软调用,我们实现这个函数里面的功能,微软调用时会给函数传递两个参数,如下所示:
HRESULT CSampleProvider::SetUsageScenario(
__in CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
__in DWORD dwFlags
);
其中 dwFlags
函数我们不需要关心,着重要关注的是 cpus
参数,这个参数标志了系统是锁屏、还是开机时登录而调用的凭据。如果是锁屏,那么 cpus 的值等于 CPUS_UNLOCK_WORKSTATION
,而如果是开机登陆(或切换用户)则 cpus 的值等于 CPUS_LOGON
。通过判断不同的登录类型,我们来给使用者显示不同的界面。而微软的例子中是将两中登录类型都同时创建了一个凭据,看如下代码:
// SetUsageScenario is the provider's cue that it's going to be asked for tiles
// in a subsequent call.
HRESULT CSampleProvider::SetUsageScenario(
__in CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
__in DWORD dwFlags
)
{
UNREFERENCED_PARAMETER(dwFlags);
HRESULT hr;
// Decide which scenarios to support here. Returning E_NOTIMPL simply tells the caller
// that we're not designed for that scenario.
switch (cpus)
{
case CPUS_LOGON:
case CPUS_UNLOCK_WORKSTATION:
_cpus = cpus;
// Create and initialize our credential.
// A more advanced credprov might only enumerate tiles for the user whose owns the locked
// session, since those are the only creds that wil work
_pCredential = new CSampleCredential();
if (_pCredential != NULL)
{
hr = _pCredential->Initialize(_cpus, s_rgCredProvFieldDescriptors, s_rgFieldStatePairs);
if (FAILED(hr))
{
_pCredential->Release();
_pCredential = NULL;
}
}
else
{
hr = E_OUTOFMEMORY;
}
break;
case CPUS_CHANGE_PASSWORD:
case CPUS_CREDUI:
hr = E_NOTIMPL;
break;
default:
hr = E_INVALIDARG;
break;
}
return hr;
}
示例中在登录和锁屏的两种情况都创建创建了 CSampleCredential
对象,这个对象就是实现凭据页面具体功能的对象。如果你需要区分登录和锁屏,那么在这里做区分创建不同的凭据对象,或者在凭据对象中判断 _cpus 的值(这个值被用作第一个参数传递到凭据对象中了)来显示不同的控件。
CSampleCredential::Initialize
注意,这里我们切换到了 CSampleCredential 类中,因为在上面介绍的方法中创建了一个 CSampleCredential 对象,并调用了该对象的 Initialize 方法,这个方法就实现了初始化凭据页面控件文字和数据的功能。同时,在调用这个方法时传递了三个参数,第一个参数就是我们刚才说的 _cpus,第二个参数描述了要创建的控件类型及控件初始化文字,第三个参数描述了创建的这些控件的初始状态,是显示、隐藏、还是具备焦点等。
// Initializes one credential with the field information passed in.
// Set the value of the SFI_LARGE_TEXT field to pwzUsername.
HRESULT CSampleCredential::Initialize(
__in CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
__in const CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR* rgcpfd, // 类型,控件的类型及默认显示文字
__in const FIELD_STATE_PAIR* rgfsp // 状态,是否显示、是否是焦点等
)
在 CSampleCredential::Initialize 函数中,遍历了这两个参数,并将这两个参数传递的内容保存到了自己类中的成员变量 _rgCredProvFieldDescriptors 和 _rgFieldStatePairs 中,这两个变量在初始化时与 CSampleProvider 初始化使用的都是相同的枚举。所以长度、成员类型、数量都是一样的。
// Initializes one credential with the field information passed in.
// Set the value of the SFI_LARGE_TEXT field to pwzUsername.
HRESULT CSampleCredential::Initialize(
__in CREDENTIAL_PROVIDER_USAGE_SCENARIO cpus,
__in const CREDENTIAL_PROVIDER_FIELD_DESCRIPTOR* rgcpfd, // 类型,控件的类型及默认显示文字
__in const FIELD_STATE_PAIR* rgfsp // 状态,是否显示、是否是焦点等
)
{
HRESULT hr = S_OK;
_cpus = cpus;
// Copy the field descriptors for each field. This is useful if you want to vary the field
// descriptors based on what Usage scenario the credential was created for.
for (DWORD i = 0; SUCCEEDED(hr) && i < ARRAYSIZE(_rgCredProvFieldDescriptors); i++)
{
_rgFieldStatePairs[i] = rgfsp[i];
hr = FieldDescriptorCopy(rgcpfd[i], &_rgCredProvFieldDescriptors[i]);
}
// Initialize the String value of all the fields.
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"Large Text", &_rgFieldStrings[SFI_LARGE_TEXT]);
}
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"Small Text", &_rgFieldStrings[SFI_SMALL_TEXT]);
}
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"Edit Text", &_rgFieldStrings[SFI_EDIT_TEXT]);
}
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"", &_rgFieldStrings[SFI_PASSWORD]);
}
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"Submit", &_rgFieldStrings[SFI_SUBMIT_BUTTON]);
}
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"Checkbox", &_rgFieldStrings[SFI_CHECKBOX]);
}
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"Combobox", &_rgFieldStrings[SFI_COMBOBOX]);
}
if (SUCCEEDED(hr))
{
hr = SHStrDupW(L"Command Link", &_rgFieldStrings[SFI_COMMAND_LINK]);
}
return S_OK;
}
代码中我们可以看到,还有一个 _rgFieldStrings 的成员,是一个字符串指针数组变量,它是为了存储每个控件的文字信息,与 _rgCredProvFieldDescriptors 变量配合使用。给每隔字符串指针数组成员赋值后,初始化结束了。
CSampleCredential::SetSelected
在初始化完成后,我们后续会看到一系列对控件初始化的一些操作,这些函数我们不必过度的去关心他,自己下个断点跟踪一下,就知道具体的执行过程了。接下来我们要介绍的这个函数就是在控件都初始化完毕后,你可能要在控件显示之前根据业务的不同情况对控件做一些改变,比如我们希望如果当前是锁屏而调用的凭据,那么我们只显示一个密码输入框,不需要显示用户名输入框了,因为锁屏的时候你可以通过代码判断出当前会话锁屏的用户信息。而如果是登录或切换用户而调用的凭据,那么我们要显示用户名和密码的输入框。当然这只是一个简单的业务场景描述,大家根据自己业务需求的不同即可在这个函数对控件的显示和隐藏做手脚。在这个函数操作控件前,你要先判断 _pCredProvCredentialEvents 成员是否是有效的,接着调用 _pCredProvCredentialEvents 的一些方法来对控件设置状态或文字等信息。如下所示:
// LogonUI calls this function when our tile is selected (zoomed)
// If you simply want fields to show/hide based on the selected state,
// there's no need to do anything here - you can set that up in the
// field definitions. But if you want to do something
// more complicated, like change the contents of a field when the tile is
// selected, you would do it here.
HRESULT CSampleCredential::SetSelected(__out BOOL* pbAutoLogon)
{
if (NULL != _pCredProvCredentialEvents)
{
// 设置 Combobox 控件为显示状态
_pCredProvCredentialEvents->SetFieldState(this, SFI_COMBOBOX, CPFS_DISPLAY_IN_SELECTED_TILE);
// 修改 SFI_LARGE_TEXT 控件的文字
_pCredProvCredentialEvents->SetFieldString(this, SFI_LARGE_TEXT, L"Modify Large Text");
// 设置密码输入控件具备焦点
_pCredProvCredentialEvents->SetFieldInteractiveState(this, SFI_PASSWORD, CPFIS_FOCUSED);
}
*pbAutoLogon = FALSE;
return S_OK;
}
上面代码仅作示例,可能并没有什么实际作用。大家可能也注意到了 pbAutoLogon 参数,这个参数是一个传出参数,当你将它的值设置为 TRUE 的时候,系统将会尝试自动登录。这也是一个非常重要的特性,这里自动登录后,将直接触发我们下面要介绍的函数 GetSerialization。
CSampleCredential::GetSerialization
该函数就是界面上点击登录按钮,或者上面我们提到自动登录后触发的函数,再这里,你需要将界面上输入的用户名及密码等信息传递给系统,让操作系统去执行登录的操作。如下代码所示:
// Collect the username and password into a serialized credential for the correct usage scenario
// (logon/unlock is what's demonstrated in this sample). LogonUI then passes these credentials
// back to the system to log on.
HRESULT CSampleCredential::GetSerialization(
__out CREDENTIAL_PROVIDER_GET_SERIALIZATION_RESPONSE* pcpgsr,
__out CREDENTIAL_PROVIDER_CREDENTIAL_SERIALIZATION* pcpcs,
__deref_out_opt PWSTR* ppwszOptionalStatusText,
__in CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon
)
{
UNREFERENCED_PARAMETER(ppwszOptionalStatusText);
UNREFERENCED_PARAMETER(pcpsiOptionalStatusIcon);
HRESULT hr;
WCHAR wsz[MAX_COMPUTERNAME_LENGTH+1];
DWORD cch = ARRAYSIZE(wsz);
if (GetComputerNameW(wsz, &cch))
{
PWSTR pwzProtectedPassword;
hr = ProtectIfNecessaryAndCopyPassword(_rgFieldStrings[SFI_PASSWORD], _cpus, &pwzProtectedPassword);
if (SUCCEEDED(hr))
{
KERB_INTERACTIVE_UNLOCK_LOGON kiul;
hr = KerbInteractiveUnlockLogonInit(wsz, _rgFieldStrings[SFI_EDIT_TEXT], pwzProtectedPassword, _cpus, &kiul);
if (SUCCEEDED(hr))
{
// We use KERB_INTERACTIVE_UNLOCK_LOGON in both unlock and logon scenarios. It contains a
// KERB_INTERACTIVE_LOGON to hold the creds plus a LUID that is filled in for us by Winlogon
// as necessary.
hr = KerbInteractiveUnlockLogonPack(kiul, &pcpcs->rgbSerialization, &pcpcs->cbSerialization);
if (SUCCEEDED(hr))
{
ULONG ulAuthPackage;
hr = RetrieveNegotiateAuthPackage(&ulAuthPackage);
if (SUCCEEDED(hr))
{
pcpcs->ulAuthenticationPackage = ulAuthPackage;
pcpcs->clsidCredentialProvider = CLSID_CSample;
// At this point the credential has created the serialized credential used for logon
// By setting this to CPGSR_RETURN_CREDENTIAL_FINISHED we are letting logonUI know
// that we have all the information we need and it should attempt to submit the
// serialized credential.
*pcpgsr = CPGSR_RETURN_CREDENTIAL_FINISHED;
}
}
}
CoTaskMemFree(pwzProtectedPassword);
}
}
else
{
DWORD dwErr = GetLastError();
hr = HRESULT_FROM_WIN32(dwErr);
}
return hr;
}
函数中调用了获取计算机名的 API,并调用几个功能函数填充了登录系统所需的结构体,传递给系统进行登录。填充结构体的几个功能函数大家可以自己看一看,并不复杂。
CSampleCredential::ReportResult
ReportResult 函数是我们点击确定按钮登录系统后,操作登录反馈给我们结果的函数。你的登录成功了、密码过期了、密码错误了等信息都可以通过这个函数捕获到,配合上面的 GetSerialization 函数你可以完成一系列非常严谨的身份认证功能。ReportResult 函数有 4 个参数。
HRESULT CSampleCredential::ReportResult(
__in NTSTATUS ntsStatus, // 错误代码
__in NTSTATUS ntsSubstatus, // 附加错误代码
__deref_out_opt PWSTR* ppwszOptionalStatusText, // 错误提示文字,系统会给我们写好,我们也可以自己修改
__out CREDENTIAL_PROVIDER_STATUS_ICON* pcpsiOptionalStatusIcon // 界面上显示的错误图标
);
当你在使用的时候,建议你在该函数的入口处增加一处日志,打印出 ntsStatus 和 ntsSubStatus 的值。这样在遇到一些没遇到过的错误时,可以通过日志来分析问题。示例代码中给我们提供了两种错误示例:
static const REPORT_RESULT_STATUS_INFO s_rgLogonStatusInfo[] =
{
{ STATUS_LOGON_FAILURE, STATUS_SUCCESS, L"Incorrect password or username.", CPSI_ERROR, },
{ STATUS_ACCOUNT_RESTRICTION, STATUS_ACCOUNT_DISABLED, L"The account is disabled.", CPSI_WARNING },
};
一种是登录失败的错误码,一种是用户被禁用的错误码。如果想知道更多的错误码,比如密码过期等,可以从这两个宏跟进去就能看到所有的错误码了。最终你可以根据这些错误码给出不同的提示,当然提示的字符串 ppwszOptionalStatusText 也是可以修改的,你只需要调用 SHStrDupW 函数向这个字符串填充一些你想提示的字符串即可。调用前别忘记释放这个字符串的内存哦。