MAUI Blazor系列目录
- MAUI Blazor学习1-移动客户端Shell布局 – SunnyTrudeau – 博客园 (cnblogs.com)
- MAUI Blazor学习2-创建移动客户端Razor页面 – SunnyTrudeau – 博客园 (cnblogs.com)
- MAUI Blazor学习3-绘制ECharts图表 – SunnyTrudeau – 博客园 (cnblogs.com)
- MAUI Blazor学习4-绘制BootstrapBlazor.Chart图表 – SunnyTrudeau – 博客园 (cnblogs.com)
- MAUI Blazor学习5-BLE低功耗蓝牙 – SunnyTrudeau – 博客园 (cnblogs.com)
- MAUI Blazor学习6-扫描二维码 – SunnyTrudeau – 博客园 (cnblogs.com)
登录是APP的基本功能,采用Identity Server 4认证服务器,Blazor Server可以简单配置一下oidc参数,即可跳转到id4服务器登录。APP可以在登录页面填写用户名和密码,发送到id4认证服务器。MAUI Blazor可以用这种方式实现登录功能。
2021年11月,在MAUI Blazor还在预览版的时候,我写了一个DEMO,实现了访问Id4服务器登录功能。现在把APP的代码直接搬到正式版,也是没问题的。
Blazor MAUI客户端访问Identity Server登录 – SunnyTrudeau – 博客园 (cnblogs.com)
DEMO代码地址:https://gitee.com/woodsun/blzid4
id4认证服务端支持手机号验证码登录方案
沿用2021年DEMO的id4服务器,把AspNetId4Web项目复制到本解决方案。
回顾一下方案。config自定义一个PhoneCodeGrantType认证类型,通过手机号和验证码,返回token。
D:\Software\gitee\blzid4\BlzId4Web\AspNetId4Web\Config.cs new Client() { ClientId="PhoneCode", ClientName = "PhoneCode", ClientSecrets=new []{new Secret("PhoneCode.Secret".Sha256())}, AllowedGrantTypes = new string[]{ "PhoneCodeGrantType" }, //效果等同客户端项目配置options.GetClaimsFromUserInfoEndpoint = true //AlwaysIncludeUserClaimsInIdToken = true, AllowedScopes = { "openid", "profile", "scope1", "role", } },
自定义手机验证码认证处理器。
D:\Software\gitee\blzid4\BlzId4Web\AspNetId4Web\PhoneCodeGrantValidator.cs /// <summary> /// 自定义手机验证码认证处理器 /// </summary> public class PhoneCodeGrantValidator : IExtensionGrantValidator { /// <summary> /// 认证方式 /// </summary> public string GrantType => "PhoneCodeGrantType"; private readonly IMemoryCache _memoryCache; private readonly ApplicationDbContext _context; private readonly ILogger _logger; public PhoneCodeGrantValidator( IMemoryCache memoryCache, ApplicationDbContext context, ILogger<PhoneCodeGrantValidator> logger) { _memoryCache = memoryCache; _context = context; _logger = logger; } /// <summary> /// 验证自定义授权请求 /// </summary> /// <param name="context"></param> /// <returns></returns> public async Task ValidateAsync(ExtensionGrantValidationContext context) { try { //获取登录参数 string phoneNumber = context.Request.Raw["PhoneNumber"]; string verificationCode = context.Request.Raw["VerificationCode"]; //获取手机号对应的缓存验证码 if (!_memoryCache.TryGetValue(phoneNumber, out string cacheVerificationCode)) { //如果获取不到缓存验证码,说明手机号不存在,或者验证码过期,但是发送验证码时已经验证过手机号是存在的,所以只能是验证码过期 context.Result = new GrantValidationResult() { IsError = true, Error = "验证码过期", }; return; } if (verificationCode != cacheVerificationCode) { context.Result = new GrantValidationResult() { IsError = true, Error = "验证码错误", }; return; } //根据手机号获取用户信息 var appUser = await GetUserByPhoneNumberAsync(phoneNumber); if (appUser == null) { context.Result = new GrantValidationResult() { IsError = true, Error = "手机号无效", }; return; } //授权通过返回 context.Result = new GrantValidationResult(appUser.Id.ToString(), "custom"); } catch (Exception ex) { context.Result = new GrantValidationResult() { IsError = true, Error = ex.Message }; } } //根据手机号获取用户信息 private async Task<ApplicationUser> GetUserByPhoneNumberAsync(string phoneNumber) { var appUser = await _context.Users.AsNoTracking() .FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber); return appUser; } }
把自定义手机验证码认证处理器PhoneCodeGrantValidator注册到id4认证服务。
D:\Software\gitee\blzid4\BlzId4Web\AspNetId4Web\Startup.cs var builder = services.AddIdentityServer(options => { options.Events.RaiseErrorEvents = true; options.Events.RaiseInformationEvents = true; options.Events.RaiseFailureEvents = true; options.Events.RaiseSuccessEvents = true; // see https://identityserver4.readthedocs.io/en/latest/topics/resources.html options.EmitStaticAudienceClaim = true; }) .AddInMemoryIdentityResources(Config.IdentityResources) .AddInMemoryApiScopes(Config.ApiScopes) .AddInMemoryClients(Config.Clients) .AddExtensionGrantValidator<PhoneCodeGrantValidator>() .AddInMemoryApiResources(Config.ApiResources) .AddAspNetIdentity<ApplicationUser>();
注意要修改一下ProfileService,如果是Code方式访问id4,可以获取到所需的claims,但是自定义的PhoneCodeGrantType方式访问id4,只能获取到nation。调试发现context.Subject也只有nation一个claim,context.Subject.FindAll(JwtClaimTypes.Name)根本无法获取到用户名等所需用户属性。我试过注解掉我写的ProfileService,id4会用内置的ProfileService,返回的claims多一点,也不满足需求。我不知道问题出在哪里,但是知道怎么解决这个问题,就是ProfileService直接返回所需的用户属性即可,不判断context.Subject。
D:\Software\gitee\mauiblazorapp\AspNetId4Web\ProfileService.cs public async Task GetProfileDataAsync(ProfileDataRequestContext context) { using var scope = _serviceProvider.CreateScope(); var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); //按Name找不到 //var user = await userMgr.FindByNameAsync(context.Subject.Identity.Name); //按Sub找得到 string userId = context.Subject.FindFirstValue(JwtClaimTypes.Subject); var user = await userMgr.FindByIdAsync(userId); #region 非Code方式访问,context.Subject只有nation,无法获取其他claim #if false var nameClaim = context.Subject.FindAll(JwtClaimTypes.Name); context.IssuedClaims.AddRange(nameClaim); var roleClaims = context.Subject.FindAll(JwtClaimTypes.Role); context.IssuedClaims.AddRange(roleClaims); var emailClaims = context.Subject.FindAll(JwtClaimTypes.Email); context.IssuedClaims.AddRange(emailClaims); var phoneNumberClaims = context.Subject.FindAll(JwtClaimTypes.PhoneNumber); context.IssuedClaims.AddRange(phoneNumberClaims); #endif #endregion //手机验证码方式访问,直接获取用户的claims var nameClaim = new Claim(JwtClaimTypes.Name, user.UserName); context.IssuedClaims.Add(nameClaim); var roles = await userMgr.GetRolesAsync(user); foreach (var role in roles) { var roleClaims = new Claim(JwtClaimTypes.Role, role); context.IssuedClaims.Add(roleClaims); } var emailClaims = new Claim(JwtClaimTypes.Email, user.Email); context.IssuedClaims.Add(emailClaims); var phoneNumberClaims = new Claim(JwtClaimTypes.PhoneNumber, user.PhoneNumber); context.IssuedClaims.Add(phoneNumberClaims); //获取民族字段 var nationClaim = new Claim("nation", user.Nation); context.IssuedClaims.Add(nationClaim); await Task.CompletedTask; }
APP增加用户管理功能
基于本系列MaBlaApp项目,把2021年DEMO的MAUI Blazor客户端项目BlaMauiApp的代码复制过来使用。
NuGet安装IdentityModel
<PackageReference Include=”IdentityModel” Version=”4.6.0″ />
登录用户信息类LoginUserInfo不用改。
D:\Software\gitee\blzid4\BlzId4Web\BlaMauiApp\Data\LoginUserInfo.cs
/// <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}]"; }
登录用户管理器LoginUserManager稍微优化一下,之前用文件保存登录用户信息,现在改用Preferences,更加方便。
D:\Software\gitee\mauiblazorapp\MaBlaApp\Data\LoginUserManager.cs
/// <summary> /// 登录用户管理器 /// </summary> public class LoginUserManager { /// <summary> /// 登录用户信息 /// </summary> public LoginUserInfo UserInfo { get; private set; } = new LoginUserInfo(); /// <summary> /// 登录用户信息json /// </summary> public string UserInfoJson { get => Preferences.Get(nameof(UserInfoJson), ""); set => Preferences.Set(nameof(UserInfoJson), value); } public LoginUserManager() { if (!string.IsNullOrWhiteSpace(UserInfoJson)) { //如果已经存在登录用户信息json,反序列化登录用户信息 UserInfo = JsonConvert.DeserializeObject<LoginUserInfo>(UserInfoJson); if ((UserInfo is null) || (UserInfo.ExpiresIn < DateTimeOffset.Now)) { //如果登录信息已经过期,清除登录用户信息 UserInfo = new LoginUserInfo(); //清除登录用户信息json UserInfoJson = ""; } } else { //如果没有登录用户json,新建登录用户信息 UserInfo = new LoginUserInfo(); } Debug.WriteLine($"{DateTimeOffset.Now}, 初始化登录用户信息: {UserInfo}"); } /// <summary> /// 用户是否已经登录? /// </summary> public bool IsAuthenticated => !string.IsNullOrWhiteSpace(UserInfo.Username); /// <summary> /// 登录,提取登录用户信息,并保存到APP配置 /// </summary> public void Login(LoginUserInfo userInfo) { UserInfo = userInfo; UserInfoJson = JsonConvert.SerializeObject(UserInfo); Debug.WriteLine($"{DateTimeOffset.Now}, 用户登录: {UserInfo}"); } /// <summary> /// 退出登录 /// </summary> public void Logout() { string userName = UserInfo.Username; //清除登录用户信息 UserInfo = new LoginUserInfo(); //清除登录用户信息json UserInfoJson = ""; Debug.WriteLine($"{DateTimeOffset.Now}, 用户退出登录: {userName}"); } }
手机验证码登录功能模块Ids4Client改为从access token解析claims,获取用户属性。
D:\Software\gitee\mauiblazorapp\MaBlaApp\Data\Ids4Client.cs
/// <summary> /// 手机验证码登录功能模块 /// </summary> public class Ids4Client { private readonly HttpClient _client; public Ids4Client(HttpClient httpClient) { _client = httpClient; } /// <summary> /// 发送验证码到手机号 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> public async Task<string> SendPhoneCodeAsync(string phoneNumber) { string url = $"api/PhoneCodeLogin/SendPhoneCode?phoneNumber={phoneNumber}"; string result = await _client.GetStringAsync(url); return result; } /// <summary> /// 手机验证码登录 /// </summary> /// <param name="phoneNumber">手机号</param> /// <param name="verificationCode">验证码</param> /// <returns></returns> public async Task<LoginUserInfo> PhoneCodeLogin(string phoneNumber, string verificationCode) { var request = new DiscoveryDocumentRequest() { Policy = new DiscoveryPolicy() { //本地调试抓包 RequireHttps = false } }; //发现端点 var discovery = await _client.GetDiscoveryDocumentAsync(request); if (discovery.IsError) { Debug.WriteLine($"访问Identity Server 4服务器失败, Error={discovery.Error}"); return null; } //填写登录参数,必须跟Identity Server 4服务器Config.cs定义一致 var requestParams = new Dictionary<string, string> { ["client_Id"] = "PhoneCode", ["client_secret"] = "PhoneCode.Secret", ["grant_type"] = "PhoneCodeGrantType", ["scope"] = "openid profile scope1 role", ["PhoneNumber"] = phoneNumber, ["VerificationCode"] = verificationCode }; //请求获取token var tokenResponse = await _client.RequestTokenRawAsync(discovery.TokenEndpoint, requestParams); if (tokenResponse.IsError) { Debug.WriteLine($"请求获取token失败, Error={tokenResponse.Error}"); return null; } string userInfoJson = ""; //设置Http认证头 _client.SetBearerToken(tokenResponse.AccessToken); //获取用户信息 //var userInfoResponse = await _client.GetAsync(discovery.UserInfoEndpoint); //if (!userInfoResponse.IsSuccessStatusCode) //{ // //scope必须包含profile才能获取到用户信息 // //如果客户端请求scope没有profile,返回403拒绝访问 // Debug.WriteLine($"获取用户信息失败, StatusCode={userInfoResponse.StatusCode}"); //} //else //{ // // {"sub":"d2f64bb2-789a-4546-9107-547fcb9cdfce","name":"Alice Smith","given_name":"Alice","family_name":"Smith","website":"http://alice.com","role":["Admin","Guest"],"preferred_username":"alice"} // userInfoJson = await userInfoResponse.Content.ReadAsStringAsync(); // Debug.WriteLine($"获取用户信息成功, {userInfoJson}"); //} //MAUI Blazor客户端PhoneCodeGrantType方式访问Id4,只获取到sub nation var jwtSecurityToken = new JwtSecurityToken(tokenResponse.AccessToken); LoginUserInfo loginUserInfo = new LoginUserInfo(); loginUserInfo.AccessToken = tokenResponse.AccessToken; loginUserInfo.RefreshToken = tokenResponse.RefreshToken; loginUserInfo.ExpiresIn = DateTimeOffset.Now.AddSeconds(tokenResponse.ExpiresIn); //用户名 loginUserInfo.Username = jwtSecurityToken.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name)?.Value; //用户ID var claimId = jwtSecurityToken.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject); if (Guid.TryParse(claimId?.Value, out Guid userId)) loginUserInfo.UserId = $"{userId}"; //角色 //id4返回的角色是字符串数组或者字符串 var roleNames = jwtSecurityToken.Claims.Where(x => x.Type == JwtClaimTypes.Role).Select(x => x.Value); loginUserInfo.UserRole = string.Join(",", roleNames); return loginUserInfo; } }
注册认证功能模块。NuGet安装Microsoft.Extensions.Http
D:\Software\gitee\mauiblazorapp\MaBlaApp\MauiProgram.cs
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"));//Windows调试 //.ConfigureHttpClient(c => c.BaseAddress = new Uri("http://10.0.2.2:5000"));//安卓模拟器,AndroidManifest.xml要添加android:usesCleartextTraffic="true"支持访问http网站
主页增加显示登录用户信息,如果当前没有登录信息的话,自动跳转到登录页面。如果不需要这个功能,注解OnInitializedAsync即可。
D:\Software\gitee\mauiblazorapp\MaBlaApp\Pages\Index.razor
@page "/" @using MaBlaApp.Data @inject LoginUserManager loginUserManager @inject NavigationManager NavManager <h1>Hello, world!</h1> Welcome to your new app. @*<SurveyPrompt Title="How is Blazor working for you?" />*@ <ul class="list-group" style="overflow:auto"> <li class="list-group-item"> <a href="testble" class="btn btn-primary btn-sm">测试低功耗蓝牙</a> </li> <li class="list-group-item"> <a href="scanqrcode" class="btn btn-primary btn-sm">扫描二维码</a> </li> @if (isAuthenticated) { <li class="list-group-item d-flex justify-content-between mb-1"> <small class="align-self-center">您已经登录</small> <button class="btn btn-warning btn-sm ms-2" @onclick="Logout">退出登录</button> </li> <li class="list-group-item"> <strong>用户信息</strong> </li> <li class="list-group-item d-flex justify-content-between mb-1"> <strong>AccessToken</strong> <small>@userInfo.AccessToken</small> </li> <li class="list-group-item d-flex justify-content-between mb-1"> <strong>RefreshToken</strong> <small>@userInfo.RefreshToken</small> </li> <li class="list-group-item d-flex justify-content-between mb-1"> <strong>ExpiresIn</strong> <small>@userInfo.ExpiresIn</small> </li> <li class="list-group-item d-flex justify-content-between mb-1"> <strong>UserId</strong> <small>@userInfo.UserId</small> </li> <li class="list-group-item d-flex justify-content-between mb-1"> <strong>Username</strong> <small>@userInfo.Username</small> </li> <li class="list-group-item d-flex justify-content-between mb-1"> <strong>UserRole</strong> <small>@userInfo.UserRole</small> </li> } else { <li class="list-group-item d-flex justify-content-between mb-1"> <small class="align-self-center">您还没有登录,请先登录</small> <a class="btn btn-primary btn-sm ms-2" href="login">登录</a> </li> } </ul> @code { private bool isAuthenticated => loginUserManager.IsAuthenticated; private LoginUserInfo userInfo => loginUserManager.UserInfo; protected override async Task OnInitializedAsync() { if (!isAuthenticated) { //没有用户登录信息,跳转到登录页面 //NavManager.NavigateTo("/login"); } } private void Logout() { loginUserManager.Logout(); //直接跳转到登录页面 //NavManager.NavigateTo("/login"); } }
登录页面PhoneCodeLogin不用改。
D:\Software\gitee\mauiblazorapp\MaBlaApp\Pages\PhoneCodeLogin.razor @page "/login" @using MaBlaApp.Data @layout LoginLayout <div class="d-flex justify-content-center"> <div class="card" style="width: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> </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 userInfo = await ids4Client.PhoneCodeLogin(PhoneNumber, VerificationCode); //登录 loginUserManager.Login(userInfo); //跳转回主页 navigationManager.NavigateTo("/"); } }
测试登录
把服务端AspNetId4Web项目和客户端MaBlaApp项目跑起来。登录页面只是遮盖了index主页,还是可以切换到其他页面。
为了解决这个问题,登录页面要引用一个遮盖MainPage的LoginLayout。
@inherits LayoutComponentBase <div class="m-4"> @Body </div> @code { }
登录页面引用这个LoginLayout布局。
D:\Software\gitee\mauiblazorapp\MaBlaApp\Pages\PhoneCodeLogin.razor
@layout LoginLayout
再次运行,登录页面遮盖了整个APP,满足需求。
同时运行AspNetId4Web认证服务器和MaBlaApp客户端项目,输入种子用户alice的手机号13512345001,获取验证码,在AspNetId4Web项目的控制台可以看到验证码,填写到MaBlaApp网页,即可登录。登录成功后,可以显示获取到的用户属性。
查看AspNetId4Web项目控制台输出:
[16:58:37 Information] IdentityServer4.Validation.TokenRequestValidator
Token request validation success, {“ClientId”: “PhoneCode”, “ClientName”: “PhoneCode”, “GrantType”: “PhoneCodeGrantType”, “Scopes”: “openid profile role scope1”, “AuthorizationCode”: null, “RefreshToken”: null, “UserName”: null, “AuthenticationContextReferenceClasses”: null, “Tenant”: null, “IdP”: null, “Raw”: {“client_Id”: “PhoneCode”, “client_secret”: “***REDACTED***”, “grant_type”: “PhoneCodeGrantType”, “scope”: “openid profile scope1 role”, “PhoneNumber”: “13512345001”, “VerificationCode”: “2747”}, “$type”: “TokenRequestValidationLog”}
[16:58:37 Debug] IdentityServer4.Services.DefaultClaimsService
Getting claims for access token for client: PhoneCode
[16:58:37 Debug] IdentityServer4.Services.DefaultClaimsService
Getting claims for access token for subject: d2f64bb2-789a-4546-9107-547fcb9cdfce
[16:58:38 Information] IdentityServer4.Events.DefaultEventService
{“ClientId”: “PhoneCode”, “ClientName”: “PhoneCode”, “RedirectUri”: null, “Endpoint”: “Token”, “SubjectId”: “d2f64bb2-789a-4546-9107-547fcb9cdfce”, “Scopes”: “openid profile role scope1”, “GrantType”: “PhoneCodeGrantType”, “Tokens”: [{“TokenType”: “access_token”, “TokenValue”: “****Kenw”, “$type”: “Token”}], “Category”: “Token”, “Name”: “Token Issued Success”, “EventType”: “Success”, “Id”: 2000, “Message”: null, “ActivityId”: “0HMOIATNCVSRE:00000005”, “TimeStamp”: “2023-02-19T08:58:38.0000000Z”, “ProcessId”: 17392, “LocalIpAddress”: “::1:5000”, “RemoteIpAddress”: “::1”, “$type”: “TokenIssuedSuccessEvent”}
[16:58:38 Debug] IdentityServer4.Endpoints.TokenEndpoint
Token request success.
MaBlaApp网页显示用户属性:
DEMO代码地址:https://gitee.com/woodsun/mauiblazorapp
转载声明:本站文章若无特别说明,皆为原创,转载请注明来源:www.88531.cn资享网,谢谢!^^