一、本教程实现效果

在这里插入图片描述

二、Blazor Server应用代码

  1. 新增用于验证的包 Blazored.FluentValidation和本地存储服务包Blazored.LocalStorage,TeachingBlazorApp.csproj代码:

    <Project Sdk="Microsoft.NET.Sdk.Web">
    
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="Blazored.FluentValidation" Version="2.1.0" />
        <PackageReference Include="MudBlazor" Version="6.11.1" />
        <PackageReference Include="Blazored.LocalStorage" Version="4.4.0"/>
      </ItemGroup>
    </Project>
    
    
  2. 在Pages文件夹中新增登录页面Login.razor:

    Login.razor

    @page "/login"
    @using TeachingBlazorApp.Shared
    @layout MainLayout
    @using Microsoft.AspNetCore.Authorization
    @using TeachingBlazorApp.Static.Icons
    @using Microsoft.AspNetCore.Components.Forms
    @attribute [AllowAnonymous]
    @inject Microsoft.Extensions.Localization.IStringLocalizer<Login> _localizer
    
    <PageTitle>Login</PageTitle>
    <EditForm Model="@_tokenModel" OnValidSubmit="SubmitAsync">
        <Blazored.FluentValidation.FluentValidationValidator @ref="_fluentValidationValidator" />
    
        <MudGrid>
            <MudItem xs="12">
                <div class="d-flex justify-center">
                    <MudIcon Icon="@TeachingBlazorApp.Static.Icons.CustomIcons.TeachingBlazorLogo" ViewBox="0 0 510 510"
                        Style="@($"color:{TeachingBlazorApp.Static.Colors.CustomColors.LogoColor};")" class="login-logo" />
                </div>
            </MudItem>
            <MudItem xs="12">
                <div class="d-flex justify-center">
                    <MudText Typo="Typo.h4">@_localizer["Sign In"]</MudText>
                </div>
            </MudItem>
            <MudItem xs="12">
                <div class="d-flex justify-center">
                    <MudText>@_localizer["Description"]</MudText>
                </div>
            </MudItem>
            <MudItem xs="12">
                <div class="d-flex justify-center">
                    <MudText>@_localizer["Don't have an account?"] <MudLink Href="/register">@_localizer["Register here"]
                        </MudLink>
                    </MudText>
                </div>
            </MudItem>
            <MudItem xs="12">
                <MudTextField T="string" Label="@_localizer["E-mail"]" @bind-Value="ReadOnlyEmail" 
                    ReadOnly="true" Variant="Variant.Outlined" />
            </MudItem>
            <MudItem xs="12">
                <MudTextField Label="@_localizer["Password"]" Variant="Variant.Outlined" @bind-Value="ReadOnlyPWD"
                     ReadOnly="true" InputType="@_passwordInput" Adornment="Adornment.End"
                    AdornmentIcon="@_passwordInputIcon" OnAdornmentClick="TogglePasswordVisibility" />
            </MudItem>
            <MudItem xs="12" Class="d-flex justify-space-between align-center">
                <MudCheckBox T="bool" Label="@_localizer["Remember me?"]" Color="Color.Dark" Class="ml-n1"></MudCheckBox>
                <MudLink Href="/account/forgot-password">@_localizer["Forgot password?"]</MudLink>
            </MudItem>
            <MudItem xs="12" Class="d-flex justify-center">
                <MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Disabled="@(!Validated)"
                    Color="@TeachingBlazorApp.Static.Colors.CustomColors.ButtonColor" Size="Size.Large" Style="width: 100%;">
                    @_localizer["Sign In"]</MudButton>
            </MudItem>
        </MudGrid>
    </EditForm>
    
    <style>
        .login-logo {
            width: 100px;
            height: 100px;
        }
    </style>
    
  3. 新增登录页面对应的cs文件,Login.razor.cs:

    Login.razor.cs

    using Blazored.FluentValidation;
    using Microsoft.AspNetCore.Components.Authorization;
    using MudBlazor;
    using System.Security.Claims;
    using System.Text.RegularExpressions;
    using TeachingBlazorApp.Infrastructure.Models.Requests.Identity;
    
    namespace TeachingBlazorApp.Pages.Authentication
    {
        public partial class Login
        {
            // Refer to BlazorHero
            private FluentValidationValidator? _fluentValidationValidator;
            private bool Validated => _fluentValidationValidator.Validate(options => { options.IncludeAllRuleSets(); });
            private TokenRequest _tokenModel = new();
            private bool _passwordVisibility;
            private InputType _passwordInput = InputType.Password;
            private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
            public string ReadOnlyEmail { get; set; } = "SuperUser@163.com";
            public string ReadOnlyPWD { get; set; } = "123Pa$$word!";
    
            protected override async Task OnAfterRenderAsync(bool firstRender)
            {
                if (firstRender)
                {
                    var state = await _stateProvider.GetAuthenticationStateAsync();
                    var authenticationState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
                    if (state.User.Identity.Name != new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())).User.Identity.Name)
                    {
                        _navigationManager.NavigateTo("/home");
                    }
                }
            }
    
            private async Task SubmitAsync()
            {
                _tokenModel.Email = ReadOnlyEmail;
                _tokenModel.Password = ReadOnlyPWD;
                var result = await _authenticationManager.Login(_tokenModel);
                if (!result.Succeeded)
                {
                    foreach (var message in result.Messages)
                    {
                        _snackBar.Add(message, Severity.Error);
                    }
                }
            }
    
            void TogglePasswordVisibility()
            {
                if (_passwordVisibility)
                {
                    _passwordVisibility = false;
                    _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
                    _passwordInput = InputType.Password;
                }
                else
                {
                    _passwordVisibility = true;
                    _passwordInputIcon = Icons.Material.Filled.Visibility;
                    _passwordInput = InputType.Text;
                }
            }
        }
    }
    
  4. 新增权限常量类ApplicationClaimTypes:

    namespace TeachingBlazorApp.Infrastructure.Constants.Permission
    {
        public static class ApplicationClaimTypes
        {
            public const string Permission = "Permission";
        }
    }
    
  5. 新增存储常量类StorageConstants:

    namespace TeachingBlazorApp.Infrastructure.Constants.Storage
    {
        public static class StorageConstants
        {
            public static class Local
            {
                public static string Preference = "clientPreference";
    
                public static string AuthToken = "authToken";
                public static string RefreshToken = "refreshToken";
                public static string UserImageURL = "userImageURL";
            }
    
            public static class Server
            {
                public static string Preference = "serverPreference";
    
                //TODO - add
            }
        }
    }
    
  6. 新增用户状态服务TeachingBlazorStateProvider:

    using Blazored.LocalStorage;
    using Microsoft.AspNetCore.Components.Authorization;
    using System.Diagnostics;
    using System.Net.Http.Headers;
    using System.Security.Claims;
    using System.Text.Json;
    using TeachingBlazorApp.Infrastructure.Constants.Permission;
    using TeachingBlazorApp.Infrastructure.Constants.Storage;
    
    namespace TeachingBlazorApp.Infrastructure.Managers.Identity.Authentication
    {
        public class TeachingBlazorStateProvider : AuthenticationStateProvider
        {
            private readonly HttpClient _httpClient;
            private readonly ILocalStorageService _localStorage;
    
            public TeachingBlazorStateProvider(
                HttpClient httpClient,
                ILocalStorageService localStorage)
            {
                _httpClient = httpClient;
                _localStorage = localStorage;
            }
    
            public async Task StateChangedAsync()
            {
                var authState = Task.FromResult(await GetAuthenticationStateAsync());
    
                NotifyAuthenticationStateChanged(authState);
    
            }
    
            public void MarkUserAsLoggedOut()
            {
                var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
                var authState = Task.FromResult(new AuthenticationState(anonymousUser));
    
                NotifyAuthenticationStateChanged(authState);
            }
    
            public async Task<ClaimsPrincipal> GetAuthenticationStateProviderUserAsync()
            {
                var state = await this.GetAuthenticationStateAsync();
                var authenticationStateProviderUser = state.User;
                return authenticationStateProviderUser;
            }
    
            public ClaimsPrincipal AuthenticationStateUser { get; set; }
    
            public override async Task<AuthenticationState> GetAuthenticationStateAsync()
            {
                try
                {
                    var savedToken = await _localStorage.GetItemAsync<string>(StorageConstants.Local.AuthToken);
                    if (string.IsNullOrWhiteSpace(savedToken))
                    {
                        return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
                    }
                    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedToken);
                    var state = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(GetClaimsFromJwt(savedToken), "jwt")));
                    AuthenticationStateUser = state.User;
                    return state;
                }
                catch (InvalidOperationException e)
                {
                    Debug.WriteLine("Local Storage cannot get savedToken: " + e);
                    return null;
                }
            }
    
            private IEnumerable<Claim> GetClaimsFromJwt(string jwt)
            {
                var claims = new List<Claim>();
                var payload = jwt.Split('.')[1];
                var jsonBytes = ParseBase64WithoutPadding(payload);
                var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
    
                if (keyValuePairs != null)
                {
                    keyValuePairs.TryGetValue(ClaimTypes.Role, out var roles);
    
                    if (roles != null)
                    {
                        if (roles.ToString().Trim().StartsWith("["))
                        {
                            var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString());
    
                            claims.AddRange(parsedRoles.Select(role => new Claim(ClaimTypes.Role, role)));
                        }
                        else
                        {
                            claims.Add(new Claim(ClaimTypes.Role, roles.ToString()));
                        }
    
                        keyValuePairs.Remove(ClaimTypes.Role);
                    }
    
                    keyValuePairs.TryGetValue(ApplicationClaimTypes.Permission, out var permissions);
                    if (permissions != null)
                    {
                        if (permissions.ToString().Trim().StartsWith("["))
                        {
                            var parsedPermissions = JsonSerializer.Deserialize<string[]>(permissions.ToString());
                            claims.AddRange(parsedPermissions.Select(permission => new Claim(ApplicationClaimTypes.Permission, permission)));
                        }
                        else
                        {
                            claims.Add(new Claim(ApplicationClaimTypes.Permission, permissions.ToString()));
                        }
                        keyValuePairs.Remove(ApplicationClaimTypes.Permission);
                    }
    
                    claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));
                }
                return claims;
            }
    
            private byte[] ParseBase64WithoutPadding(string payload)
            {
                payload = payload.Trim().Replace('-', '+').Replace('_', '/');
                var base64 = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
                return Convert.FromBase64String(base64);
            }
        }
    }
    
  7. 在_Imports.razor中加入用户状态服务,本地存储服务和导航管理服务:

    @using Microsoft.AspNetCore.Components.Routing
    @using Microsoft.AspNetCore.Components.Web
    @using Microsoft.JSInterop
    @using MudBlazor
    @using TeachingBlazorApp
    @using TeachingBlazorApp.Infrastructure.Managers
    @using TeachingBlazorApp.Infrastructure.Managers.Identity.Authentication
    
    @inject HealthCheckManager _healthCheckManager;
    @inject ISnackbar _snackBar;
    @inject IAuthenticationManager _authenticationManager;
    @inject TeachingBlazorStateProvider _stateProvider;
    @inject NavigationManager _navigationManager;
    
  8. 新增用于登录的实体类,TokenRequest.cs和TokenResponse.cs:

    using System.ComponentModel.DataAnnotations;
    
    namespace TeachingBlazorApp.Infrastructure.Models.Requests.Identity
    {
        public class TokenRequest
        {
            [Required]
            public string Email { get; set; }
    
            [Required]
            public string Password { get; set; }
        }
    }
    
    using System;
    
    namespace TeachingBlazorApp.Infrastructure.Models.Responses.Identity
    {
        public class TokenResponse
        {
            public string Token { get; set; }
            public string RefreshToken { get; set; }
            public string UserImageURL { get; set; }
            public DateTime RefreshTokenExpiryTime { get; set; }
        }
    }
    
  9. 新增路由类TokenEndpoints.cs:

    namespace TeachingBlazorApp.Infrastructure.Routes
    {
        public static class TokenEndpoints
        {
            public static string Get = "api/identity/token";
            public static string Refresh = "api/identity/token/refresh";
    
        }
    }
    
  10. 新增包装类接口IResult.cs和Result.cs:

    using System.Collections.Generic;
    
    namespace TeachingBlazorApp.Infrastructure.Utils.Wrapper
    {
        public interface IResult
        {
            List<string> Messages { get; set; }
    
            bool Succeeded { get; set; }
        }
    
        public interface IResult<out T> : IResult
        {
            T Data { get; }
        }
    }
    
    using System.Collections.Generic;
    using System.Threading.Tasks;
    
    namespace TeachingBlazorApp.Infrastructure.Utils.Wrapper
    {
        public class Result : IResult
        {
            public Result()
            {
            }
    
            public List<string> Messages { get; set; } = new List<string>();
    
            public bool Succeeded { get; set; }
    
            public static IResult Fail()
            {
                return new Result { Succeeded = false };
            }
    
            public static IResult Fail(string message)
            {
                return new Result { Succeeded = false, Messages = new List<string> { message } };
            }
    
            public static IResult Fail(List<string> messages)
            {
                return new Result { Succeeded = false, Messages = messages };
            }
    
            public static Task<IResult> FailAsync()
            {
                return Task.FromResult(Fail());
            }
    
            public static Task<IResult> FailAsync(string message)
            {
                return Task.FromResult(Fail(message));
            }
    
            public static Task<IResult> FailAsync(List<string> messages)
            {
                return Task.FromResult(Fail(messages));
            }
    
            public static IResult Success()
            {
                return new Result { Succeeded = true };
            }
    
            public static IResult Success(string message)
            {
                return new Result { Succeeded = true, Messages = new List<string> { message } };
            }
    
            public static Task<IResult> SuccessAsync()
            {
                return Task.FromResult(Success());
            }
    
            public static Task<IResult> SuccessAsync(string message)
            {
                return Task.FromResult(Success(message));
            }
        }
    
        public class Result<T> : Result, IResult<T>
        {
            public Result()
            {
            }
    
            public T Data { get; set; }
    
            public new static Result<T> Fail()
            {
                return new Result<T> { Succeeded = false };
            }
    
            public new static Result<T> Fail(string message)
            {
                return new Result<T> { Succeeded = false, Messages = new List<string> { message } };
            }
    
            public new static Result<T> Fail(List<string> messages)
            {
                return new Result<T> { Succeeded = false, Messages = messages };
            }
    
            public new static Task<Result<T>> FailAsync()
            {
                return Task.FromResult(Fail());
            }
    
            public new static Task<Result<T>> FailAsync(string message)
            {
                return Task.FromResult(Fail(message));
            }
    
            public new static Task<Result<T>> FailAsync(List<string> messages)
            {
                return Task.FromResult(Fail(messages));
            }
    
            public new static Result<T> Success()
            {
                return new Result<T> { Succeeded = true };
            }
    
            public new static Result<T> Success(string message)
            {
                return new Result<T> { Succeeded = true, Messages = new List<string> { message } };
            }
    
            public static Result<T> Success(T data)
            {
                return new Result<T> { Succeeded = true, Data = data };
            }
    
            public static Result<T> Success(T data, string message)
            {
                return new Result<T> { Succeeded = true, Data = data, Messages = new List<string> { message } };
            }
    
            public static Result<T> Success(T data, List<string> messages)
            {
                return new Result<T> { Succeeded = true, Data = data, Messages = messages };
            }
    
            public new static Task<Result<T>> SuccessAsync()
            {
                return Task.FromResult(Success());
            }
    
            public new static Task<Result<T>> SuccessAsync(string message)
            {
                return Task.FromResult(Success(message));
            }
    
            public static Task<Result<T>> SuccessAsync(T data)
            {
                return Task.FromResult(Success(data));
            }
    
            public static Task<Result<T>> SuccessAsync(T data, string message)
            {
                return Task.FromResult(Success(data, message));
            }
        }
    }
    
  11. 新增分页包装类PaginatedResult.cs:

    using System;
    using System.Collections.Generic;
    
    namespace TeachingBlazorApp.Infrastructure.Utils.Wrapper
    {
        public class PaginatedResult<T> : Result
        {
            public PaginatedResult(List<T> data)
            {
                Data = data;
            }
    
            public List<T> Data { get; set; }
    
            internal PaginatedResult(bool succeeded, List<T> data = default, List<string> messages = null, int count = 0, int page = 1, int pageSize = 10)
            {
                Data = data;
                CurrentPage = page;
                Succeeded = succeeded;
                PageSize = pageSize;
                TotalPages = (int)Math.Ceiling(count / (double)pageSize);
                TotalCount = count;
            }
    
            public static PaginatedResult<T> Failure(List<string> messages)
            {
                return new PaginatedResult<T>(false, default, messages);
            }
    
            public static PaginatedResult<T> Success(List<T> data, int count, int page, int pageSize)
            {
                return new PaginatedResult<T>(true, data, null, count, page, pageSize);
            }
    
            public int CurrentPage { get; set; }
    
            public int TotalPages { get; set; }
    
            public int TotalCount { get; set; }
            public int PageSize { get; set; }
    
            public bool HasPreviousPage => CurrentPage > 1;
    
            public bool HasNextPage => CurrentPage < TotalPages;
        }
    }
    
  12. 新增包装拓展类ResultExtensions.cs:

    using System.Diagnostics;
    using System.Net.Http;
    using System.Text.Json;
    using System.Text.Json.Serialization;
    using System.Threading.Tasks;
    using TeachingBlazorApp.Infrastructure.Utils.Wrapper;
    using IResult = TeachingBlazorApp.Infrastructure.Utils.Wrapper.IResult;
    
    namespace TeachingBlazorApp.Infrastructure.Extensions
    {
        internal static class ResultExtensions
        {
            /// <summary>
            /// Process http response to return object
            /// </summary>
            /// <param name="response"></param>
            /// <typeparam name="T"></typeparam>
            /// <returns>Object</returns>
            internal static async Task<IResult<T>> ToResult<T>(this HttpResponseMessage response)
            {
                try
                {
                    var responseAsString = await response.Content.ReadAsStringAsync();
                    var responseObject = JsonSerializer.Deserialize<Result<T>>(responseAsString, new JsonSerializerOptions
                    {
                        PropertyNameCaseInsensitive = true,
                        ReferenceHandler = ReferenceHandler.Preserve
                    });
                    return responseObject;
                }
                catch (Exception e)
                {
                    Debug.WriteLine("ResultExtensions ToResult e = " + e);
                }
                return null;
    
            }
    
            internal static async Task<IResult> ToResult(this HttpResponseMessage response)
            {
                var responseAsString = await response.Content.ReadAsStringAsync();
                var responseObject = JsonSerializer.Deserialize<Result>(responseAsString, new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true,
                    ReferenceHandler = ReferenceHandler.Preserve
                });
                return responseObject;
            }
    
            internal static async Task<PaginatedResult<T>> ToPaginatedResult<T>(this HttpResponseMessage response)
            {
                var responseAsString = await response.Content.ReadAsStringAsync();
                var responseObject = JsonSerializer.Deserialize<PaginatedResult<T>>(responseAsString, new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                });
                return responseObject;
            }
        }
    }
    
  13. 新增验证管理类AuthenticateManager.cs,用于发送用户登录请求和处理Asp.Net Web Api应用返回的报文:

    using Microsoft.AspNetCore.Components.Authorization;
    using Microsoft.Extensions.Localization;
    using System;
    using System.Net.Http;
    using System.Net.Http.Headers;
    using System.Net.Http.Json;
    using System.Security.Claims;
    using System.Threading.Tasks;
    using TeachingBlazorApp.Infrastructure.Extensions;
    using TeachingBlazorApp.Infrastructure.Models.Requests.Identity;
    using TeachingBlazorApp.Infrastructure.Models.Responses.Identity;
    using TeachingBlazorApp.Infrastructure.Routes;
    using TeachingBlazorApp.Infrastructure.Utils.Wrapper;
    using IResult = TeachingBlazorApp.Infrastructure.Utils.Wrapper.IResult;
    
    namespace TeachingBlazorApp.Infrastructure.Managers.Identity.Authentication
    {
        public class AuthenticationManager : IAuthenticationManager
        {
            private readonly HttpClient _httpClient;
            // private readonly ILocalStorageService _localStorage;
            private readonly AuthenticationStateProvider _authenticationStateProvider;
            private readonly IStringLocalizer<AuthenticationManager> _localizer;
    
            public AuthenticationManager(
                HttpClient httpClient,
                AuthenticationStateProvider authenticationStateProvider,
                IStringLocalizer<AuthenticationManager> localizer)
            {
                _httpClient = httpClient;
                _authenticationStateProvider = authenticationStateProvider;
                _localizer = localizer;
            }
            
            public async Task<IResult> Login(TokenRequest model)
            {
                var response = await _httpClient.PostAsJsonAsync(TokenEndpoints.Get, model);
                var result = await response.ToResult<TokenResponse>();
                if (result.Succeeded)
                {
                    var token = result.Data.Token;
                    var refreshToken = result.Data.RefreshToken;
                    var userImageURL = result.Data.UserImageURL;
                    
                    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
    
                    return await Result.SuccessAsync();
                }
                else
                {
                    return await Result.FailAsync(result.Messages);
                }
            }
        }
    }
    
  14. 新增全局变量,MudBlazor的组件ISnackbar和验证管理类AuthenticateManager.cs,_Imports.razor代码:

    @using CoolBlazor.Infrastructure.Managers.Identity.Authentication
    @using Microsoft.AspNetCore.Components.Routing
    @using Microsoft.AspNetCore.Components.Web
    @using Microsoft.JSInterop
    @using MudBlazor
    @using TeachingBlazorApp
    @using TeachingBlazorApp.Infrastructure.Managers
    
    @inject HealthCheckManager _healthCheckManager;
    @inject ISnackbar _snackBar;
    @inject IAuthenticationManager _authenticationManager;
    
  15. _Imports.razor中加入组件,但Login.razor.cs里的组件仍然显示错误:找不到该变量;

    1. 解决方法:在Program.cs里注册验证服务,MudBlazor第三方包和注册Localization,Program.cs代码:

    !!!注意:
    app.UseAuthentication();
    app.UseAuthorization();

    以上代码需要加到app.UseRouting();后面

    
    builder.Services.AddLocalization(options =>
                    {
                        options.ResourcesPath = "Resources";
                    });
                    
    builder.Services.AddScoped<IAuthenticationManager, AuthenticationManager>();
    
    // Add third party libraries
    builder.Services.AddMudServices();
    
    // JWT
    app.UseAuthentication();
    app.UseAuthorization();
    
    using Microsoft.AspNetCore.Components;
    using Microsoft.AspNetCore.Components.Web;
    using MudBlazor.Services;
    using TeachingBlazorApp.Infrastructure.Managers;
    using TeachingBlazorApp.Infrastructure.Managers.Identity.Authentication;
    
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddRazorPages();
    builder.Services.AddServerSideBlazor();
    
    builder.Services.AddLocalization(options =>
                    {
                        options.ResourcesPath = "Resources";
                    });
    
    builder.Services.AddScoped(sp =>
        new HttpClient
        {
            BaseAddress = new Uri(builder.Configuration["WebApi"] ?? "http://localhost:5211")
        });
    
    // Register Services
    builder.Services.AddScoped<HealthCheckManager>();
    builder.Services.AddScoped<IAuthenticationManager, AuthenticationManager>();
    
    // Add third party libraries
    builder.Services.AddMudServices();
    
    var app = builder.Build();
    
    if (!app.Environment.IsDevelopment())
    {
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }
    
    app.UseHttpsRedirection();
    
    app.UseStaticFiles();
    
    app.UseRouting();
    
    // JWT
    app.UseAuthentication();
    app.UseAuthorization();
    
    app.MapBlazorHub();
    app.MapFallbackToPage("/_Host");
    
    app.Run();
    
    
  16. 运行后报错解决:

    InvalidOperationException: FluentValidationValidator requires a cascading parameter of type EditContext. For example, you can use FluentValidationValidator inside an EditForm.

    解决方法:声明fluentValidationValidator为空;

    private FluentValidationValidator? _fluentValidationValidator;
    
  17. 运行后点击主页右上角的LOGIN HERE,即可看到登录页面:

    在这里插入图片描述

三、Web Api应用代码

  1. 在TeachingWebApi.csproj里添加所需要的包:

     	  <PackageReference Include="AspNetCore.Identity.Mongo" Version="8.3.3"/>
        <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.7"/>
        <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7"/>
        <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0"/>
    

    目前安装的包:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>
        <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
        <PackageReference Include="MongoDB.Driver" Version="2.27.0"/>
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
        <PackageReference Include="MongoDB.EntityFrameworkCore" Version="8.0.3"/>
        <PackageReference Include="AspNetCore.Identity.Mongo" Version="8.3.3"/>
        <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.7"/>
        <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7"/>
        <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0"/>
      </ItemGroup>
    </Project>
    
  2. appSettings.json和appSettings.Development.json中加入AppConfiguration用于进行JWT加密:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "TeachingAppDatabase": {
        "BooksCollectionName": "books",
        "UsersCollectionName": "users",
        "RolesCollectionName": "roles",
        "ConnectionString": "Fill it with your MongoDB connection string",
        "DatabaseName": "teaching_blazor_app"
      },
      "AppConfiguration": {
        "Secret": "S0M3RAN0MS3CR3T!1!MAG1C!1!123456789",
        "BehindSSLProxy": false,
        "ProxyIP": "",
        "ApplicationUrl": ""
      },
      "AllowedHosts": "*"
    }
    
  3. 新增AppConfiguration类,以存放配置文件中的AppConfiguration域的值:

    namespace TeachingWebApi.Config
    {
        public class AppConfiguration
        {
            public string Secret { get; set; }
    
            public bool BehindSSLProxy { get; set; }
    
            public string ProxyIP { get; set; }
    
            public string ApplicationUrl { get; set; }
        }
    }
    
  4. 新增处理用户登录的接口Controller,TokenController.cs:

    using CoolWebApi.Services.Identity;
    using Microsoft.AspNetCore.Mvc;
    using TeachingWebApi.Models.Requests.Identity;
    
    namespace TeachingWebApi.Controllers.Identity
    {
        [Route("api/identity/token")]
        [ApiController]
        public class TokenController : ControllerBase
        {
            private readonly ITokenService _tokenService;
    
            public TokenController(ITokenService tokenService)
            {
                _tokenService = tokenService;
            }
    
            /// <summary>
            /// Get Token (Email, Password)
            /// </summary>
            /// <param name="model"></param>
            /// <returns>Status 200 OK</returns>
            [HttpPost]
            public async Task<ActionResult> Get(TokenRequest model)
            {
                var response = await _tokenService.LoginAsync(model);
                return Ok(response);
            }
        }
    }
    
  5. 新增TeachingWebApi中的包装类服务IResult.cs,Result.cs和PaginatedResult.cs:

    1. IResult.cs:

      using System;
      namespace TeachingWebApi.Utils.Wrapper
      {
          public interface IResult
          {
              List<string> Messages { get; set; }
      
              bool Succeeded { get; set; }
          }
      
          public interface IResult<out T> : IResult
          {
              T Data { get; }
          }
      }
      
    2. Result.cs:

      using System;
      namespace TeachingWebApi.Utils.Wrapper
      {
          public class Result : IResult
          {
              public Result()
              {
              }
      
              public List<string> Messages { get; set; } = new List<string>();
      
              public bool Succeeded { get; set; }
      
              public static IResult Fail()
              {
                  return new Result { Succeeded = false };
              }
      
              public static IResult Fail(string message)
              {
                  return new Result { Succeeded = false, Messages = new List<string> { message } };
              }
      
              public static IResult Fail(List<string> messages)
              {
                  return new Result { Succeeded = false, Messages = messages };
              }
      
              public static Task<IResult> FailAsync()
              {
                  return Task.FromResult(Fail());
              }
      
              public static Task<IResult> FailAsync(string message)
              {
                  return Task.FromResult(Fail(message));
              }
      
              public static Task<IResult> FailAsync(List<string> messages)
              {
                  return Task.FromResult(Fail(messages));
              }
      
              public static IResult Success()
              {
                  return new Result { Succeeded = true };
              }
      
              public static IResult Success(string message)
              {
                  return new Result { Succeeded = true, Messages = new List<string> { message } };
              }
      
              public static Task<IResult> SuccessAsync()
              {
                  return Task.FromResult(Success());
              }
      
              public static Task<IResult> SuccessAsync(string message)
              {
                  return Task.FromResult(Success(message));
              }
          }
      
          public class Result<T> : Result, IResult<T>
          {
              public Result()
              {
              }
      
              public T Data { get; set; }
      
              public new static Result<T> Fail()
              {
                  return new Result<T> { Succeeded = false };
              }
      
              public new static Result<T> Fail(string message)
              {
                  return new Result<T> { Succeeded = false, Messages = new List<string> { message } };
              }
      
              public new static Result<T> Fail(List<string> messages)
              {
                  return new Result<T> { Succeeded = false, Messages = messages };
              }
      
              public new static Task<Result<T>> FailAsync()
              {
                  return Task.FromResult(Fail());
              }
      
              public new static Task<Result<T>> FailAsync(string message)
              {
                  return Task.FromResult(Fail(message));
              }
      
              public new static Task<Result<T>> FailAsync(List<string> messages)
              {
                  return Task.FromResult(Fail(messages));
              }
      
              public new static Result<T> Success()
              {
                  return new Result<T> { Succeeded = true };
              }
      
              public new static Result<T> Success(string message)
              {
                  return new Result<T> { Succeeded = true, Messages = new List<string> { message } };
              }
      
              public static Result<T> Success(T data)
              {
                  return new Result<T> { Succeeded = true, Data = data };
              }
      
              public static Result<T> Success(T data, string message)
              {
                  return new Result<T> { Succeeded = true, Data = data, Messages = new List<string> { message } };
              }
      
              public static Result<T> Success(T data, List<string> messages)
              {
                  return new Result<T> { Succeeded = true, Data = data, Messages = messages };
              }
      
              public new static Task<Result<T>> SuccessAsync()
              {
                  return Task.FromResult(Success());
              }
      
              public new static Task<Result<T>> SuccessAsync(string message)
              {
                  return Task.FromResult(Success(message));
              }
      
              public static Task<Result<T>> SuccessAsync(T data)
              {
                  return Task.FromResult(Success(data));
              }
      
              public static Task<Result<T>> SuccessAsync(T data, string message)
              {
                  return Task.FromResult(Success(data, message));
              }
          }
      }
      
      
    3. PaginatedResult.cs:

      using System;
      using System.Collections.Generic;
      
      namespace TeachingWebApi.Utils.Wrapper
      {
          public class PaginatedResult<T> : Result
          {
              public PaginatedResult(List<T> data)
              {
                  Data = data;
              }
      
              public List<T> Data { get; set; }
      
              internal PaginatedResult(bool succeeded, List<T> data = default, List<string> messages = null, int count = 0, int page = 1, int pageSize = 10)
              {
                  Data = data;
                  CurrentPage = page;
                  Succeeded = succeeded;
                  PageSize = pageSize;
                  TotalPages = (int)Math.Ceiling(count / (double)pageSize);
                  TotalCount = count;
              }
      
              public static PaginatedResult<T> Failure(List<string> messages)
              {
                  return new PaginatedResult<T>(false, default, messages);
              }
      
              public static PaginatedResult<T> Success(List<T> data, int count, int page, int pageSize)
              {
                  return new PaginatedResult<T>(true, data, null, count, page, pageSize);
              }
      
              public int CurrentPage { get; set; }
      
              public int TotalPages { get; set; }
      
              public int TotalCount { get; set; }
              public int PageSize { get; set; }
      
              public bool HasPreviousPage => CurrentPage > 1;
      
              public bool HasNextPage => CurrentPage < TotalPages;
          }
      }
      
  6. 新增TokenRequest.cs和TokenResponse.cs:

    1. TokenRequest.cs:

      using System;
      using System.ComponentModel.DataAnnotations;
      
      namespace TeachingWebApi.Models.Requests.Identity
      {
          public class TokenRequest
          {
              [Required]
              public string Email { get; set; }
      
              [Required]
              public string Password { get; set; }
          }
      }
      
    2. TokenResponse.cs:

      using System;
      namespace TeachingWebApi.Models.Responses.Identity
      {
          public class TokenResponse
          {
              public string Token { get; set; }
      
              public string RefreshToken { get; set; }
      
              public string UserImageURL { get; set; }
      
              public DateTime RefreshTokenExpiryTime { get; set; }
          }
      }
      
  7. 新增ITokenService.cs和TokenService.cs:

    1. ITokenService.cs:

      using TeachingWebApi.Models.Requests.Identity;
      using TeachingWebApi.Models.Responses.Identity;
      using TeachingWebApi.Utils.Wrapper;
      
      namespace TeachingWebApi.Services.Identity
      {
          public interface ITokenService : IService
          {
              Task<Result<TokenResponse>> LoginAsync(TokenRequest model);
      
          }
      }
      
    2. TokenService.cs:

      using Microsoft.AspNetCore.Identity;
      using Microsoft.Extensions.Localization;
      using TeachingWebApi.Config;
      using System.Security.Claims;
      using System.Security.Cryptography;
      using System.Text;
      using System.Xml;
      using Microsoft.IdentityModel.Tokens;
      using TeachingWebApi.Utils.Wrapper;
      using TeachingWebApi.Models.Responses.Identity;
      using TeachingWebApi.Models.Requests.Identity;
      using TeachingWebApi.Models.Identity;
      using System.IdentityModel.Tokens.Jwt;
      using Microsoft.Extensions.Options;
      
      namespace TeachingWebApi.Services.Identity.impl
      {
          public class TokenService : ITokenService
          {
              private const string InvalidErrorMessage = "Invalid email or password.";
      
              private readonly UserManager<TeachingBlazorUser> _userManager;
              private readonly RoleManager<TeachingBlazorRole> _roleManager;
              private readonly SignInManager<TeachingBlazorUser> _signInManager;
              private readonly IStringLocalizer<TokenService> _localizer;
              private readonly IHttpContextAccessor _httpContextAccessor;
              private readonly AppConfiguration _appConfiguration;
      
              public TokenService(
                  UserManager<TeachingBlazorUser> userManager,
                  RoleManager<TeachingBlazorRole> roleManager,
                  IOptions<AppConfiguration> appConfiguration,
                  SignInManager<TeachingBlazorUser> signInManager,
                  IHttpContextAccessor httpContextAccessor,
                  IStringLocalizer<TokenService> localizer)
              {
                  _userManager = userManager;
                  _roleManager = roleManager;
                  _signInManager = signInManager;
                  _appConfiguration = appConfiguration.Value;
                  _httpContextAccessor = httpContextAccessor;
                  _localizer = localizer;
              }
      
              public async Task<Result<TokenResponse>> LoginAsync(TokenRequest model)
              {
                  var user = await _userManager.FindByEmailAsync(model.Email);
                  if (user == null)
                  {
                      return await Result<TokenResponse>.FailAsync(_localizer["User Not Found."]);
                  }
                  if (!user.IsActive)
                  {
                      return await Result<TokenResponse>.FailAsync(_localizer["User Not Active. Please contact the administrator."]);
                  }
                  if (!user.EmailConfirmed)
                  {
                      return await Result<TokenResponse>.FailAsync(_localizer["E-Mail not confirmed."]);
                  }
                  var passwordValid = await _userManager.CheckPasswordAsync(user, model.Password);
                  if (!passwordValid)
                  {
                      return await Result<TokenResponse>.FailAsync(_localizer["Invalid Credentials."]);
                  }
      
                  user.RefreshToken = GenerateRefreshToken();
                  user.RefreshTokenExpiryTime = DateTime.Now.AddDays(7);
                  await _userManager.UpdateAsync(user);
                  var claims = await GetClaimsAsync(user);
      
                  var token = await GenerateJwtAsync(user, claims);
                  var response = new TokenResponse { Token = token, RefreshToken = user.RefreshToken, UserImageURL = user.ProfilePictureDataUrl };
                  return await Result<TokenResponse>.SuccessAsync(response);
              }
      
              private async Task<string> GenerateJwtAsync(TeachingBlazorUser user, IEnumerable<Claim> claims)
              {
                  var token = GenerateEncryptedToken(GetSigningCredentials(), claims);
                  return token;
              }
      
              private async Task<IEnumerable<Claim>> GetClaimsAsync(TeachingBlazorUser user)
              {
                  var userClaims = _userManager.GetClaimsAsync(user).Result;
                  var roles = await _userManager.GetRolesAsync(user);
                  var roleClaims = new List<Claim>();
                  var permissionClaims = new List<Claim>();
                  foreach (var role in roles)
                  {
                      roleClaims.Add(new Claim(ClaimTypes.Role, role));
                      var thisRole = await _roleManager.FindByNameAsync(role);
                      var allPermissionsForThisRoles = await _roleManager.GetClaimsAsync(thisRole);
                      permissionClaims.AddRange(allPermissionsForThisRoles);
                  }
                  var claims = new List<Claim>
                  {
                      new(ClaimTypes.NameIdentifier, user.Id.ToString()),
                      new(ClaimTypes.Email, user.Email),
                      new(ClaimTypes.Name, user.FirstName),
                      new(ClaimTypes.Surname, user.LastName),
                      new(ClaimTypes.MobilePhone, user.PhoneNumber ?? string.Empty)
                  };
                  var unionClaims = claims
                  .Union(userClaims)
                  .Union(roleClaims)
                  .Union(permissionClaims);
                  return unionClaims;
              }
      
              private string GenerateRefreshToken()
              {
                  var randomNumber = new byte[32];
                  using var rng = RandomNumberGenerator.Create();
                  rng.GetBytes(randomNumber);
                  return Convert.ToBase64String(randomNumber);
              }
      
              private string GenerateEncryptedToken(SigningCredentials signingCredentials, IEnumerable<Claim> claims)
              {
                  DateTime now = DateTime.Now;
                  var token = new JwtSecurityToken(
                     claims: claims,
                     expires: DateTime.UtcNow.AddDays(2),
                  //    notBefore: now, // Current User Test
                     signingCredentials: signingCredentials);
                  var tokenHandler = new JwtSecurityTokenHandler();
                  var encryptedToken = tokenHandler.WriteToken(token);
                  return encryptedToken;
              }
      
              private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
              {
                  var tokenValidationParameters = new TokenValidationParameters
                  {
                      ValidateIssuerSigningKey = true,
                      IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_appConfiguration.Secret)),
                      ValidateIssuer = false,
                      ValidateAudience = false,
                      RoleClaimType = ClaimTypes.Role,
                      ClockSkew = TimeSpan.Zero,
                      ValidateLifetime = false
                  };
                  // String2XmlReader
                  StringReader strRdr = new StringReader(token);
                  XmlReader rdr = XmlReader.Create(strRdr);
                  var tokenHandler = new JwtSecurityTokenHandler();
                  var principal = tokenHandler.ValidateToken(rdr, tokenValidationParameters, out var securityToken);
                  if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256,
                      StringComparison.InvariantCultureIgnoreCase))
                  {
                      throw new SecurityTokenException(_localizer["Invalid token"]);
                  }
      
                  return principal;
              }
      
              private SigningCredentials GetSigningCredentials()
              {
                  if (string.IsNullOrEmpty(_appConfiguration.Secret))
                      throw new Exception("JWT secret not configured");
                  var secret = Encoding.UTF8.GetBytes(_appConfiguration.Secret!);
                  return new SigningCredentials(new SymmetricSecurityKey(secret), SecurityAlgorithms.HmacSha256);
              }
      
          }
      
      }
      
  8. 在Program.cs里注册TokenService.cs和Localization服务,并且读取配置文件中AppConfiguration的内容:

    builder.Services.AddLocalization(opts => { opts.ResourcesPath = "Resources"; });
    builder.Services.Configure<AppConfiguration>(builder.Configuration.GetSection("AppConfiguration"));
    builder.Services.AddScoped<ITokenService, TokenService>();
    

    Program.cs所有代码:

    using AspNetCore.Identity.Mongo;
    using Microsoft.AspNetCore.Identity;
    using Microsoft.EntityFrameworkCore;
    using MongoDB.Driver;
    using TeachingWebApi.Config;
    using TeachingWebApi.Data.Seeder;
    using TeachingWebApi.Models.Identity;
    using TeachingWebApi.Services;
    using TeachingWebApi.Services.Identity;
    using TeachingWebApi.Services.Identity.impl;
    using TeachingWebApi.Utils.Data;
    using TeachingWebApi.Utils.Extensions;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // Add services to the container.
    // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen();
    builder.Services.AddControllers();
    builder.Services.AddLocalization(opts => { opts.ResourcesPath = "Resources"; });
    
    builder.Services.Configure<TeachingAppDatabaseSettings>(
        builder.Configuration.GetSection("TeachingAppDatabase"));
    
    var MongoDbConnectionString = builder.Configuration.GetSection("TeachingAppDatabase").Get<TeachingAppDatabaseSettings>().ConnectionString;
    var MongoDbUsersCollection = builder.Configuration.GetSection("TeachingAppDatabase").Get<TeachingAppDatabaseSettings>().UsersCollectionName;
    var MongoDbRolesCollection = builder.Configuration.GetSection("TeachingAppDatabase").Get<TeachingAppDatabaseSettings>().RolesCollectionName;
    
    builder.Services.Configure<AppConfiguration>(builder.Configuration.GetSection("AppConfiguration"));
    
    builder.Services.AddSingleton<BooksService>();
    builder.Services.AddScoped<ITokenService, TokenService>();
    builder.Services.AddTransient<MongoDbContext>();
    builder.Services.AddTransient<MongoIdentityDbContext>();
    
    // MongoDB with Identity
    builder.Services.AddIdentityMongoDbProvider<TeachingBlazorUser, TeachingBlazorRole>(identity =>
                {
                    // Password settings.
                    identity.Password.RequireDigit = false;
                    identity.Password.RequireLowercase = false;
                    identity.Password.RequireNonAlphanumeric = false;
                    identity.Password.RequireUppercase = false;
                    identity.Password.RequiredLength = 1;
                    identity.Password.RequiredUniqueChars = 0;
    
                    // Lockout settings.
                    identity.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
                    identity.Lockout.MaxFailedAccessAttempts = 5;
                    identity.Lockout.AllowedForNewUsers = true;
    
                    // User settings.
                    identity.User.AllowedUserNameCharacters =
                    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
                    identity.User.RequireUniqueEmail = false;
                },
                    mongo =>
                    {
                        mongo.ConnectionString = MongoDbConnectionString;
                        mongo.UsersCollection = MongoDbUsersCollection;
                        mongo.RolesCollection = MongoDbRolesCollection;
                    }
                )
                .AddEntityFrameworkStores<MongoIdentityDbContext>()
                .AddDefaultTokenProviders();
    
    builder.Services
        .AddTransient<DatabaseSeeder>()
            .AddDbContext<MongoIdentityDbContext>(options => options
                .UseMongoDB(new MongoClient(MongoDbConnectionString), "teaching_blazor_app"));
    
    var app = builder.Build();
    
    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
        app.UseSwagger();
        app.UseSwaggerUI();
    }
    app.Initialize(builder.Configuration);
    app.UseHttpsRedirection();
    
    app.UseRouting();
    
    app.UseEndpoints(endpoints =>
                {
                    endpoints.MapGet("/", async context =>
                    {
                        await context.Response.WriteAsync("Hello From ASP.NET Core Web API");
                    });
                    endpoints.MapGet("/Resource1", async context =>
                    {
                        await context.Response.WriteAsync("Hello From ASP.NET Core Web API Resource1");
                    });
                    endpoints.MapControllerRoute(
                      name: "Admin",
                      pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
                    endpoints.MapControllerRoute(
                      name: "default",
                      pattern: "{controller=Home}/{action=Index}/{id?}");
                });
    
    app.Run();
    
    record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
    {
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
    
    
  9. Swagger测试,使用之前自动生成的SuperUser账号信息:

    在这里插入图片描述

  10. 同时运行TeachingBlazorApp和TeachingWebApi应用,使用Login页面登录并看到成功登录的提示:

    在这里插入图片描述

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐