建立 Windows Hello 登入服務
這是完整演練的第 2 部分,介紹如何在 Windows 10 和 Windows 11 UWP (通用 Windows 平台) 應用程式中使用 Windows Hello 作為傳統使用者名稱和密碼驗證系統的替代方案。 本文繼續第 1 部分 Windows Hello 登入應用程式的內容,並擴展了功能以演示如何將 Windows Hello 整合到現有應用程式中。
若要建置此專案,您需要具備 C# 和 XAML 經驗。 您還需要在 Windows 10 或 Windows 11 電腦上使用 Visual Studio 2015 (社群版或更高版本)。
練習 1:伺服器端邏輯
在本練習中,您將從第一個實驗室內建的 Windows Hello 應用程式開始,並建立本機模擬伺服器和資料庫。 這個實作教室的設計目的是要教導 Windows Hello 如何整合到現有的系統中。 藉由使用模擬伺服器和模擬資料庫,會消除許多不相關的設定。 在您自己的應用程式中,您必須將模擬物件取代為實際的服務和資料庫。
若要開始,請從第一個 Passport Hands On Lab 開啟 PassportLogin 解決方案。
您一開始會實作模擬伺服器和模擬資料庫。 建立一個名為「AuthService」的新資料夾。 在方案總管中,右鍵點擊解決方案「PassportLogin (Universal Windows)」,然後選擇新增 > 資料夾。
建立 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 清單將包含裝置 ID、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 的模型後,您需要在 AuthService 中建立另一個新類別來充當模擬資料庫。 因為這是一個模擬資料庫,您將在其中儲存和載入本機使用者帳戶清單。 在現實世界中,這將是您的資料庫實作。 在 AuthService 中建立一個名為「MockStore.cs」的新類別。 將類別定義變更為 public。
由於模擬儲存將在本地保存和載入使用者帳戶列表,因此您可以使用 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(); } }
現在可以儲存和載入模擬儲存中的使用者帳戶清單。 應用程式的其他部分需要存取此列表,因此需要一些方法來檢索此資料。 在 InitializeSampleUserAccounts 方法下,新增以下 get 方法。 它們將允許您獲取使用者 ID、單一使用者、特定 Windows Hello 裝置的使用者列表,以及取得特定裝置上使用者的公鑰。
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」的新類別。 將類別定義變更為 public 並新增單例執行個體模式以確保只建立一個執行個體。
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 物件中的新增、刪除和更新護照詳細資訊方法。 在 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)); }
更新登入頁面介面以要求輸入護照。 此實作教室示範如何移轉現有系統以使用 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。 變更 OnNavigedTo 事件以將類型轉換為 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(); } } } } }
由於登入頁面使用使用者帳戶對象而不是先前的帳戶對象,因此需要更新 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 變數。 然後更新 OnNavigedTo 方法以使用 AuthService 並擷取目前帳戶的資訊。 取得帳戶資訊後,您可以設定清單的項目來源以顯示裝置。 您需要新增對 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 中實作「忘記裝置」點擊事件。 這將使用裝置清單中選定的裝置,並使用護照助理來呼叫刪除裝置。
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 擷取目前裝置的所有使用者帳戶。 目前,您無法將裝置 ID 傳遞給 AuthService,以便它可以傳回該裝置的使用者帳戶。 在 Utils 資料夾中,建立名為 「Helpers.cs」 的新類別。 將類別定義變更為公共靜態,然後新增以下方法以允許您檢索目前裝置識別碼。
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,然後嘗試更新其護照詳細資料。 如果護照無法建立護照金鑰,則該帳戶將因註冊過程失敗而被刪除。
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 時,護照資訊不會推送到 AuthService。
要將護照資訊取得到 AuthService,需要更新 MicrosoftPassportHelper.cs。 在 CreatePassportKeyAsync 方法中,您需要呼叫一個新方法來嘗試取得 KeyAttestation,而不是只在成功時傳回 true。 雖然本實作教室並未在 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。
建置並執行應用程式,並像以前一樣使用預設憑證登入。 在歡迎畫面上,您現在將看到顯示裝置識別碼。 如果您在其他裝置上登錄,該裝置也會顯示在此 (如果您有雲端託管的身分驗證服務)。 對於本實作教室,將顯示實際的裝置識別碼。 在實際的實作中,您可能希望顯示一個人們可以理解並用來確定每個裝置的友善名稱。
-
- 要完成此實作教室,您需要在使用者從使用者選擇頁面進行選擇並重新登入時向他們發出請求和挑戰。 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 類別的需求。 刪除 Account 類別、Models 資料夾以及 Utils 資料夾中的 AccountHelper 類別。 在成功建立解決方案之前,您需要刪除整個應用程式中對模型命名空間的所有參考。
建置並執行應用程式,並享受使用具有模擬服務和資料庫的 Windows Hello。
在此實作教室中,您了解如何使用 Windows Hello API 來取代從 Windows 10 或 Windows 11 電腦進行驗證時對密碼的需求。 當您考慮到人們在現有系統中維護密碼和支援遺失密碼花費了多少精力時,您應該會看到遷移到這個新的 Windows Hello 身分驗證系統的好處。
我們為您留下瞭如何在服務和伺服器端實現身分驗證的詳細資訊作為練習。 預計大多數人都擁有需要遷移才能開始使用 Windows Hello 的現有系統,並且每個系統的詳細資訊會有所不同。
相關主題
意見反應
https://aka.ms/ContentUserFeedback。
即將登場:在 2024 年,我們將逐步淘汰 GitHub 問題作為內容的意見反應機制,並將它取代為新的意見反應系統。 如需詳細資訊,請參閱:提交並檢視相關的意見反應