微软 Credential Providers 详解二《关键函数》

上一篇中我们介绍了凭据的加载和代码中函数的调用顺序,接下来我们就要了解一下一些关键函数在代码中起到什么作用了。了解清楚这些以后我们才能定制出我们自己需要功能。

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 函数向这个字符串填充一些你想提示的字符串即可。调用前别忘记释放这个字符串的内存哦。

12 评论

  1. 很抱歉又来打扰您,想请教您一下这个CSampleCredential::GetSerialization的这个函数,详解一里最后那个调用函数的图里,这个函数调用过之后就显示登陆是否成功了,那用户名和密码的认证应该就在这个GetSerialization和ReportResult里吧,我看这个GetSerialization里好像是把用户输入的值传给kiul这个变量,然后再复制给pcpcs,再从函数传出来是吧,不知道我理解的对不对,那如果是这样的话我看后边的那个ReportResult函数传入的数据就已经是登陆的状态了。想问下这个判断用户名和密码是否正确的地方在哪里

    1. 几个例子都差不多,进入到 GetSerialization 函数时,已经是登录前的一些准备了。这里是给系统传递用户名、密码、登录方式(域或本地)等信息的时候。实际就是完善一个结构体(详见 KerbInteractiveUnlockLogonInit 函数和 KerbInteractiveUnlockLogonPack 函数),填充到 GetSerialization 的传出参数 pcpcs 中。填充完毕后函数返回,这个时候系统就接管了登录的过程,它拿到你传递的用户名密码进行登录。无论登录成功还是失败,都将返回进入到 ReportResult 函数中。ReportResult 函数的 ntsStatus 和 ntsSubStatus 会告诉你登录成功还是失败了,是密码错误还是账户被禁用。所有系统返回的信息都在这两个参数中可以得到结果。你根据这两个参数来判断如果登录失败则重新返回到登录界面等一系列操作。这个要结合你具体业务的需求了。

      1. 那就是判断我密码是不是正确的过程是在GetSerialization和ReportResult中间的,进入到ReportResult的时候系统已经做好了这次登陆是成功还是失败了的判断了,这个判断是系统自动做的,是这样的吧 ,那这样登陆的时候只能输入已经在系统里的帐号和密码咯?

  2. 您好,我最近也在做Credential Providers这方面,我这边打算的是将指纹认证加到Credential Providers中,当指纹完成认证后将用户名和密码通过USB协议发送过来,然后将用户名和密码传递到GetSerialization中,我现在不清楚应该在CredentialProviderCredential哪个函数中完成读取指纹设备的认证情况并获取用户名和密码。

    1. 指纹设备认证没有那么复杂,我做过的项目中,指纹设备是由指定厂商提供的,比如中天一维等。他们会提供指纹硬件设备的驱动程序(驱动+动态库),驱动安装后你需要调用他们的接口来弹出指纹录入的窗口并等待用户录入指纹。当用户录入指纹后,无论对错,你调用的他们提供的驱动中的接口函数都会返回,此时你只需要判断函数调用的返回值就可以了。
      你需要指纹硬件厂商的配合,他们有成型的解决方案的。有需要再联系,祝好运。

      1. 您好,我有个疑问,我现在已经拿到了指纹设备的开发包,如果登陆认证只有指纹,我是要把指纹的验证放到GetSerialization函数中吗? 因为没有密码的输入,那提供什么给Windows进行认证呢?

        1. 像 USB-Key 一样,USB-Key 提供的只有一个 PIN 码输入,而如果想登录系统,是需要指定 USB-Key 和系统的某个账户进行绑定的,这个绑定的信息自己写到一个配置文件里面、或者数据库或自己组织的一种数据保存到文件中。当用户登录的时候,比如指定 USB-Key 验证 PIN 码正确了,那么就去查询自己保存的已经绑定的系统用户名和密码,传递给 GetSerialization 中结构体交由系统去登录。
          指纹也是一样的,需要绑定系统用户保存到文件中,验证指纹通过后读取绑定的信息传递给系统进行登录。
          说到绑定又涉及到很多东西,进入系统要有一个 UI 提供用户绑定,如果尚未绑定的指纹 Key 在登录界面要提供用户绑定的控件界面等。如果密码过期了,需要在登录界面提供用户修改密码的界面,这些都需要你慢慢研究。不过好消息是微软这些接口都有提供。

    2. 指纹设备认证没有那么复杂,我做过的项目中,指纹设备是由指定厂商提供的,比如中天一维等。他们会提供指纹硬件设备的驱动程序(驱动 动态库),驱动安装后你需要调用他们的接口来弹出指纹录入的窗口并等待用户录入指纹。当用户录入指纹后,无论对错,你调用的他们提供的驱动中的接口函数都会返回,此时你只需要判断函数调用的返回值就可以了。
      你需要指纹硬件厂商的配合,他们有成型的解决方案的。有需要再联系,祝好运。

  3. 你好,我在做这方面的时候,将CredentialProvider类GetCredentialCount函数中pbAutoLogonWithDefault 和ICredentialProviderCredential类SetSelected中的pbAutoLogon设为TRUE,实现了认证完成后的自动登录。现在的问题是如果输入的用户名或密码错误,系统会不断的调用GetSerialization,一直尝试用错误的用户名或密码去登录,我想问一下您,当设置自动登录后,如果认证错误,如何不让系统不断的调用GetSerialization?谢谢!

    1. 错误的账号密码在 ReportResult 中是可以截获到的,截获到错误的账号密码后可以设置错误的提示信息,也可以自己做一个成员变量,发生登录错误时做一个标记,GetSerialization 去判断这个标记就知道上一次是登录成功还是失败了。方法有很多,自己灵活的用用就好了。

发表评论