创建 Windows Hello 登录服务
这是有关如何在 Windows 10 和 Windows 11 UWP (通用 Windows 平台) 应用中使用 Windows Hello 作为传统用户名和密码身份验证系统的替代方法的完整演练的第 2 部分。 本文将接着第 1 部分 Windows Hello 登录应用进行介绍,并扩展相关功能来演示如何将 Windows Hello 集成到现有应用程序中。
为了生成此项目,你需要具有 C# 和 XAML 方面的一些经验。 还需要在Windows 10或Windows 11计算机上使用 Visual Studio 2015 (Community Edition 或更高版本) 。
练习 1:服务器端逻辑
在本练习中,你将从第一个实验中生成的 Windows Hello 应用程序开始操作,并创建一个本地 mock 服务器和数据库。 本动手实验设计用来教你如何将 Windows Hello 集成到现有系统中。 通过使用 mock 服务器和 mock 数据库,将消除大量不相关的设置。 在你自己的应用程序中,你需要将 mock 对象替换为真实的服务和数据库。
若要开始操作,请从第一个 Passport 动手实验打开 PassportLogin 解决方案。
首先实现 mock 服务器和 mock 数据库。 创建一个名为“AuthService”的新文件夹。 在“解决方案资源管理器”中,右键单击解决方案“PassportLogin(通用 Windows)”,然后依次选择“添加”>“新建文件夹”。
针对要保存在 mock 数据库中的数据创建将用作模型的 UserAccount 和 PassportDevices 类。 UserAccount 类似于在传统身份验证服务器上实现的用户模型。 右键单击 AuthService 文件夹,并添加名为“UserAccount.cs”的新类。
更改类定义以使其成为公共类,然后添加以下公共属性。 你将需要以下引用。
using System.ComponentModel.DataAnnotations; namespace PassportLogin.AuthService { public class UserAccount { [Key, Required] public Guid UserId { get; set; } [Required] public string Username { get; set; } public string Password { get; set; } // public List<PassportDevice> PassportDevices = new List<PassportDevice>(); } }
你可能已经注意到,已注释 PassportDevices 列表。 这是一项你需要对当前实现中的现有用户模型进行的修改。 PassportDevices 列表将包含 deviceID(从 Windows Hello 中获取的公钥)以及 KeyCredentialAttestationResult。 对于本动手实验,你将需要实现 keyAttestationResult,因为它们仅由 Windows Hello 在具有 TPM(受信任的平台模块)芯片的设备上提供。 KeyCredentialAttestationResult 是多个属性的组合,并且将需要进行拆分,以便可以使用数据库保存和加载它们。
在名为“PassportDevice.cs”的 AuthService 文件夹中创建新类。 这是适用于上述 Windows Hello 设备的模型。 更改类定义以使其成为公共类,并添加以下属性。
namespace PassportLogin.AuthService { public class PassportDevice { // These are the new variables that will need to be added to the existing UserAccount in the Database // The DeviceName is used to support multiple devices for the one user. // This way the correct public key is easier to find as a new public key is made for each device. // The KeyAttestationResult is only used if the User device has a TPM (Trusted Platform Module) chip, // in most cases it will not. So will be left out for this hands on lab. public Guid DeviceId { get; set; } public byte[] PublicKey { get; set; } // public KeyCredentialAttestationResult KeyAttestationResult { get; set; } } }
返回到 UserAccount.cs 并取消 Windows Hello 设备列表的注释。
using System.Collections.Generic; namespace PassportLogin.AuthService { public class UserAccount { [Key, Required] public Guid UserId { get; set; } [Required] public string Username { get; set; } public string Password { get; set; } public List<PassportDevice> PassportDevices = new List<PassportDevice>(); } }
在创建 UserAccount 和 PassportDevice 的模型后,你需要在用作 mock 数据库的 AuthService 中创建另一个新类。 因为这是将在其中本地保存和加载用户帐户列表的 mock 数据库。 在现实世界中,这将是你的数据库实现。 在称为“MockStore.cs”的 AuthService 中创建新类。 将该类定义更改为公共。
由于 mock 存储将本地保存和加载用户帐户列表,因此你可以使用 XmlSerializer 来实现用于保存和加载该列表的逻辑。 你还需要记住文件名和保存位置。 在 MockStore.cs 中,实现以下项:
using System.IO;
using System.Linq;
using System.Xml.Serialization;
using Windows.Storage;
namespace PassportLogin.AuthService
{
public class MockStore
{
private const string USER_ACCOUNT_LIST_FILE_NAME = "userAccountsList.txt";
// This cannot be a const because the LocalFolder is accessed at runtime
private string _userAccountListPath = Path.Combine(
ApplicationData.Current.LocalFolder.Path, USER_ACCOUNT_LIST_FILE_NAME);
private List<UserAccount> _mockDatabaseUserAccountsList;
#region Save and Load Helpers
/// <summary>
/// Create and save a useraccount list file. (Replacing the old one)
/// </summary>
private async void SaveAccountListAsync()
{
string accountsXml = SerializeAccountListToXml();
if (File.Exists(_userAccountListPath))
{
StorageFile accountsFile = await StorageFile.GetFileFromPathAsync(_userAccountListPath);
await FileIO.WriteTextAsync(accountsFile, accountsXml);
}
else
{
StorageFile accountsFile = await ApplicationData.Current.LocalFolder.CreateFileAsync(USER_ACCOUNT_LIST_FILE_NAME);
await FileIO.WriteTextAsync(accountsFile, accountsXml);
}
}
/// <summary>
/// Gets the useraccount list file and deserializes it from XML to a list of useraccount objects.
/// </summary>
/// <returns>List of useraccount objects</returns>
private async void LoadAccountListAsync()
{
if (File.Exists(_userAccountListPath))
{
StorageFile accountsFile = await StorageFile.GetFileFromPathAsync(_userAccountListPath);
string accountsXml = await FileIO.ReadTextAsync(accountsFile);
DeserializeXmlToAccountList(accountsXml);
}
// If the UserAccountList does not contain the sampleUser Initialize the sample users
// This is only needed as it in a Hand on Lab to demonstrate a user migrating
// In the real world user accounts would just be in a database
if (!_mockDatabaseUserAccountsList.Any(f => f.Username.Equals("sampleUsername")))
{
//If the list is empty InitializeSampleAccounts and return the list
//InitializeSampleUserAccounts();
}
}
/// <summary>
/// Uses the local list of accounts and returns an XML formatted string representing the list
/// </summary>
/// <returns>XML formatted list of accounts</returns>
private string SerializeAccountListToXml()
{
XmlSerializer xmlizer = new XmlSerializer(typeof(List<UserAccount>));
StringWriter writer = new StringWriter();
xmlizer.Serialize(writer, _mockDatabaseUserAccountsList);
return writer.ToString();
}
/// <summary>
/// Takes an XML formatted string representing a list of accounts and returns a list object of accounts
/// </summary>
/// <param name="listAsXml">XML formatted list of accounts</param>
/// <returns>List object of accounts</returns>
private List<UserAccount> DeserializeXmlToAccountList(string listAsXml)
{
XmlSerializer xmlizer = new XmlSerializer(typeof(List<UserAccount>));
TextReader textreader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(listAsXml)));
return _mockDatabaseUserAccountsList = (xmlizer.Deserialize(textreader)) as List<UserAccount>;
}
#endregion
}
}
在加载方法中,你可能已注意到 InitializeSampleUserAccounts 方法已有注释。你将需要在 MockStore.cs 中创建此方法。 此方法将填充用户帐户列表,以便可进行登录。 在现实世界中,用户数据库已填充。 在此步骤中,你还将创建一个构造函数,用于初始化用户列表和调用加载。
namespace PassportLogin.AuthService { public class MockStore { private const string USER_ACCOUNT_LIST_FILE_NAME = "userAccountsList.txt"; // This cannot be a const because the LocalFolder is accessed at runtime private string _userAccountListPath = Path.Combine( ApplicationData.Current.LocalFolder.Path, USER_ACCOUNT_LIST_FILE_NAME); private List<UserAccount> _mockDatabaseUserAccountsList; public MockStore() { _mockDatabaseUserAccountsList = new List& lt; UserAccount & gt; (); LoadAccountListAsync(); } private void InitializeSampleUserAccounts() { // Create a sample Traditional User Account that only has a Username and Password // This will be used initially to demonstrate how to migrate to use Windows Hello UserAccount sampleUserAccount = new UserAccount() { UserId = Guid.NewGuid(), Username = "sampleUsername", Password = "samplePassword", }; // Add the sampleUserAccount to the _mockDatabase _mockDatabaseUserAccountsList.Add(sampleUserAccount); SaveAccountListAsync(); } } }
既然 InitalizeSampleUserAccounts 方法已存在,可在 LoadAccountListAsync 方法中取消方法调用的注释。
private async void LoadAccountListAsync() { if (File.Exists(_userAccountListPath)) { StorageFile accountsFile = await StorageFile.GetFileFromPathAsync(_userAccountListPath); string accountsXml = await FileIO.ReadTextAsync(accountsFile); DeserializeXmlToAccountList(accountsXml); } // If the UserAccountList does not contain the sampleUser Initialize the sample users // This is only needed as it in a Hand on Lab to demonstrate a user migrating // In the real world user accounts would just be in a database if (!_mockDatabaseUserAccountsList.Any(f = > f.Username.Equals("sampleUsername"))) { //If the list is empty InitializeSampleAccounts and return the list InitializeSampleUserAccounts(); } }
现在可以保存和加载 mock 存储中的用户帐户列表。 由于应用程序的其他部分需要具有对此列表的访问权限,因此需要采用一些方法来检索此数据。 在 InitializeSampleUserAccounts 方法的下方,添加以下 get 方法。 借助这些方法,你不仅可以针对特定 Windows Hello 设备获取 userid、单个用户和用户列表,还可以针对特定设备上的用户获取公钥。
public Guid GetUserId(string username) { if (_mockDatabaseUserAccountsList.Any()) { UserAccount account = _mockDatabaseUserAccountsList.FirstOrDefault(f => f.Username.Equals(username)); if (account != null) { return account.UserId; } } return Guid.Empty; } public UserAccount GetUserAccount(Guid userId) { return _mockDatabaseUserAccountsList.FirstOrDefault(f => f.UserId.Equals(userId)); } public List<UserAccount> GetUserAccountsForDevice(Guid deviceId) { List<UserAccount> usersForDevice = new List<UserAccount>(); foreach (UserAccount account in _mockDatabaseUserAccountsList) { if (account.PassportDevices.Any(f => f.DeviceId.Equals(deviceId))) { usersForDevice.Add(account); } } return usersForDevice; } public byte[] GetPublicKey(Guid userId, Guid deviceId) { UserAccount account = _mockDatabaseUserAccountsList.FirstOrDefault(f => f.UserId.Equals(userId)); if (account != null) { if (account.PassportDevices.Any()) { return account.PassportDevices.FirstOrDefault(p => p.DeviceId.Equals(deviceId)).PublicKey; } } return null; }
要实现的后续方法将处理添加帐户、删除帐户以及删除设备的简单操作。 删除设备是必需的,因为 Windows Hello 特定于设备。 对于要登录的每台设备,Windows Hello 将创建新的公钥和私钥对。 这与登录的每台设备具有不同密码的情况类似,唯一的区别是你无需记住所有这些密码(服务器会记住)。 将以下方法添加到 MockStore.cs 中
public UserAccount AddAccount(string username) { UserAccount newAccount = null; try { newAccount = new UserAccount() { UserId = Guid.NewGuid(), Username = username, }; _mockDatabaseUserAccountsList.Add(newAccount); SaveAccountListAsync(); } catch (Exception) { throw; } return newAccount; } public bool RemoveAccount(Guid userId) { UserAccount userAccount = GetUserAccount(userId); if (userAccount != null) { _mockDatabaseUserAccountsList.Remove(userAccount); SaveAccountListAsync(); return true; } return false; } public bool RemoveDevice(Guid userId, Guid deviceId) { UserAccount userAccount = GetUserAccount(userId); PassportDevice deviceToRemove = null; if (userAccount != null) { foreach (PassportDevice device in userAccount.PassportDevices) { if (device.DeviceId.Equals(deviceId)) { deviceToRemove = device; break; } } } if (deviceToRemove != null) { //Remove the PassportDevice userAccount.PassportDevices.Remove(deviceToRemove); SaveAccountListAsync(); } return true; }
在 MockStore 类中,添加一个将 Windows Hello 相关信息添加到现有 UserAccount 的方法。 此方法将命名为 PassportUpdateDetails,并采用参数来标识用户和 Windows Hello 详细信息。 在创建 PassportDevice 时,KeyAttestationResult 已有注释,不过在现实应用程序中,你需要此项。
using Windows.Security.Credentials; public void PassportUpdateDetails(Guid userId, Guid deviceId, byte[] publicKey, KeyCredentialAttestationResult keyAttestationResult) { UserAccount existingUserAccount = GetUserAccount(userId); if (existingUserAccount != null) { if (!existingUserAccount.PassportDevices.Any(f => f.DeviceId.Equals(deviceId))) { existingUserAccount.PassportDevices.Add(new PassportDevice() { DeviceId = deviceId, PublicKey = publicKey, // KeyAttestationResult = keyAttestationResult }); } } SaveAccountListAsync(); }
MockStore 类现已完成,这表示应将数据库视为专用数据库。 若要访问 MockStore,则需要 AuthService 类来操纵数据库数据。 在 AuthService 文件夹中,创建名为“AuthService.cs”的新类。 将类定义更改为公共,并添加单一实例模式,以确保仅创建了一个实例。
namespace PassportLogin.AuthService { public class AuthService { // Singleton instance of the AuthService // The AuthService is a mock of what a real world server and service implementation would be private static AuthService _instance; public static AuthService Instance { get { if (null == _instance) { _instance = new AuthService(); } return _instance; } } private AuthService() { } } }
AuthService 类将需要创建 MockStore 类的实例,并提供对 MockStore 对象的属性的访问权限。
namespace PassportLogin.AuthService { public class AuthService { //Singleton instance of the AuthService //The AuthService is a mock of what a real world server and database implementation would be private static AuthService _instance; public static AuthService Instance { get { if (null == _instance) { _instance = new AuthService(); } return _instance; } } private MockStore _mockStore = new MockStore(); public Guid GetUserId(string username) { return _mockStore.GetUserId(username); } public UserAccount GetUserAccount(Guid userId) { return _mockStore.GetUserAccount(userId); } public List<UserAccount> GetUserAccountsForDevice(Guid deviceId) { return _mockStore.GetUserAccountsForDevice(deviceId); } } }
你需要 AuthService 类中的方法,以便访问 MockStore 对象中的添加、删除和更新 Passport 详细信息方法。 在 AuthService 类文件的末尾处,添加以下方法。
using Windows.Security.Credentials; public void Register(string username) { _mockStore.AddAccount(username); } public bool PassportRemoveUser(Guid userId) { return _mockStore.RemoveAccount(userId); } public bool PassportRemoveDevice(Guid userId, Guid deviceId) { return _mockStore.RemoveDevice(userId, deviceId); } public void PassportUpdateDetails(Guid userId, Guid deviceId, byte[] publicKey, KeyCredentialAttestationResult keyAttestationResult) { _mockStore.PassportUpdateDetails(userId, deviceId, publicKey, keyAttestationResult); }
AuthService 类将需要提供一种方法来验证凭据。 此方法将采用用户名和密码,请确保帐户存在且密码有效。 现有系统将采用等效方法来检查用户是否已授权。 将以下 ValidateCredentials 添加到 AuthService.cs 文件。
public bool ValidateCredentials(string username, string password) { if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) { // This would be used for existing accounts migrating to use Passport Guid userId = GetUserId(username); if (userId != Guid.Empty) { UserAccount account = GetUserAccount(userId); if (account != null) { if (string.Equals(password, account.Password)) { return true; } } } } return false; }
AuthService 类需要一个请求质询方法来将质询返回到客户端,从而验证用户的身份是否就是其所声明的那样。 随后,在 AuthService 类中需要使用一种方法,以便从客户端重新接收已签名的质询。 在本动手实验中,确定已签名质询是否已完成的方法并不完整。 Windows Hello 到现有身份验证系统的每个实现将略有不同。 存储在服务器上的公钥需要与客户端返回到服务器的结果匹配。 将这两种方法添加到 AuthService.cs。
using Windows.Security.Cryptography; using Windows.Storage.Streams; public IBuffer PassportRequestChallenge() { return CryptographicBuffer.ConvertStringToBinary("ServerChallenge", BinaryStringEncoding.Utf8); } public bool SendServerSignedChallenge(Guid userId, Guid deviceId, byte[] signedChallenge) { // Depending on your company polices and procedures this step will be different // It is at this point you will need to validate the signedChallenge that is sent back from the client. // Validation is used to ensure the correct user is trying to access this account. // The validation process will use the signedChallenge and the stored PublicKey // for the username and the specific device signin is called from. // Based on the validation result you will return a bool value to allow access to continue or to block the account. // For this sample validation will not happen as a best practice solution does not apply and will need to // be configured for each company. // Simply just return true. // You could get the User's Public Key with something similar to the following: byte[] userPublicKey = _mockStore.GetPublicKey(userId, deviceId); return true; }
练习 2:客户端逻辑
在本练习中,你将更改来自第一个实验的客户端视图和帮助程序类,以便使用 AuthService 类。 在现实世界中,AuthService 即为身份验证服务器,并且你需要使用 Web API 发送数据并从服务器接收数据。 为方便起见,本动手实验中的客户端和服务器都是本地的。 目标是了解如何使用 Windows Hello API。
在 MainPage.xaml.cs 中,可以删除加载方法中的 AccountHelper.LoadAccountListAsync 方法调用,因为 AuthService 类创建了一个可用于加载帐户列表的 MockStore 的实例。 现在,加载的方法应如下所示。 请注意,异步方法定义已删除,因为没有任何项处于等待状态。
private void MainPage_Loaded(object sender, RoutedEventArgs e) { Frame.Navigate(typeof(UserSelection)); }
更新需要输入 Passport 的登录页界面。 本动手实验演示了如何迁移现有系统以便使用 Windows Hello,以及现有帐户将具有用户名和密码。 此外,更新 XAML 底部的说明以包含默认密码。 在 Login.xaml 中更新以下 XAML
<Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> <StackPanel Orientation="Vertical"> <TextBlock Text="Login" FontSize="36" Margin="4" TextAlignment="Center"/> <TextBlock x:Name="ErrorMessage" Text="" FontSize="20" Margin="4" Foreground="Red" TextAlignment="Center"/> <TextBlock Text="Enter your credentials below" Margin="0,0,0,20" TextWrapping="Wrap" Width="300" TextAlignment="Center" VerticalAlignment="Center" FontSize="16"/> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <!-- Username Input --> <TextBlock x:Name="UserNameTextBlock" Text="Username: " FontSize="20" Margin="4" Width="100"/> <TextBox x:Name="UsernameTextBox" PlaceholderText="sampleUsername" Width="200" Margin="4"/> </StackPanel> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <!-- Password Input --> <TextBlock x:Name="PasswordTextBlock" Text="Password: " FontSize="20" Margin="4" Width="100"/> <PasswordBox x:Name="PasswordBox" PlaceholderText="samplePassword" Width="200" Margin="4"/> </StackPanel> <Button x:Name="PassportSignInButton" Content="Login" Background="DodgerBlue" Foreground="White" Click="PassportSignInButton_Click" Width="80" HorizontalAlignment="Center" Margin="0,20"/> <TextBlock Text="Don't have an account?" TextAlignment="Center" VerticalAlignment="Center" FontSize="16"/> <TextBlock x:Name="RegisterButtonTextBlock" Text="Register now" PointerPressed="RegisterButtonTextBlock_OnPointerPressed" Foreground="DodgerBlue" TextAlignment="Center" VerticalAlignment="Center" FontSize="16"/> <Border x:Name="PassportStatus" Background="#22B14C" Margin="0,20" Height="100"> <TextBlock x:Name="PassportStatusText" Text="Windows Hello is ready to use!" Margin="4" TextAlignment="Center" VerticalAlignment="Center" FontSize="20"/> </Border> <TextBlock x:Name="LoginExplaination" FontSize="24" TextAlignment="Center" TextWrapping="Wrap" Text="Please Note: To demonstrate a login, validation will only occur using the default username 'sampleUsername' and default password 'samplePassword'"/> </StackPanel> </Grid>
在 Login 类代码隐藏中,你将需要将该类顶部的 Account 私有变量更改为 UserAccount。 更改 OnNavigateTo 事件,以便将该类型强制转换为 UserAccount。 你将需要以下引用。
using PassportLogin.AuthService; namespace PassportLogin.Views { public sealed partial class Login : Page { private UserAccount _account; private bool _isExistingAccount; public Login() { this.InitializeComponent(); } protected override async void OnNavigatedTo(NavigationEventArgs e) { //Check Windows Hello is setup and available on this machine if (await MicrosoftPassportHelper.MicrosoftPassportAvailableCheckAsync()) { if (e.Parameter != null) { _isExistingAccount = true; //Set the account to the existing account being passed in _account = (UserAccount)e.Parameter; UsernameTextBox.Text = _account.Username; SignInPassport(); } } } } }
由于登录页使用的是 UserAccount 象而非之前的 Account 对象,因此 MicrosoftPassportHelper.cs 将需要进行更新,以便可以针对某些方法将 UserAccount 用作参数。 你将需要针对 CreatePassportKeyAsync、RemovePassportAccountAsync 和 GetPassportAuthenticationMessageAsync 方法更改以下参数。 因为 UserAccount 类具有 UserId 的 Guid,因此你将开始在多个位置中使用 Id 以使其更为具体化。
public static async Task<bool> CreatePassportKeyAsync(Guid userId, string username) { KeyCredentialRetrievalResult keyCreationResult = await KeyCredentialManager.RequestCreateAsync(username, KeyCredentialCreationOption.ReplaceExisting); } public static async void RemovePassportAccountAsync(UserAccount account) { } public static async Task<bool> GetPassportAuthenticationMessageAsync(UserAccount account) { KeyCredentialRetrievalResult openKeyResult = await KeyCredentialManager.OpenAsync(account.Username); //Calling OpenAsync will allow the user access to what is available in the app and will not require user credentials again. //If you wanted to force the user to sign in again you can use the following: //var consentResult = await Windows.Security.Credentials.UI.UserConsentVerifier.RequestVerificationAsync(account.Username); //This will ask for the either the password of the currently signed in Microsoft Account or the PIN used for Windows Hello. if (openKeyResult.Status == KeyCredentialStatus.Success) { //If OpenAsync has succeeded, the next thing to think about is whether the client application requires access to backend services. //If it does here you would Request a challenge from the Server. The client would sign this challenge and the server //would check the signed challenge. If it is correct it would allow the user access to the backend. //You would likely make a new method called RequestSignAsync to handle all this //for example, RequestSignAsync(openKeyResult); //Refer to the second Windows Hello sample for information on how to do this. //For this sample there is not concept of a server implemented so just return true. return true; } else if (openKeyResult.Status == KeyCredentialStatus.NotFound) { //If the _account is not found at this stage. It could be one of two errors. //1. Windows Hello has been disabled //2. Windows Hello has been disabled and re-enabled cause the Windows Hello Key to change. //Calling CreatePassportKey and passing through the account will attempt to replace the existing Windows Hello Key for that account. //If the error really is that Windows Hello is disabled then the CreatePassportKey method will output that error. if (await CreatePassportKeyAsync(account.UserId, account.Username)) { //If the Passport Key was again successfully created, Windows Hello has just been reset. //Now that the Passport Key has been reset for the _account retry sign in. return await GetPassportAuthenticationMessageAsync(account); } } // Can't use Passport right now, try again later return false; }
Login.xaml.cs 文件中的 SignInPassport 方法将需要进行更新,以便使用 AuthService 而不是 AccountHelper。 将通过 AuthService 对凭据进行验证。 在本动手实验中,唯一配置的帐户是“sampleUsername”。 此帐户在 MockStore.cs 的 InitializeSampleUserAccounts 方法中进行创建。 现在更新 Login.xaml.cs 中的 SignInPassport 方法以反映下面的代码片段。
private async void SignInPassportAsync() { if (_isExistingLocalAccount) { if (await MicrosoftPassportHelper.GetPassportAuthenticationMessageAsync(_account)) { Frame.Navigate(typeof(Welcome), _account); } } else if (AuthService.AuthService.Instance.ValidateCredentials(UsernameTextBox.Text, PasswordBox.Password)) { Guid userId = AuthService.AuthService.Instance.GetUserId(UsernameTextBox.Text); if (userId != Guid.Empty) { //Now that the account exists on server try and create the necessary passport details and add them to the account bool isSuccessful = await MicrosoftPassportHelper.CreatePassportKeyAsync(userId, UsernameTextBox.Text); if (isSuccessful) { Debug.WriteLine("Successfully signed in with Windows Hello!"); //Navigate to the Welcome Screen. _account = AuthService.AuthService.Instance.GetUserAccount(userId); Frame.Navigate(typeof(Welcome), _account); } else { //The passport account creation failed. //Remove the account from the server as passport details were not configured AuthService.AuthService.Instance.PassportRemoveUser(userId); ErrorMessage.Text = "Account Creation Failed"; } } } else { ErrorMessage.Text = "Invalid Credentials"; } }
由于 Windows Hello 将对每台设备上的每个帐户创建不同的公钥和私钥对,因此欢迎页将需要对已登录帐户显示已注册设备列表,并允许忘记所有设备。 在 Welcome.xaml 中,在 ForgetButton 的下方添加以下 XAML。 这将实现一个忘记设备按钮、一个错误文本区域以及一个用于显示所有设备的列表。
<Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> <StackPanel Orientation="Vertical"> <TextBlock x:Name="Title" Text="Welcome" FontSize="40" TextAlignment="Center"/> <TextBlock x:Name="UserNameText" FontSize="28" TextAlignment="Center" Foreground="Black"/> <Button x:Name="BackToUserListButton" Content="Back to User List" Click="Button_Restart_Click" HorizontalAlignment="Center" Margin="0,20" Foreground="White" Background="DodgerBlue"/> <Button x:Name="ForgetButton" Content="Forget Me" Click="Button_Forget_User_Click" Foreground="White" Background="Gray" HorizontalAlignment="Center"/> <Button x:Name="ForgetDeviceButton" Content="Forget Device" Click="Button_Forget_Device_Click" Foreground="White" Background="Gray" Margin="0,40,0,20" HorizontalAlignment="Center"/> <TextBlock x:Name="ForgetDeviceErrorTextBlock" Text="Select a device first" TextWrapping="Wrap" Width="300" Foreground="Red" TextAlignment="Center" VerticalAlignment="Center" FontSize="16" Visibility="Collapsed"/> <ListView x:Name="UserListView" MaxHeight="500" MinWidth="350" Width="350" HorizontalAlignment="Center"> <ListView.ItemTemplate> <DataTemplate> <Grid Background="Gray" Height="50" Width="350" HorizontalAlignment="Center" VerticalAlignment="Stretch" > <TextBlock Text="{Binding DeviceId}" HorizontalAlignment="Center" TextAlignment="Center" VerticalAlignment="Center" Foreground="White"/> </Grid> </DataTemplate> </ListView.ItemTemplate> </ListView> </StackPanel> </Grid>
在 Welcome.xaml.cs 文件中,你需要将该类顶部的私有 Account 变量更改为私有 UserAccount 变量。 然后更新 OnNavigatedTo 方法,以便使用 AuthService 并从当前帐户检索信息。 当你拥有帐户信息时,你可以将列表的 itemsource 设置为显示设备。 你将需要添加对 AuthService 命名空间的引用。
using PassportLogin.AuthService; namespace PassportLogin.Views { public sealed partial class Welcome : Page { private UserAccount _activeAccount; public Welcome() { InitializeComponent(); } protected override void OnNavigatedTo(NavigationEventArgs e) { _activeAccount = (UserAccount)e.Parameter; if (_activeAccount != null) { UserAccount account = AuthService.AuthService.Instance.GetUserAccount(_activeAccount.UserId); if (account != null) { UserListView.ItemsSource = account.PassportDevices; UserNameText.Text = account.Username; } } } } }
因为你在删除帐户时要使用 AuthService,因此可以删除对 Button_Forget_User_Click 方法中的 AccountHelper 的引用。 现在,该方法应如下所示。
private void Button_Forget_User_Click(object sender, RoutedEventArgs e) { //Remove it from Windows Hello MicrosoftPassportHelper.RemovePassportAccountAsync(_activeAccount); Debug.WriteLine("User " + _activeAccount.Username + " deleted."); //Navigate back to UserSelection page. Frame.Navigate(typeof(UserSelection)); }
MicrosoftPassportHelper 方法不使用 AuthService 来删除帐户。 你需要调用 AuthService 并传递 userId。
public static async void RemovePassportAccountAsync(UserAccount account) { //Open the account with Windows Hello KeyCredentialRetrievalResult keyOpenResult = await KeyCredentialManager.OpenAsync(account.Username); if (keyOpenResult.Status == KeyCredentialStatus.Success) { // In the real world you would send key information to server to unregister AuthService.AuthService.Instance.PassportRemoveUser(account.UserId); } //Then delete the account from the machines list of Passport Accounts await KeyCredentialManager.DeleteAsync(account.Username); }
在完成实现欢迎页类之前,你需要在 MicrosoftPassportHelper.cs 中创建一种允许删除设备的方法。 创建一个将在 AuthService 中调用 PassportRemoveDevice 的新方法。
public static void RemovePassportDevice(UserAccount account, Guid deviceId) { AuthService.AuthService.Instance.PassportRemoveDevice(account.UserId, deviceId); }
在 Welcome.xaml.cs 中,实现“忘记设备”单击事件。 这将使用从设备列表选定的设备,并使用 Passport 帮助程序来调用删除设备。
private void Button_Forget_Device_Click(object sender, RoutedEventArgs e) { PassportDevice selectedDevice = UserListView.SelectedItem as PassportDevice; if (selectedDevice != null) { //Remove it from Windows Hello MicrosoftPassportHelper.RemovePassportDevice(_activeAccount, selectedDevice.DeviceId); Debug.WriteLine("User " + _activeAccount.Username + " deleted."); if (!UserListView.Items.Any()) { //Navigate back to UserSelection page. Frame.Navigate(typeof(UserSelection)); } } else { ForgetDeviceErrorTextBlock.Visibility = Visibility.Visible; } }
将更新的下一个页面是 UserSelection 页。 UserSelection 页将需要使用 AuthService 来检索当前设备的所有用户帐户。 当前没有方法可用来获取传递给 AuthService 的设备 id,因此它将返回该设备的用户帐户。 在 Utils 文件夹中,创建名为“Helpers.cs”的新类。 将类定义更改为公共静态,然后添加以下方法,以便用户可以检索当前设备 id。
using Windows.Security.ExchangeActiveSyncProvisioning; namespace PassportLogin.Utils { public static class Helpers { public static Guid GetDeviceId() { //Get the Device ID to pass to the server EasClientDeviceInformation deviceInformation = new EasClientDeviceInformation(); return deviceInformation.Id; } } }
在 UserSelection 页类中,只需更改代码隐藏,无需更改用户界面。 在 UserSelection.xaml.cs 中,更新加载方法和用户选择方法,以便使用 UserAccount 类而不是 Account 类。 你还需要通过 AuthService 获取此设备的所有用户。
using System.Linq; using PassportLogin.AuthService; namespace PassportLogin.Views { public sealed partial class UserSelection : Page { public UserSelection() { InitializeComponent(); Loaded += UserSelection_Loaded; } private void UserSelection_Loaded(object sender, RoutedEventArgs e) { List<UserAccount> accounts = AuthService.AuthService.Instance.GetUserAccountsForDevice(Helpers.GetDeviceId()); if (accounts.Any()) { UserListView.ItemsSource = accounts; UserListView.SelectionChanged += UserSelectionChanged; } else { //If there are no accounts navigate to the LoginPage Frame.Navigate(typeof(Login)); } } /// <summary> /// Function called when an account is selected in the list of accounts /// Navigates to the Login page and passes the chosen account /// </summary> private void UserSelectionChanged(object sender, RoutedEventArgs e) { if (((ListView)sender).SelectedValue != null) { UserAccount account = (UserAccount)((ListView)sender).SelectedValue; if (account != null) { Debug.WriteLine("Account " + account.Username + " selected!"); } Frame.Navigate(typeof(Login), account); } } } }
PassportRegister 页需要更新代码隐藏,而用户界面无需进行更改。 在 PassportRegister.xaml.cs 中,删除该类顶部不再需要的私有 Account 变量。 更新 RegisterButton 单击事件以使用 AuthService。 此方法将创建新的 UserAccount,然后重试并更新其 Passport 详细信息。 如果 Passport 无法创建 Passport 密钥,则由于注册过程失败而删除帐户。
private async void RegisterButton_Click_Async(object sender, RoutedEventArgs e) { ErrorMessage.Text = ""; //Validate entered credentials are acceptable if (!string.IsNullOrEmpty(UsernameTextBox.Text)) { //Register an Account on the AuthService so that we can get back a userId AuthService.AuthService.Instance.Register(UsernameTextBox.Text); Guid userId = AuthService.AuthService.Instance.GetUserId(UsernameTextBox.Text); if (userId != Guid.Empty) { //Now that the account exists on server try and create the necessary passport details and add them to the account bool isSuccessful = await MicrosoftPassportHelper.CreatePassportKeyAsync(userId, UsernameTextBox.Text); if (isSuccessful) { //Navigate to the Welcome Screen. Frame.Navigate(typeof(Welcome), AuthService.AuthService.Instance.GetUserAccount(userId)); } else { //The passport account creation failed. //Remove the account from the server as passport details were not configured AuthService.AuthService.Instance.PassportRemoveUser(userId); ErrorMessage.Text = "Account Creation Failed"; } } } else { ErrorMessage.Text = "Please enter a username"; } }
生成并运行应用程序 (F5)。 使用凭据“sampleUsername”和“samplePassword”登录到示例用户帐户。 在欢迎屏幕上,你可能会注意到忽略设备按钮已显示,但未显示任何设备。 当你创建或迁移用户以便使用 Windows Hello 时,Passport 信息不会推送至 AuthService。
若要将 Passport 信息传递到 AuthService,需要更新 MicrosoftPassportHelper.cs。 在 CreatePassportKeyAsync 方法中,不仅要在成功时返回 true,还需要调用尝试获取 KeyAttestation 的新方法。 尽管此动手实验不会在 AuthService 中记录此信息,但你将在客户端上了解获取此信息的方法。 更新 CreatePassportKeyAsync 方法。
public static async Task<bool> CreatePassportKeyAsync(Guid userId, string username) { KeyCredentialRetrievalResult keyCreationResult = await KeyCredentialManager.RequestCreateAsync(username, KeyCredentialCreationOption.ReplaceExisting); switch (keyCreationResult.Status) { case KeyCredentialStatus.Success: Debug.WriteLine("Successfully made key"); await GetKeyAttestationAsync(userId, keyCreationResult); return true; case KeyCredentialStatus.UserCanceled: Debug.WriteLine("User cancelled sign-in process."); break; case KeyCredentialStatus.NotFound: // User needs to setup Windows Hello Debug.WriteLine("Windows Hello is not setup!\nPlease go to Windows Settings and set up a PIN to use it."); break; default: break; } return false; }
在 MicrosoftPassportHelper.cs 中创建 GetKeyAttestationAsync 方法。 此方法将展示如何获取 Windows Hello 为特定设备上的每个帐户提供的所有必要信息。
using Windows.Storage.Streams; private static async Task GetKeyAttestationAsync(Guid userId, KeyCredentialRetrievalResult keyCreationResult) { KeyCredential userKey = keyCreationResult.Credential; IBuffer publicKey = userKey.RetrievePublicKey(); KeyCredentialAttestationResult keyAttestationResult = await userKey.GetAttestationAsync(); IBuffer keyAttestation = null; IBuffer certificateChain = null; bool keyAttestationIncluded = false; bool keyAttestationCanBeRetrievedLater = false; KeyCredentialAttestationStatus keyAttestationRetryType = 0; if (keyAttestationResult.Status == KeyCredentialAttestationStatus.Success) { keyAttestationIncluded = true; keyAttestation = keyAttestationResult.AttestationBuffer; certificateChain = keyAttestationResult.CertificateChainBuffer; Debug.WriteLine("Successfully made key and attestation"); } else if (keyAttestationResult.Status == KeyCredentialAttestationStatus.TemporaryFailure) { keyAttestationRetryType = KeyCredentialAttestationStatus.TemporaryFailure; keyAttestationCanBeRetrievedLater = true; Debug.WriteLine("Successfully made key but not attestation"); } else if (keyAttestationResult.Status == KeyCredentialAttestationStatus.NotSupported) { keyAttestationRetryType = KeyCredentialAttestationStatus.NotSupported; keyAttestationCanBeRetrievedLater = false; Debug.WriteLine("Key created, but key attestation not supported"); } Guid deviceId = Helpers.GetDeviceId(); //Update the Pasport details with the information we have just gotten above. //UpdatePassportDetails(userId, deviceId, publicKey.ToArray(), keyAttestationResult); }
你可能已注意到在你刚添加了最后一行的 GetKeyAttestationAsync 方法中已有注释。最后一行将是你创建的一个新方法,它会将所有 Windows Hello 信息发送到 AuthService。 在真实世界中,你需要将此信息发送到带有 Web API 的真实服务器。
using System.Runtime.InteropServices.WindowsRuntime; public static bool UpdatePassportDetails(Guid userId, Guid deviceId, byte[] publicKey, KeyCredentialAttestationResult keyAttestationResult) { //In the real world you would use an API to add Passport signing info to server for the signed in _account. //For this tutorial we do not implement a WebAPI for our server and simply mock the server locally //The CreatePassportKey method handles adding the Windows Hello account locally to the device using the KeyCredential Manager //Using the userId the existing account should be found and updated. AuthService.AuthService.Instance.PassportUpdateDetails(userId, deviceId, publicKey, keyAttestationResult); return true; }
在 GetKeyAttestationAsync 方法中取消最后一行的注释,以便 Windows Hello 信息可以发送到 AuthService。
像以前一样,生成和运行应用程序,并使用默认凭据登录。 现在,在欢迎屏幕上,你将看到显示设备 ID。 如果登录了也会在此处显示的另一台设备(如果你拥有云托管的身份验证服务)。 在本动手实验中,将显示实际设备 ID。 在实际实现中,你会想要显示用户可以理解并用于确定每台设备的友好名称。
-
- 若要完成本动手实验,在用户从用户选择页面进行选择并重新登录时,你需要用户的请求和质询。 AuthService 具有两种你为请求质询而创建的方法,其中一种方法使用签名的质询。 在 MicrosoftPassportHelper.cs 中,创建名为“RequestSignAsync”的新方法。此方法会从 AuthService 请求质询、在本地使用 Passport API 对该质询进行签名,并将已签名的质询发送到 AuthService。 在本动手实验中,AuthService 将接收已签名的质询并返回 true。 在实际实现中,你需要实现验证机制,以确定该质询是否已在正确的设备上由正确的用户进行签名。 将以下方法添加到 MicrosoftPassportHelper.cs
private static async Task<bool> RequestSignAsync(Guid userId, KeyCredentialRetrievalResult openKeyResult) { // Calling userKey.RequestSignAsync() prompts the uses to enter the PIN or use Biometrics (Windows Hello). // The app would use the private key from the user account to sign the sign-in request (challenge) // The client would then send it back to the server and await the servers response. IBuffer challengeMessage = AuthService.AuthService.Instance.PassportRequestChallenge(); KeyCredential userKey = openKeyResult.Credential; KeyCredentialOperationResult signResult = await userKey.RequestSignAsync(challengeMessage); if (signResult.Status == KeyCredentialStatus.Success) { // If the challenge from the server is signed successfully // send the signed challenge back to the server and await the servers response return AuthService.AuthService.Instance.SendServerSignedChallenge( userId, Helpers.GetDeviceId(), signResult.Result.ToArray()); } else if (signResult.Status == KeyCredentialStatus.UserCanceled) { // User cancelled the Windows Hello PIN entry. } else if (signResult.Status == KeyCredentialStatus.NotFound) { // Must recreate Windows Hello key } else if (signResult.Status == KeyCredentialStatus.SecurityDeviceLocked) { // Can't use Windows Hello right now, remember that hardware failed and suggest restart } else if (signResult.Status == KeyCredentialStatus.UnknownError) { // Can't use Windows Hello right now, try again later } return false; }
-
- 在 MicrosoftPassportHelper 类中,从 GetPassportAuthenticationMessageAsync 方法调用 RequestSignAsync 方法。
public static async Task<bool> GetPassportAuthenticationMessageAsync(UserAccount account) { KeyCredentialRetrievalResult openKeyResult = await KeyCredentialManager.OpenAsync(account.Username); // Calling OpenAsync will allow the user access to what is available in the app and will not require user credentials again. // If you wanted to force the user to sign in again you can use the following: // var consentResult = await Windows.Security.Credentials.UI.UserConsentVerifier.RequestVerificationAsync(account.Username); // This will ask for the either the password of the currently signed in Microsoft Account or the PIN used for Windows Hello. if (openKeyResult.Status == KeyCredentialStatus.Success) { //If OpenAsync has succeeded, the next thing to think about is whether the client application requires access to backend services. //If it does here you would Request a challenge from the Server. The client would sign this challenge and the server //would check the signed challenge. If it is correct it would allow the user access to the backend. //You would likely make a new method called RequestSignAsync to handle all this //for example, RequestSignAsync(openKeyResult); //Refer to the second Windows Hello sample for information on how to do this. return await RequestSignAsync(account.UserId, openKeyResult); } else if (openKeyResult.Status == KeyCredentialStatus.NotFound) { //If the _account is not found at this stage. It could be one of two errors. //1. Windows Hello has been disabled //2. Windows Hello has been disabled and re-enabled cause the Windows Hello Key to change. //Calling CreatePassportKey and passing through the account will attempt to replace the existing Windows Hello Key for that account. //If the error really is that Windows Hello is disabled then the CreatePassportKey method will output that error. if (await CreatePassportKeyAsync(account.UserId, account.Username)) { //If the Passport Key was again successfully created, Windows Hello has just been reset. //Now that the Passport Key has been reset for the _account retry sign in. return await GetPassportAuthenticationMessageAsync(account); } } // Can't use Windows Hello right now, try again later return false; }
在此整个练习中,你更新了客户端应用程序以使用 AuthService。 执行此操作后,不需要再使用 Account 类和 AccountHelper 类。 在 Utils 文件夹中删除 Account 类、Models 文件夹和 AccountHelper 类。 成功生成解决方案之前,你需要删除对整个应用程序内 Models 命名空间的所有引用。
生成和运行该应用程序,然后尽情使用带有 mock 服务和数据库的 Windows Hello。
在本动手实验中,你已了解如何使用 Windows Hello API 来取代从Windows 10或Windows 11计算机使用身份验证时对密码的需求。 当你思考用户为在现有系统中维护密码和支持丢失的密码而花费多少精力时,你应该看到移动到此全新的用于身份验证的 Windows Hello 系统的优势。
我们为你保留了有关如何在服务和服务器端实现身份验证的详细信息作为练习。 预计你们中的大多数的现有系统都需要迁移才可以开始使用 Windows Hello,并且每个系统的详细信息都各不相同。
相关主题
反馈
https://aka.ms/ContentUserFeedback。
即将发布:在整个 2024 年,我们将逐步淘汰作为内容反馈机制的“GitHub 问题”,并将其取代为新的反馈系统。 有关详细信息,请参阅:提交和查看相关反馈