Blazor WebAssembly&JWT鉴权授权

目录

前后端分离下,后端专注于业务逻辑,前端专注于页面交互,Blazor WebAssembly令后端开发者降低学习成本,也能够玩转一些前端开发功能。本文仅简单介绍前后端交互,借助于Blazor WebAssembly如何调用接口,鉴权授权。

新建项目

前端项目为Blazor WebAssembly(Standalone)模板,后端为WebApi。

203937537_887f7549-da3d-4be5-b948-5b8b112ee79e 本文使用DotNet CLI快速搭建。

dotnet new blazorwasm -n AppDemo -f net9.0
dotnet new webapi -n ApiDemo -f net9.0 -controllers
dotnet new sln
dotnet sln add .\AppDemo\
dotnet sln add .\ApiDemo\

服务端令牌接口

安装Nuget包

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

本次不使用数据库验证,直接模拟返回一个JWT。

[ApiController]
[Route("api/[controller]")]
public class AccountController : ControllerBase
{
    private const string SecretKey = "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
    private const string Issuer = "ApiDemo";
    private const string Audience = "ApiDemo";
    private const int ExpirationMinutes = 60;

    [Authorize]
    [HttpGet("CurrentUserName")]
    public string GetCurrentUserName()
    {
        return "admin";
    }

    [HttpPost("Login")]
    public IActionResult Login([FromBody] LoginRequest request)
    {
        // 示例:简单的硬编码验证(实际项目中应该从数据库验证)
        if (request.Username == "admin" && request.Password == "admin123")
        {
            var token = GenerateJwtToken(request.Username);
            var expires = DateTime.UtcNow.AddMinutes(ExpirationMinutes);

            return Ok(new LoginResponse
            {
                Token = token,
                Expires = expires
            });
        }

        return Unauthorized(new { message = "用户名或密码错误" });
    }

    private string GenerateJwtToken(string username)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
        var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(ClaimTypes.Name, username),
            new Claim(ClaimTypes.NameIdentifier, username),
            new Claim(JwtRegisteredClaimNames.Sub, username),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        var token = new JwtSecurityToken(
            issuer: Issuer,
            audience: Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(ExpirationMinutes),
            signingCredentials: credentials
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

public class LoginRequest
{
    [Required(ErrorMessage = "用户名不能为空")]
    public required string Username { get; set; }

    [Required(ErrorMessage = "密码不能为空")]
    public required string Password { get; set; }
}

public class LoginResponse
{
    public string Token { get; set; } = string.Empty;
    public DateTime Expires { get; set; }
}

服务端鉴权配置

简单使用明文方式配置鉴权服务。设定默认Schema为Jwt格式。

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

const string secretKey = "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
const string issuer = "ApiDemo";
const string audience = "ApiDemo";

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
    });
});
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = issuer,
        ValidAudience = audience,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey))
    };
});

builder.Services.AddAuthorization();

var app = builder.Build();
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

服务端设置完毕,仅当用户名密码正确下返回JWT。

客户端请求令牌

Blazor WebAssembly项目中,分开Http的实现通常是个好的做法,新建一个HttpService,并实现请求后端令牌地址。

public class HttpService
{
    private readonly HttpClient _httpClient;

    public HttpService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<LoginResponse?> LoginAsync(LoginRequest loginRequest)
    {
        var response = await _httpClient.PostAsJsonAsync("api/account/login", loginRequest);
        if (response.IsSuccessStatusCode)
        {
            var loginResponse = await response.Content.ReadFromJsonAsync<LoginResponse>();
            return loginResponse!;
        }

        return null;
    }
}

public class LoginRequest
{
    public required string Username { get; set; }
    public required string Password { get; set; }
}

public class LoginResponse
{
    public string Token { get; set; } = string.Empty;
    public DateTime Expires { get; set; }
}

将其注册到服务容器中

builder.Services.AddScoped<HttpService>();

实现一个简单登录页,调用后端拿到Token。

@page "/login"
@layout EmptyLayout
@using AppDemo.Infrastructures
@inject HttpService _httpService
@inject NavigationManager _navigationManager

<PageTitle>Login</PageTitle>

<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 300px;">
    <h1>AppDemo</h1>
    <input type="text" @bind="username" class="form-control mb-2" style="max-width: 300px;" placeholder="Username" />
    <input type="password" @bind="password" class="form-control mb-2" style="max-width: 300px;" placeholder="Password" />
    <button @onclick="LoginAsync" class="btn btn-primary" style="width: 300px;">Login</button>
</div>

@code{
    private string username = string.Empty;
    private string password = string.Empty;

    private async Task LoginAsync()
    {
        var loginRequest = new LoginRequest { Username = username, Password = password };
        var loginResponse = await _httpService.LoginAsync(loginRequest);
        if (loginResponse != null)
        {
            _navigationManager.NavigateTo("/");
        }
        else
        {
            Console.WriteLine("Login failed");
        }
    }
}

如此,登录完成后跳转到首页,可是首页要获取用户信息没有token怎么请求,token该存储在哪里。即身份状态需要共享到其他组件中,以支持其他组件需求。 203939415_d676d3b7-2cec-448b-9878-199b5833cccc

客户端身份状态共享

在纯前端项目中,跨组件共享状态是普遍的。特别是身份验证状态,需要所有组件都可共享。在Blazor WebAssembly中,抽象好了一个AuthenticationStateProvider类来共享身份信息。包括获取身份信息。身份信息变更回调。为此实现一个自定义的StateProvider,在这其中管理JWT。

203940876_0d4279ce-302c-4c45-a09e-3f082fd49964 需要增加一个Nuget包

dotnet add package Microsoft.AspNetCore.Components.Authorization --version 9.0.10

自定义身份状态管理类,其身份状态先随便弄一个。

public class CustomAuthStateProvider : AuthenticationStateProvider
{
    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, "defaultUser")
        };
        var identity = new ClaimsIdentity(claims, "CustomAuth");
        var user = new ClaimsPrincipal(identity);
        return new AuthenticationState(user);
    }
}

注册鉴权服务、级联身份验证状态服务和自定义身份状态管理类

builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();

在首页中,获取当前用户信息。

<AuthorizeView>
    <Authorized>
        <p>Hello, @context.User.Identity?.Name! Welcome to your new app.</p>
    </Authorized>
    <NotAuthorized>
        <p>You're not authorized.</p>
    </NotAuthorized>
</AuthorizeView>

此时,仅仅是展示GetAuthenticationStateAsync中的默认值。为此改造一番,在登录完成后,获取到用户信息替换默认值。

客户端请求身份信息

为了令获取到的信息是当前登录人的信息,请求服务端Api,而请求过程需要已得到的Token,因此在登录完成后,需要将Token保存到HttpClient中,以便于在GetAuthenticationStateAsync中请求服务端Api时,能够正常请求。

203942249_c5ea3dc6-e7d8-4653-bfaf-e936386f11c3 在登录完成后,保存Token到HttpClient头中,以能够在下一次请求时正常。

public async Task<LoginResponse?> LoginAsync(LoginRequest loginRequest)
{
    var response = await _httpClient.PostAsJsonAsync("api/account/login", loginRequest);
    if (response.IsSuccessStatusCode)
    {
        var loginResponse = await response.Content.ReadFromJsonAsync<LoginResponse>();
        // 保存到请求头中
        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginResponse.Token);
        return loginResponse!;
    }

    return null;
}

并同步StateProvider获取用户信息,以及令身份状态更新

var loginRequest = new LoginRequest { Username = username, Password = password };
var loginResponse = await _httpService.LoginAsync(loginRequest);
if (loginResponse != null)
{
    await ((CustomAuthStateProvider)_authStateProvider).MarkUserAsAuthenticated();
    _navigationManager.NavigateTo("/");
}

在StateProvider中,先获取最新用户信息,在刷新身份状态,当GetAuthenticationStateAsync中请求服务端Api时,默认携带了请求头,因此不会出现401错误。

public class CustomAuthStateProvider : AuthenticationStateProvider
{
    private readonly HttpService _httpService;

    public CustomAuthStateProvider(HttpService httpService)
    {
        _httpService = httpService;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var user = new ClaimsPrincipal(new ClaimsIdentity());

        try
        {
            var currentUserName = await _httpService.GetCurrentUserName();
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.Name, currentUserName)
            };
            var identity = new ClaimsIdentity(claims, "CustomAuth");
            user = new ClaimsPrincipal(identity);
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }

        return new AuthenticationState(user);
    }

    public async Task MarkUserAsAuthenticated()
    {
        // 刷新身份状态
        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
    }
}

客户端令牌写入LocalStorage

这种方案下,令牌只是在内存中持有,当刷新页面、关闭Tab页或者关闭浏览器,下次再打开,又得重新登录获取,为此,需要将其存储到LocalStorage中。

203943478_a4f3474f-c725-4629-bd5f-20b79ebdd70f 新增Nuget包

dotnet add package Blazored.LocalStorage --version 4.5.0

服务注册

builder.Services.AddBlazoredLocalStorage();

只需修改HttpService,当每次实例化时,从LocalStorage中拿到Token,当登录完成后,存储Token即可。

public class HttpService
{
    private readonly HttpClient _httpClient;
    private readonly ISyncLocalStorageService _localStorageService;

    public HttpService(HttpClient httpClient, ISyncLocalStorageService localStorageService)
    {
        _httpClient = httpClient;
        _localStorageService = localStorageService;

        var token = _localStorageService.GetItem<string>("token");
        if (token != null)
        {
            _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        }
    }

    public async Task<LoginResponse?> LoginAsync(LoginRequest loginRequest)
    {
        var response = await _httpClient.PostAsJsonAsync("api/account/login", loginRequest);
        if (response.IsSuccessStatusCode)
        {
            var loginResponse = await response.Content.ReadFromJsonAsync<LoginResponse>();
            _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginResponse.Token);

            _localStorageService.SetItem("token", loginResponse.Token);

            return loginResponse!;
        }

        return null;
    }

    public async Task<string?> GetCurrentUserName()
    {
        var response = await _httpClient.GetAsync("api/account/CurrentUserName");
        if (response.IsSuccessStatusCode)
        {
            var currentUserName = await response.Content.ReadAsStringAsync();
            return currentUserName!;
        }

        return null;
    }
}

客户端退出

退出无非是清理LocalStorage中的Token,附带要更新StateProvider当前状态。

在HttpService内,清理LocalStorage中的Token,与重置生命周期内HttpClient的头。

public async Task LogoutAsync()
{
    _httpClient.DefaultRequestHeaders.Authorization = null;
    _localStorageService.RemoveItem("token");
}

在StateProvider内,更新身份状态。

public async Task MarkUserAsUnauthenticated()
{
    // 刷新身份状态
    NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}

最终点击Logout按钮触发事件内执行逻辑

private async Task Logout()
{
    await _httpService.LogoutAsync();
    await ((CustomAuthStateProvider)_authStateProvider).MarkUserAsUnauthenticated();
    _navigationManager.NavigateTo("/Login");
}

客户端路由权限控制

当浏览器中直接敲入目标地址,如果当前未处于登录状态且目标地址为鉴权完成才能查看,则需要对目标地址进行保护。

203944952_0cd4704e-bf34-42cb-b9b9-404e66ee71aa 默认的路由设定为未鉴权也可以访问,需要替换为鉴权完毕才能访问,在App.razor中替换,RouteView为AuthorizeRouteView

@using Microsoft.AspNetCore.Components.Authorization

<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        <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>

当没有鉴权通过时,可以进行重定向跳转到登录页。新建一个重定向组件。示例如下。

@inject NavigationManager Navigation

@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo("/login");
    }
}

应用到App.razor中便是根据身份状态判断,重定向到登录页或者提示没有访问权限。

<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
    <NotAuthorized>
        @if (context.User.Identity?.IsAuthenticated != true)
        {
            <RedirectToLogin />
        }
        else
        {
            <p role="alert">You are not authorized to access this resource.</p>
        }
    </NotAuthorized>
</AuthorizeRouteView>

客户端获取身份状态

在之前的示例中是借助于AuthorizeView,其内部封装好了user。

<AuthorizeView>
    <Authorized>
        <p>Hello, @context.User.Identity?.Name!</p>
    </Authorized>
    <NotAuthorized>
        <p>You're not authorized.</p>
    </NotAuthorized>
</AuthorizeView>

但是如果想要手动获取状态从而判断是否鉴权与否。可以通过Task参数。这个依赖于服务注册时的builder.Service.AddCascadingAuthenticationState,其内部实现是实例化Task。

@code {
    private string? _userName = "";

    [CascadingParameter]
    private Task<AuthenticationState>? authenticationState { get; set; }

    private async Task DoSomething()
    {
        if (authenticationState is not null)
        {
            var authState = await authenticationState;
            var user = authState?.User;

            if (user is not null)
            {
                if (user.Identity is not null && user.Identity.IsAuthenticated)
                {
                    _userName = user.Identity.Name;
                }
            }
        }
    }
}

可能会说是否可以直接注入AuthenticationStateProvider,可以但不建议,因为身份验证状态发生改变,不会自动通知组件。 https://learn.microsoft.com/zh-cn/aspnet/core/blazor/security/?view=aspnetcore-9.0&tabs=visual-studio#authenticationstateprovider-service

获取身份状态也就区分了使用场景,在Html中使用AuthorizeView,在Code中使用Task,这两个内部都是从AuthenticationStateProvider获取身份状态。

注意事项

Route上写是.Net8以前的用法,之后是采用服务注册builder.Service.AddCascadingAuthenticationState。很多文章视频中都是用的稀里糊涂。

https://learn.microsoft.com/zh-cn/aspnet/core/blazor/security/?view=aspnetcore-9.0&tabs=visual-studio#troubleshoot-errors

默认场景下,Task是需要依赖于AddCascadingAuthenticationState。但在使用了AuthorizeRouteView后,发现无需注册AddCascadingAuthenticationState,也能够正常使用Task,

203946211_03fc887f-6dbe-44c3-81ff-43e0650c9599 找到源码发现,AuthorizeRouteView中存在了一个判断逻辑,当没有时,会有一些处理逻辑。

https://source.dot.net/#Microsoft.AspNetCore.Components.Authorization/AuthorizeRouteView.cs,70

参考文档

https://learn.microsoft.com/zh-cn/aspnet/core/blazor/security/?view=aspnetcore-9.0&tabs=visual-studio#resource-authorization

2025-07-08,望技术有成后能回来看见自己的脚步。