教程:对 WPF 桌面应用程序的用户进行身份验证

适用于:带灰色 X 号的白色圆圈。 员工租户 带白色勾号的绿色圆圈。 外部租户(了解详细信息

本教程演示如何生成 Windows 演示文稿窗体(WPF)桌面应用,并准备使用 Microsoft Entra 管理中心进行身份验证。

在本教程中,你将:

  • 配置 WPF 桌面应用以使用其应用注册详细信息。
  • 构建可以登录用户并代表用户获取令牌的桌面应用。

先决条件

创建 WPF 桌面应用程序

  1. 打开终端并导航到希望项目所在的文件夹。

  2. 初始化 WPF 桌面应用并导航到其根文件夹。

    dotnet new wpf --language "C#" --name sign-in-dotnet-wpf
    cd sign-in-dotnet-wpf
    

安装软件包

安装配置提供程序,帮助我们的应用从应用设置文件中的键值对读取配置数据。 使用这些配置抽象可将配置值绑定到 .NET 对象的实例。

dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.Binder

安装 Microsoft 身份验证库 (MSAL),其中包含获取令牌所需的所有关键组件。 还可以安装 MSAL 代理库,以处理与桌面身份验证代理的交互。

dotnet add package Microsoft.Identity.Client
dotnet add package Microsoft.Identity.Client.Broker

创建 appsettings.json 文件,并添加注册配置

  1. 在应用的根文件夹中创建 appsettings.json 文件。

  2. 将应用注册详细信息添加到 appsettings.json 文件中。

    {
        "AzureAd": {
            "Authority": "https://<Enter_the_Tenant_Subdomain_Here>.ciamlogin.com/",
            "ClientId": "<Enter_the_Application_Id_Here>"
        }
    }
    
    • Enter_the_Tenant_Subdomain_Here 替换为目录(租户)子域。
    • Enter_the_Application_Id_Here 替换为之前注册的应用的应用程序(客户端)ID。
  3. 创建应用设置文件后,我们将创建另一个名为 AzureAdConfig.cs 的文件,它将帮助你从应用设置文件读取配置。 在应用的根文件夹中创建 AzureAdConfig.cs 文件。

  4. AzureAdConfig.js 文件中,定义 ClientIdAuthority 属性的获取器和设置器。 添加以下代码:

    namespace sign_in_dotnet_wpf
    {
        public class AzureAdConfig
        {
            public string Authority { get; set; }
            public string ClientId { get; set; }
        }
    }
    

使用自定义 URL 域(可选)

使用自定义域对身份验证 URL 进行完全品牌化。 从用户的角度来看,用户在身份验证过程中仍然停留在您的域名上,而不会被重定向到 ciamlogin.com 域名。

按照以下步骤使用自定义域:

  1. 使用启用外部租户中应用的自定义 URL 域名中的步骤,为您的外部租户启用自定义 URL 域名。

  2. 打开 appsettings.json 文件:

    1. 将属性的值 Authority 更新为 https://Enter_the_Custom_Domain_Here/Enter_the_Tenant_ID_Here. 将 Enter_the_Custom_Domain_Here 替换为您的自定义 URL 域,并将 Enter_the_Tenant_ID_Here 替换为您的租户 ID。 如果没有租户 ID,请了解如何读取租户详细信息
    2. 添加knownAuthorities属性,并使用值 [Enter_the_Custom_Domain_Here]

appsettings.json 文件进行更改后,如果自定义 URL 域 login.contoso.com,并且租户 ID 为 aaaabb-0000-cccc-1111-dd222eeee,则文件应类似于以下代码片段:

{
    "AzureAd": {
        "Authority": "https://login.contoso.com/aaaabbbb-0000-cccc-1111-dddd2222eeee",
        "ClientId": "Enter_the_Application_Id_Here",
        "KnownAuthorities": ["login.contoso.com"]
    }
}

修改项目文件

  1. 导航到应用根文件夹中的 sign-in-dotnet-wpf.csproj 文件。

  2. 在此文件中,执行以下两个步骤:

    1. 修改 sign-in-dotnet-wpf.csproj 文件以指示应用在编译项目时将 appsettings.json 文件复制到输出目录。 将以下代码片段添加到 sign-in-dotnet-wpf.csproj 文件:
    2. 将目标框架设置为面向 windows10.0.19041.0 版本,以帮助从令牌缓存读取缓存的令牌,如令牌缓存帮助程序类中所示。
    <Project Sdk="Microsoft.NET.Sdk">
    
        ...
    
        <!-- Set target framework to target windows10.0.19041.0 build -->
        <PropertyGroup>
            <OutputType>WinExe</OutputType>
            <TargetFramework>net7.0-windows10.0.19041.0</TargetFramework> <!-- target framework -->
            <RootNamespace>sign_in_dotnet_wpf</RootNamespace>
            <Nullable>enable</Nullable>
            <UseWPF>true</UseWPF>
        </PropertyGroup>
    
        <!-- Copy appsettings.json file to output folder. -->
        <ItemGroup>
            <None Remove="appsettings.json" />
        </ItemGroup>
    
        <ItemGroup>
            <EmbeddedResource Include="appsettings.json">
                <CopyToOutputDirectory>Always</CopyToOutputDirectory>
            </EmbeddedResource>
        </ItemGroup>
    </Project>
    

创建令牌缓存辅助类

创建用于初始化令牌缓存的令牌缓存帮助程序类。 应用程序在尝试获取新令牌之前,将会尝试从缓存中读取令牌。 如果在缓存中找不到令牌,则应用程序将获取新令牌。 注销后,将会清除缓存中的所有帐户和所有相应的访问令牌。

  1. 在应用的根文件夹中创建 TokenCacheHelper.cs 文件。

  2. 打开 TokenCacheHelper.cs 文件。 将包和命名空间添加到文件。 在以下步骤中,将会通过将相关逻辑添加到 TokenCacheHelper 类来使用代码逻辑填充此文件。

    using System.IO;
    using System.Security.Cryptography;
    using Microsoft.Identity.Client;
    
    namespace sign_in_dotnet_wpf
    {
        static class TokenCacheHelper{}
    }
    
  3. 将构造函数添加到定义缓存文件路径的 TokenCacheHelper 类。 对于打包的桌面应用(MSIX 包,也称为桌面网桥),执行程序集文件夹是只读的。 在这种情况下,我们需要使用 Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path + "\msalcache.bin",它是打包应用的按应用读/写文件夹。

    namespace sign_in_dotnet_wpf
    {
        static class TokenCacheHelper
        {
            static TokenCacheHelper()
            {
                try
                {
                    CacheFilePath = Path.Combine(Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path, ".msalcache.bin3");
                }
                catch (System.InvalidOperationException)
                {
                    CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin3";
                }
            }
            public static string CacheFilePath { get; private set; }
            private static readonly object FileLock = new object();
        }
    }
    
    
  4. 请添加代码来处理令牌缓存序列化。 ITokenCache 接口可实现对缓存操作的公共访问。 ITokenCache 接口包含用于订阅缓存序列化事件的方法,而接口 ITokenCacheSerializer 则会公开需要在缓存序列化事件中使用的方法,以对缓存进行序列化/反序列化。 TokenCacheNotificationArgs 包含访问缓存时由 Microsoft.Identity.Client (MSAL) 调用所使用的参数。 ITokenCacheSerializer 接口在 TokenCacheNotificationArgs 回调中可用。

    将以下代码添加到 TokenCacheHelper 类:

        static class TokenCacheHelper
        {
            static TokenCacheHelper()
            {...}
            public static string CacheFilePath { get; private set; }
            private static readonly object FileLock = new object();
    
            public static void BeforeAccessNotification(TokenCacheNotificationArgs args)
            {
                lock (FileLock)
                {
                    args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
                            ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
                                                     null,
                                                     DataProtectionScope.CurrentUser)
                            : null);
                }
            }
    
            public static void AfterAccessNotification(TokenCacheNotificationArgs args)
            {
                if (args.HasStateChanged)
                {
                    lock (FileLock)
                    {
                        File.WriteAllBytes(CacheFilePath,
                                           ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
                                                                 null,
                                                                 DataProtectionScope.CurrentUser)
                                          );
                    }
                }
            }
        }
    
        internal static void EnableSerialization(ITokenCache tokenCache)
        {
            tokenCache.SetBeforeAccess(BeforeAccessNotification);
            tokenCache.SetAfterAccess(AfterAccessNotification);
        }
    

    BeforeAccessNotification 方法中,可从文件系统读取缓存,如果缓存不为空,则将其反序列化并加载。 在 AfterAccessNotification (MSAL) 访问缓存后,将会调用 Microsoft.Identity.Client 方法。 如果缓存已更改,则对其进行序列化,并将更改保存到缓存中。

    EnableSerialization 包含 ITokenCache.SetBeforeAccess()ITokenCache.SetAfterAccess() 方法:

    • ITokenCache.SetBeforeAccess() 可将委托设置为在任何库方法访问缓存之前收到通知。 通过此操作,代理可以选择反序列化在 TokenCacheNotificationArgs 中指定的应用程序和帐户的缓存条目。
    • ITokenCache.SetAfterAccess() 可将委托设置为在任何库方法访问缓存之后收到通知。 通过此操作,委托可以选择序列化在 TokenCacheNotificationArgs 中指定的应用程序和帐户的缓存条目。

创建 WPF 桌面应用 UI

修改 MainWindow.xaml 文件以添加应用的 UI 元素。 打开应用根文件夹中的 MainWindow.xaml 文件,并添加带有 <Grid></Grid> 控件部分的以下代码段。

    <StackPanel Background="Azure">
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
            <Button x:Name="SignInButton" Content="Sign-In" HorizontalAlignment="Right" Padding="5" Click="SignInButton_Click" Margin="5" FontFamily="Segoe Ui"/>
            <Button x:Name="SignOutButton" Content="Sign-Out" HorizontalAlignment="Right" Padding="5" Click="SignOutButton_Click" Margin="5" Visibility="Collapsed" FontFamily="Segoe Ui"/>
        </StackPanel>
        <Label Content="Authentication Result" Margin="0,0,0,-5" FontFamily="Segoe Ui" />
        <TextBox x:Name="ResultText" TextWrapping="Wrap" MinHeight="120" Margin="5" FontFamily="Segoe Ui"/>
        <Label Content="Token Info" Margin="0,0,0,-5" FontFamily="Segoe Ui" />
        <TextBox x:Name="TokenInfoText" TextWrapping="Wrap" MinHeight="70" Margin="5" FontFamily="Segoe Ui"/>
    </StackPanel>

此代码会添加关键 UI 元素。 处理 UI 元素功能的方法和对象在 MainWindow.xaml.cs 文件中定义,我们将在下一步中创建该文件。

  • 用于登录用户的按钮。 当用户选择此按钮后,将会调用 SignInButton_Click 方法。
  • 用于注销用户的按钮。 当用户选择此按钮后,将会调用 SignOutButton_Click 方法。
  • 一个文本框,用于在用户尝试登录后显示身份验证结果详细信息。 此处显示的信息由 ResultText 对象返回。
  • 一个文本框,用于在用户成功登录后显示令牌详细信息。 此处显示的信息由 TokenInfoText 对象返回。

将代码添加到 MainWindow.xaml.cs 文件

MainWindow.xaml.cs 文件包含为 MainWindow.xaml 文件中 UI 元素的行为提供运行时逻辑的代码。

  1. 打开应用根文件夹中的 MainWindow.xaml.cs 文件。

  2. 在文件中添加以下代码以导入包,并为我们创建的方法定义占位符。

    using Microsoft.Identity.Client;
    using System;
    using System.Linq;
    using System.Windows;
    using System.Windows.Interop;
    
    namespace sign_in_dotnet_wpf
    {
        public partial class MainWindow : Window
        {
            string[] scopes = new string[] { };
    
            public MainWindow()
            {
                InitializeComponent();
            }
    
            private async void SignInButton_Click(object sender, RoutedEventArgs e){...}
    
            private async void SignOutButton_Click(object sender, RoutedEventArgs e){...}
    
            private void DisplayBasicTokenInfo(AuthenticationResult authResult){...}
        }
    }
    
  3. 将以下代码添加到 SignInButton_Click 方法中。 当用户选择“登录”按钮时,将会调用此方法。

    private async void SignInButton_Click(object sender, RoutedEventArgs e)
    {
        AuthenticationResult authResult = null;
        var app = App.PublicClientApp;
    
        ResultText.Text = string.Empty;
        TokenInfoText.Text = string.Empty;
    
        IAccount firstAccount;
    
        var accounts = await app.GetAccountsAsync();
        firstAccount = accounts.FirstOrDefault();
    
        try
        {
            authResult = await app.AcquireTokenSilent(scopes, firstAccount)
                    .ExecuteAsync();
        }
        catch (MsalUiRequiredException ex)
        {
            try
            {
                authResult = await app.AcquireTokenInteractive(scopes)
                    .WithAccount(firstAccount)
                    .WithParentActivityOrWindow(new WindowInteropHelper(this).Handle) 
                    .WithPrompt(Prompt.SelectAccount)
                    .ExecuteAsync();
            }
            catch (MsalException msalex)
            {
                ResultText.Text = $"Error Acquiring Token:{System.Environment.NewLine}{msalex}";
            }
            catch (Exception ex)
            {
                ResultText.Text = $"Error Acquiring Token Silently:{System.Environment.NewLine}{ex}";
                return;
            }
    
            if (authResult != null)
            {
                ResultText.Text = "Sign in was successful.";
                DisplayBasicTokenInfo(authResult);
                this.SignInButton.Visibility = Visibility.Collapsed;
                this.SignOutButton.Visibility = Visibility.Visible;
            }
        }
    }
    

    GetAccountsAsync() 返回用户令牌缓存中该应用程序的所有可用帐户。 IAccount 接口表示单个帐户的相关信息。

    为了获取令牌,应用会尝试使用 AcquireTokenSilent 方法以无提示方式获取令牌,以验证缓存中是否存在可接受的令牌。 例如,方法 AcquireTokenSilent 可能会失败,因为用户已注销。当 MSAL 检测到问题可以通过需要交互式作来解决时,它会引发异常 MsalUiRequiredException 。 此异常会导致应用以交互方式获取令牌。

    调用 AcquireTokenInteractive 方法将出现提示用户进行登录的窗口。 当用户首次需要进行身份验证时,应用通常会要求用户进行交互式登录。 在执行用户获取令牌的无提示操作时,他们也可能需要登录。 首次执行 AcquireTokenInteractive 后,AcquireTokenSilent 会成为获取令牌的常用方法

  4. 将以下代码添加到 SignOutButton_Click 方法中。 当用户选择“退出登录”按钮时,将会调用此方法。

    private async void SignOutButton_Click(object sender, RoutedEventArgs e)
    {
        var accounts = await App.PublicClientApp.GetAccountsAsync();
        if (accounts.Any())
        {
            try
            {
                await App.PublicClientApp.RemoveAsync(accounts.FirstOrDefault());
                this.ResultText.Text = "User has signed-out";
                this.TokenInfoText.Text = string.Empty;
                this.SignInButton.Visibility = Visibility.Visible;
                this.SignOutButton.Visibility = Visibility.Collapsed;
            }
            catch (MsalException ex)
            {
                ResultText.Text = $"Error signing-out user: {ex.Message}";
            }
        }
    }
    

    方法 SignOutButton_Click 会清除缓存中的所有帐户和所有相应的访问令牌。 用户下次尝试登录时,将必须以交互方式登录。

  5. 将以下代码添加到 DisplayBasicTokenInfo 方法中。 此方法可显示有关令牌的基本信息。

    private void DisplayBasicTokenInfo(AuthenticationResult authResult)
    {
        TokenInfoText.Text = "";
        if (authResult != null)
        {
            TokenInfoText.Text += $"Username: {authResult.Account.Username}" + Environment.NewLine;
            TokenInfoText.Text += $"{authResult.Account.HomeAccountId}" + Environment.NewLine;
        }
    }
    

将代码添加到 App.xaml.cs 文件

App.xaml 是你声明应用中所使用资源的位置。 它是应用的入口点。 App.xaml.csApp.xaml 的后台代码文件。 App.xaml.cs 还定义了应用程序的启动窗口。

打开应用根文件夹中的 App.xaml.cs 文件,然后将以下代码添加到该文件中。

using System.Windows;
using System.Reflection;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Broker;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

namespace sign_in_dotnet_wpf
{
    public partial class App : Application
    {
        static App()
        {
            CreateApplication();
        }

        public static void CreateApplication()
        {
            var assembly = Assembly.GetExecutingAssembly();
            using var stream = assembly.GetManifestResourceStream("sign_in_dotnet_wpf.appsettings.json");
            AppConfiguration = new ConfigurationBuilder()
                .AddJsonStream(stream)
                .Build();

            AzureAdConfig azureADConfig = AppConfiguration.GetSection("AzureAd").Get<AzureAdConfig>();

            var builder = PublicClientApplicationBuilder.Create(azureADConfig.ClientId)
                .WithAuthority(azureADConfig.Authority)
                .WithDefaultRedirectUri();

            _clientApp = builder.Build();
            TokenCacheHelper.EnableSerialization(_clientApp.UserTokenCache);
        }

        private static IPublicClientApplication _clientApp;
        private static IConfiguration AppConfiguration;
        public static IPublicClientApplication PublicClientApp { get { return _clientApp; } }
    }
}

在此步骤中,将会加载 appsettings.json 文件。 配置生成器可帮助读取 appsettings.json 文件中定义的应用配置。 由于 WPF 应用属于桌面应用,因此还需将它定义为公共客户端应用。 方法 TokenCacheHelper.EnableSerialization 会启用令牌缓存序列化。

运行应用

运行应用并登录以测试应用程序

  1. 在终端中,导航到 WPF 应用的根文件夹,并通过在终端中运行命令 dotnet run 来运行应用。

  2. 启动示例后,应会看到一个带有“登录”按钮的窗口。 选择该“登录”按钮。

    WPF 桌面应用程序登录屏幕的屏幕截图。

  3. 在登录页上,输入帐户电子邮件地址。 如果没有帐户,请选择“无帐户? 创建一个”,以启动注册流。 按照此流程创建新帐户并登录。

  4. 登录后,你将看到一个屏幕,显示登录成功以及存储在检索到的令牌中的用户帐户的基本信息。 基本信息显示在登录屏幕的 “令牌信息 ”部分中

另请参阅