Blazor MAUI客户端访问Identity Server登录

VS2022.NET 6正式版即将发布,带来了Blazor MAUI客户端项目类型,支持在桌面和移动客户端中采用Html创建页面,绑定后台数据,调用后台服务。为什么要用Blazor MAUI呢?因为Html生态圈比Xaml好太多了,MAUI虽然跨平台,但是生态圈太弱了。而且作为.NET开发团队,如果要同时进行服务器的网页客户端和移动、桌面客户端开发,能够统一成一种前端框架最好,网页客户端只能用Html,没法用MAUI,所以最好还是统一到Html框架。

创建.NET MAUI Blazor App项目

基于之前编写的DEMO继续。

https://www.cnblogs.com/sunnytrudeau/p/15365125.html

安装VS2022预览版要添加MAUI相关的工作负载,以及Single-project MSIX extension扩展。

新建BlaMauiApp项目,采用.NET MAUI Blazor App模板。

 

这个项目csproj文件默认是移动平台

<TargetFrameworks>net6.0-ios;net6.0-android;net6.0-maccatalyst</TargetFrameworks>

<!-- <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows')) and '$(MSBuildRuntimeType)' == 'Full'">$(TargetFrameworks);net6.0-windows10.0.19041</TargetFrameworks> -->

参考目前最新的官方博客,把TargetFrameworks 改为Windows平台,便于调试运行。

Announcing .NET MAUI Preview 9 - .NET Blog (microsoft.com)

Get Started Today

First thing’s first, install Visual Studio 2022 Preview 5 and check .NET MAUI (preview) under the Mobile Development with .NET workload, and check the Universal Windows Platform development workload.

Now, install the Windows App SDK Single-project MSIX extension. Before running the Windows target, remember to uncomment the framework in the csproj file.

修改后,根据提示重新加载项目

<!--<TargetFrameworks>net6.0-ios;net6.0-android;net6.0-maccatalyst</TargetFrameworks>-->

 <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows')) and '$(MSBuildRuntimeType)' == 'Full'">$(TargetFrameworks);net6.0-windows10.0.19041</TargetFrameworks>

然后编译、运行一下BlaMauiApp项目,它跟一个Blazor Server网站很相似。但是它毕竟是一个桌面客户端软件,现在还不清楚怎么用cookiesAuthorizeView组件这些,我就把它当做一个WPF桌面客户端去用,只是界面Xaml变成了Html

添加访问Identity Server登录功能

复制Ids4Client手机验证码登录功能模块到本项目使用。

因为不使用cookies了,所以要自定义登录用户信息数据类和管理者。用户登录后,保存用户信息到本地文件。注意当前工作目录Environment.CurrentDirectory是系统目录,没有写权限,可以用当前可执行文件所在的目录AppContext.BaseDirectory。

/// <summary>
    /// 登录用户信息
    /// </summary>
    public class LoginUserInfo
    {
        /// <summary>
        /// 从Identity Server获取的token结果
        /// </summary>
        public string AccessToken { get; set; }
        public string RefreshToken { get; set; }
        public DateTimeOffset ExpiresIn { get; set; } = DateTimeOffset.MinValue;

        /// <summary>
        /// 从Identity Server获取的用户信息
        /// </summary>
        public string UserId { get; set; }
        public string Username { get; set; }
        public string UserRole { get; set; }

        public override string ToString() => string.IsNullOrWhiteSpace(Username) ? "没有登录用户" : $"用户[{Username}], 有效期[{ExpiresIn}]";

    }

/// <summary>
    /// 登录用户管理器
    /// </summary>
    public class LoginUserManager
    {
        /// <summary>
        /// 登录用户信息
        /// </summary>
        public LoginUserInfo UserInfo { get; private set; }

        /// <summary>
        /// 登录用户信息文件名
        /// </summary>
        public const string UserInfoFilename = "userinfo.json";

        private readonly string UserInfoFilePath;

        public LoginUserManager()
        {
            //默认文件目录是C:Windowssystem32,报错
            //System.UnauthorizedAccessException
            //Access to the path 'C:Windowssystem32userinfo.json' is denied.
            //Environment.CurrentDirectory=C:Windowssystem32
            UserInfoFilePath = Path.Combine(AppContext.BaseDirectory, UserInfoFilename);

            if (File.Exists(UserInfoFilePath))
            {
                //如果已经存在登录用户文件,加载登录用户信息
                string userInfoJson = File.ReadAllText(UserInfoFilePath);

                UserInfo = JsonConvert.DeserializeObject<LoginUserInfo>(userInfoJson);

                if (UserInfo.ExpiresIn < DateTimeOffset.Now)
                {
                    //如果登录信息已经过期,清除登录用户信息
                    UserInfo = new LoginUserInfo();

                    //删除登录用户信息文件
                    File.Delete(UserInfoFilePath);
                }
            }
            else
            {
                //如果没有登录用户文件,新建登录用户信息
                UserInfo = new LoginUserInfo();
            }

            //Console.WriteLine输出看不到?
            Debug.WriteLine($"{DateTimeOffset.Now}, 初始化登录用户信息: {UserInfo}");
        }

        /// <summary>
        /// 用户是否已经登录?
        /// </summary>
        public bool IsAuthenticated => !string.IsNullOrWhiteSpace(UserInfo.Username);

        /// <summary>
        /// 登录,提取登录用户信息,并保存到文件
        /// </summary>
        public void Login(TokenResponse tokenResponse, string userInfoJson)
        {
            //提取从Identity Server获取的token结果
            UserInfo.AccessToken = tokenResponse.AccessToken;
            UserInfo.RefreshToken = tokenResponse.RefreshToken;
            UserInfo.ExpiresIn = DateTimeOffset.Now.AddSeconds(tokenResponse.ExpiresIn);

            dynamic userInfo = JsonConvert.DeserializeObject(userInfoJson);

            //从Identity Server获取的用户信息
            UserInfo.UserId = $"{userInfo.sub}";

            //提取name
            UserInfo.Username = $"{userInfo.name}";

            //提取角色
            //id4返回的角色是字符串数组或者字符串
            UserInfo.UserRole = $"{userInfo.role}";

            File.WriteAllText(UserInfoFilePath, JsonConvert.SerializeObject(UserInfo));

            Debug.WriteLine($"{DateTimeOffset.Now}, 用户登录: {UserInfo}");
        }

        /// <summary>
        /// 退出登录
        /// </summary>
        public void Logout()
        {
            string userName = UserInfo.Username;

            //清除登录用户信息
            UserInfo = new LoginUserInfo();

            //删除登录用户信息文件
            File.Delete(UserInfoFilePath);

            Debug.WriteLine($"{DateTimeOffset.Now}, 用户退出登录: {userName}");
        }
    }

注册服务到容器

MauiProgram提供了跟Asp.Net Core Web项目类似的服务容器注册框架,在这里可以注册自己的服务,非常熟悉的味道,客户端和服务端开发体验如此接近。

public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .RegisterBlazorMauiWebView()
                .UseMauiApp<App>()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                });

            builder.Services.AddBlazorWebView();
            builder.Services.AddSingleton<WeatherForecastService>();

            builder.Services.AddSingleton<LoginUserManager>();

            //NuGet安装Microsoft.Extensions.Http
            //访问Identity Server 4服务器的HttpClient
            builder.Services.AddHttpClient<Ids4Client>()
                .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://localhost:5000"));

            return builder.Build();
        }
    }

添加登录razor网页

PhoneCodeLogin.razor复制到本项目,稍微修改一下,主要登录成功后,不再进行MVC控制器跳转,而是调用LoginUserManager提取用户信息,保存到文件,然后直接跳转回主页。

@page "/phonecodelogin"

@using BlaMauiApp.Data

<div class="card" style="500px">

    <div class="card-header">
        <h5>
            手机验证码登录
        </h5>
    </div>

    <div class="card-body">

        <div class="form-group form-inline">
            <label for="PhoneNumber" class="control-label">手机号</label>
            <input id="PhoneNumber" @bind="PhoneNumber" class="form-control" placeholder="请输入手机号" />
        </div>

        <div class="form-group form-inline">
            <label for="VerificationCode" class="control-label">验证码</label>
            <input id="VerificationCode" @bind="VerificationCode" class="form-control" placeholder="请输入验证码" />
            @if (CanGetVerificationCode)
            {
                <button type="button" class="btn btn-link" @onclick="GetVerificationCode">
                    获取验证码
                </button>
            }
            else
            {
                <label>@GetVerificationCodeMsg</label>
            }
        </div>

    </div>

    <div class="card-footer">
        <button type="button" class="btn btn-primary" @onclick="Login">
            登录
        </button>
    </div>

</div>

@code {

    [Inject]
    private Ids4Client ids4Client { get; set; }

    [Inject]
    private NavigationManager navigationManager { get; set; }

    [Inject]
    private LoginUserManager loginUserManager { get; set; }

    private string PhoneNumber;

    private string VerificationCode;

    //获取验证码按钮当前状态
    private bool CanGetVerificationCode = true;

    private string GetVerificationCodeMsg;

    //获取验证码
    private async void GetVerificationCode()
    {
        if (CanGetVerificationCode)
        {
            //发送验证码到手机号
            string result = await ids4Client.SendPhoneCodeAsync(PhoneNumber);

            if (result != "发送验证码成功")
                return;

            CanGetVerificationCode = false;

            //1分钟倒计时
            for (int i = 60; i >= 0; i--)
            {
                GetVerificationCodeMsg = $"获取验证码({i})";

                await Task.Delay(1000);

                //通知页面更新
                StateHasChanged();
            }

            CanGetVerificationCode = true;

            //通知页面更新
            StateHasChanged();
        }
    }

    //登录
    private async void Login()
    {
        //手机验证码登录
        var (tokenResponse, userInfoJson) = await ids4Client.PhoneCodeLogin(PhoneNumber, VerificationCode);

        //登录
        loginUserManager.Login(tokenResponse, userInfoJson);

        //跳转回主页
        navigationManager.NavigateTo("/");
    }
}

在主页Index.razor显示登录用户信息

@page "/"
@using BlaMauiApp.Data
@inject LoginUserManager loginUserManager

<h1>Hello, world!</h1>

Welcome to your new app.

<SurveyPrompt Title="How is Blazor working for you?" />

@if (isAuthenticated)
{
    <p>您已经登录</p>

    <div class="card">
        <div class="card-header">
            <h2>用户信息</h2>
        </div>
        <div class="card-body">
            <dl>
                <dt>AccessToken</dt>
                <dd>@userInfo.AccessToken</dd>
                <dt>RefreshToken</dt>
                <dd>@userInfo.RefreshToken</dd>
                <dt>ExpiresIn</dt>
                <dd>@userInfo.ExpiresIn</dd>
                <dt>UserId</dt>
                <dd>@userInfo.UserId</dd>
                <dt>Username</dt>
                <dd>@userInfo.Username</dd>
                <dt>UserRole</dt>
                <dd>@userInfo.UserRole</dd>
            </dl>
        </div>
    </div>

    <button class="nav-link" @onclick="Logout">退出登录</button>
}
else
{
    <p>您还没有登录,请先登录</p>
    <a class="nav-link" href="phonecodelogin">登录</a>
}

@code {
    private bool isAuthenticated => loginUserManager.IsAuthenticated;
    private LoginUserInfo userInfo => loginUserManager.UserInfo;

    private void Logout()
    {
        loginUserManager.Logout();

        StateHasChanged();
    }

}

测试

然后可以测试一下,同时运行AspNetId4Web认证服务器和BlaMauiApp客户端项目,输入种子数据的手机号获取验证码,一切跟Blazor Server项目极其相似,代码高度共享,非常滑溜。

 

 

切换到安卓项目

注意要在安装VS2022时勾选了桌面应用和移动应用工作负载,并且在安卓SDK管理器安装API 31

 

安装JDK 11,参考官网介绍:

.NET MAUI installation - .NET MAUI | Microsoft Docs

.NET MAUI requires the Android 12 (API 31) SDK for development. Install the following items:

Microsoft Build of OpenJDK

While Visual Studio installs a version of Microsoft OpenJDK, you need to install Microsoft OpenJDK 11, available from the OpenJDK page. When installing OpenJDK 11, use the default installation configuration settings. After installing OpenJDK 11, Visual Studio should automatically consume it. However, if it doesn't, set the path to the OpenJDK install in the Tools > Options > Xamarin > Android Settings > Java Development Kit Location field.

在这里可以下载Win10jdk 11的安装包。

https://aka.ms/download-jdk/microsoft-jdk-11.0.12.7.1-windows-x64.msi

装完看一下VS2022的配置,默认已经是最新的

 

BlaMauiApp项目csproj文件的平台改为安卓,保存csproj文件,根据提示重新加载项目,还要关闭VS2022重新打开项目,在运行菜单才能看到安卓模拟器。

<TargetFrameworks>net6.0-android</TargetFrameworks>

安卓客户端默认策略是不允许访问http网站的,会报错Cleartext HTTP traffic to localhost not permitted,如果改为https仍然会报错java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,为了省事就修改安卓AndroidManifest.xml文件,添加android:usesCleartextTraffic="true",允许访问http网站。安卓模拟器访问宿主机的IP10.0.2.2,不是127.0.0.1

            //访问Identity Server 4服务器的HttpClient
            builder.Services.AddHttpClient<Ids4Client>()
                //.ConfigureHttpClient(c => c.BaseAddress = new Uri("http://localhost:5000"));//Windows调试
                .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://10.0.2.2:5000"));//安卓模拟器,AndroidManifest.xml要添加android:usesCleartextTraffic="true"支持访问http网站

用户信息文件也不能保存在当前运行目录下面了,报错Read-only file system : '/userinfo.json'

            //FileSystem.AppDataDirectory=/data/user/0/com.companyname.BlaMauiApp/files/userinfo.json
            UserInfoFilePath = Path.Combine(FileSystem.AppDataDirectory, UserInfoFilename);

同时运行Identity Server认证服务器项目和BlaMauiApp项目,可以实现登录功能,效果类似桌面客户端。

 

DEMO代码地址:https://gitee.com/woodsun/blzid4

问题

我想测试一下Zxing扫码,它依赖了Xamarin.Forms类库,编译报错,这个问题影响很大,很多主流类库都要升级才能使用,要等待整个生态圈逐渐成熟。

安卓项目呈现的效果也是网站,主流APP都是底部导航风格,虽然用Html组件也可以搞APP风格,但是毕竟要写很多代码,希望尽快能有Xamarin.Forms Shell这样的APP模板。

还有登录状态的管理问题,对于Web项目来说已经高度成熟,但是客户端不合适用cookiesHttpContext,这些是项目的基础设施,希望有高集成度的解决方案。

回顾与展望

现在.NET MAUI Blazor仍然处于预览状态,官方宣称2022年第二季度发布,但愿别延期了,不然真的黄花菜都凉了。

微软当年凭借WindowsIE垄断地位强推私有标准的XamlSilverlight等前端技术,结果被开放标准的HTML5和开源库生态圈打得一败涂地,也让.NET平台在云计算和移动互联网时代沦落到第三世界的处境。后来微软在来自第三世界的印度CEO领导下,走上了改革开放的道路,开源了NET core,服务端各种技术搞得风生水起,但是客户端WinformWPFUWP各有各的毛病,没有一个能跟Html生态圈对打的。现在MAUI Blazor把客户端带入了Html大家庭,这也是正确的选择,打不过你就加入你。希望这个技术能蓬勃发展,让.NET开发者早日用上Html生态圈各种成熟组件,提高开发效率。这么多年说来说去,可视化、跨平台、开源、一次编写到处运行,说到底还是为了提高效率,.NET技术栈的优势就是要提高生产力,不然凭什么立足呢。

原文地址:https://www.cnblogs.com/sunnytrudeau/p/15440622.html