Blazor Server 前后端不分离项目简单身份验证

此基于Blazor Server 应用通过使用 SignalR 创建的实时连接运行。建立连接时,将处理基于 SignalR 的应用中的身份验证。身份验证可以基于 Cookie 或其他一些不记名令牌。Blazor Server 应用的安全性配置方式与 ASP.NET Core 应用相同。

Blazor Server 应用的内置 AuthenticationStateProvider 服务从 ASP.NET Core 的 HttpContext.User 获取身份验证状态数据。这就是身份验证状态与现有 ASP.NET Core 身份验证机制集成的方式

项目结构

  1. 解决方案
    • MyBlazorApp.sln
  2. Blazor Server项目
    • MyBlazorApp (Blazor Server项目)
    • Pages/ – 包含所有的页面组件(*.razor
    • Components/ – 可复用的UI组件
    • Authentication/ – 身份验证服务相关文件夹
      • CustomAuthenticationStateProvider.cs/ – 自定义的认证状态提供器类
      • UserAccount.cs/ – 用户Dto
      • UserAccountService.cs/ – 用户服务
      • UserSession.cs/ – 用户的会话信息
    • Shared/ – 前后端共享的组件或代码
    • Data/ – 数据访问层和业务逻辑
    • Models/ – 数据模型
    • wwwroot/ – 静态文件,如CSS、JavaScript和图像
    • Program.cs – 配置Blazor Server应用程序的启动
    • Startup.cs – 配置服务和中间件(如果使用Startup类)

详细说明

  • Pages: 包含Blazor应用的页面,每个页面都由一个.razor文件组成。这些页面负责用户界面的展示和用户交互。
  • Components: 存放可复用的UI组件,组件可以在多个页面中使用,帮助减少代码重复。
  • Shared: 用于存放前后端共享的代码,如共享的组件或帮助类。
  • Data: 负责数据访问和业务逻辑。可以在这里实现与数据库的交互逻辑(如使用Entity Framework Core),以及任何必要的业务规则。
  • Models: 定义应用程序使用的数据模型。这些模型通常用于表示数据库中的实体或其他数据结构。
  • wwwroot: 包含所有的静态资源,如CSS样式表、JavaScript文件和图像文件。Blazor Server会将这些文件直接提供给浏览器。
  • Program.cs: 定义应用程序的入口点,配置服务(如依赖注入)和应用程序生命周期。
  • Startup.cs: 配置应用程序的请求管道和服务(如果使用Startup类)。包括中间件配置、路由设置等。

身份验证详解

Authentication/CustomAuthenticationStateProvider.cs

// 定义一个自定义的认证状态提供器类,继承自AuthenticationStateProvider
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
    // 使用ProtectedSessionStorage来安全地存储用户会话信息
    private readonly ProtectedSessionStorage _sessionStorage;
    // 定义一个匿名用户的ClaimsPrincipal对象,表示未认证的用户
    private ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());

    // 构造函数,初始化会话存储对象
    public CustomAuthenticationStateProvider(ProtectedSessionStorage sessionStorage)
    {
        _sessionStorage = sessionStorage;
    }

    // 重写基类的GetAuthenticationStateAsync方法,用于获取当前用户的认证状态
    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        try
        {
            // 从会话存储中异步获取用户会话信息
            var userSessionStorageResult = await _sessionStorage.GetAsync<UserSession>("UserSession");
            // 检查获取结果是否成功,如果成功则获取用户会话信息,否则为null
            var userSession = userSessionStorageResult.Success ? userSessionStorageResult.Value : null;

            // 如果用户会话信息为null,返回匿名用户的认证状态
            if (userSession == null)
                return await Task.FromResult(new AuthenticationState(_anonymous));

            // 如果用户会话信息存在,创建一个包含用户名和角色的ClaimsPrincipal对象
            var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>
            {
                new Claim(ClaimTypes.Name, userSession.UserName), // 用户名声明
                new Claim(ClaimTypes.Role, userSession.Role) // 角色声明
            }, "CustomAuth")); // 使用自定义认证类型

            // 返回包含用户信息的认证状态
            return await Task.FromResult(new AuthenticationState(claimsPrincipal));
        }
        catch
        {
            // 如果发生异常,返回匿名用户的认证状态
            return await Task.FromResult(new AuthenticationState(_anonymous));
        }
    }

    // 更新认证状态的方法,接收一个用户会话对象作为参数
    public async Task UpdateAuthenticationState(UserSession userSession)
    {
        ClaimsPrincipal claimsPrincipal;

        if (userSession != null)
        {
            // 如果用户会话信息不为null,将其存储到会话存储中
            await _sessionStorage.SetAsync("UserSession", userSession);
            // 创建包含用户名和角色的ClaimsPrincipal对象
            claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>
            {
                new Claim(ClaimTypes.Name, userSession.UserName), // 用户名声明
                new Claim(ClaimTypes.Role, userSession.Role) // 角色声明
            }));
        }
        else
        {
            // 如果用户会话信息为null,删除存储的会话信息
            await _sessionStorage.DeleteAsync("UserSession");
            // 设置为匿名用户
            claimsPrincipal = _anonymous;
        }

        // 通知认证状态已更改,传递新的认证状态
        NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(claimsPrincipal)));
    }
}

Authentication/UserAccount.cs

public class UserAccount
{
    public string UserName { get; set; }
    public string Password { get; set; }
    public string Role { get; set; }
}

Authentication/UserAccountService.cs

public class UserAccountService
{
    private List<UserAccount> _users;

    public UserAccountService()
    {
        _users = new List<UserAccount>
        {
            new UserAccount{ UserName = "admin", Password = "admin", Role = "Administrator" },
            new UserAccount{ UserName = "user", Password = "user", Role = "User" }
        };
    }

    public UserAccount? GetByUserName(string userName)
    {
        return _users.FirstOrDefault(x => x.UserName == userName);
    }
}

Authentication/UserSession.cs

public class UserSession
{
    public string UserName { get; set; }
    public string Role { get; set; }
}

Pages/Login.razor

@page "/login"
@using MyBlazorApp.Authentication
@inject UserAccountService userAccountService
@inject IJSRuntime js
@inject AuthenticationStateProvider authStateProvider
@inject NavigationManager navManager

<div class="row">
    <div class="col-lg-4 offset-lg-4 pt-4 pb-4 border">
        <div class="mb-3 text-center">
            <h3>LOGIN</h3>
        </div>
        <div class="mb-3">
            <label>User Name</label>
            <input @bind="model.UserName" class="form-control" placeholder="User Name" />
        </div>
        <div class="mb-3">
            <label>Password</label>
            <input @bind="model.Password" type="password" class="form-control" placeholder="Password" />
        </div>
        <div class="mb-3 d-grid gap-2">
            <button @onclick="Authenticate" class="btn btn-primary">Login</button>
        </div>
    </div>
</div>

@code {
    private class Model
    {
        public string UserName { get; set; } 
        public string Password { get; set; }
    } 
    private Model model = new Model(); 
    private async Task Authenticate()
    {
        var userAccount = userAccountService.GetByUserName(model.UserName); 
        if (userAccount == null || userAccount.Password != model.Password)
        {
            await js.InvokeVoidAsync("alert", "Invalid User Name or Password");
            return;
        } 
        var customAuthStateProvider = (CustomAuthenticationStateProvider)authStateProvider;
        await customAuthStateProvider.UpdateAuthenticationState(new UserSession
            {
                UserName = userAccount.UserName,
                Role = userAccount.Role
            });
        navManager.NavigateTo("/", true);
    }
} 

Shared/RedirectToLogin

@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation

<p>用户未授权正在重定向到登录页面...</p>

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            var authState = await AuthStateProvider.GetAuthenticationStateAsync();
            var user = authState.User; 
            if (user.Identity == null || !user.Identity.IsAuthenticated)
            {
                // 用户未认证,重定向到登录页面
                Navigation.NavigateTo("/login", true);
            }
        } 
    }
}

Shared/MainLayout.razor

@using MyBlazorApp.Authentication
@inherits LayoutComponentBase
@inject AuthenticationStateProvider authStateProvider
@inject NavigationManager navManager

<PageTitle>MyBlazorApp</PageTitle>

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
            <AuthorizeView>
                <Authorized>
                    <a @onclick="Logout" href="javascript:void(0)">Logout</a>
                </Authorized>
                <NotAuthorized>
                    <a href="/login">Login</a>
                </NotAuthorized>
            </AuthorizeView>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

@code{
    private async Task Logout()
    {
        var customAuthStateProvider = (CustomAuthenticationStateProvider)authStateProvider;
        await customAuthStateProvider.UpdateAuthenticationState(null);
        navManager.NavigateTo("/", true);
    }
}

App.razor

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    <RedirectToLogin />
                </NotAuthorized>
                <Authorizing>
                    You are getting authorized  
                </Authorizing>
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="@routeData" Selector="h1" />
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Program.cs

var builder = WebApplication.CreateBuilder(args); 
builder.Services.AddAuthenticationCore();
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddScoped<ProtectedSessionStorage>();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
builder.Services.AddSingleton<UserAccountService>();
builder.Services.AddSingleton<WeatherForecastService>();

var app = builder.Build(); 
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
}  
app.UseStaticFiles(); 
app.UseRouting(); 
app.MapBlazorHub();
app.MapFallbackToPage("/_Host"); 
app.Run();

使用方式

在页面中使用

@page "/counter"
@attribute [Authorize(Roles = "Administrator,User")]

在组件中使用

<AuthorizeView Roles="Administrator,User">
    <Authorized>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="counter">
                <span class="oi oi-plus" aria-hidden="true"></span> Counter
            </NavLink>
        </div>
    </Authorized>
</AuthorizeView>
<AuthorizeView Roles="Administrator">
    <Authorized>
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </div>
    </Authorized>
</AuthorizeView>