管理基于Webassembly的Blazor中的身份验证令牌到期

Blazor WebAsseMbly项目模板不具有选项 包括身份验证。如果你想添加 对基于Webassembly的制布应用程序进行身份验证,您需要执行此操作 你自己。本文展示了如何添加应用程序范围的身份验证 管理然后使用内置剃刀组件 保护FetchData页面从未经授权的用户中的标准模板中。

在查看身份验证时有一些良好的起点 布拉泽申请。这 官方文档解释了如何将身份验证应用于Blazor Server application。在客户端,克里斯舜天看了 使用Identity数据库管理身份验证 在他的一个 一系列全品牌文章。和史蒂夫桑德森(主要海角门口 at Microsoft) 提供他在NDC oslo上显示的演示应用程序 今年6月。

与其他示例一样,本文将显示如何使用Web API 端点将JSON Web令牌(JWT)发出到验证的用户。在那里 关于其他例子的文章建立在展示如何管理 令牌在浏览器中到期。

warning Warning

就像输入验证一样,可以避免在Blazor中的客户端身份验证和授权管理。 因此,您可以正确保护服务器端非常重要 resources as well.

这个演练从标准的ASP.NET核心托管的网页安装开始 Blazor project:

海滩 Wasm

如果你想复制和粘贴,我已经叫我的Blazorwasmaechentication 从这里的代码。生成的解决方案包括3个项目:服务器,客户端 并分享。每个都需要修改。

修改共享项目

第一次改变是对的 共享 项目。这是 .NET类库,包含共享的代码(主要是模型类) 在客户端和服务器项目之间。添加两个类, Credentials and LoginResult:

using System.ComponentModel.DataAnnotations;

namespace BlazorWasmAuthentication.Shared
{
    public class Credentials
    {
        [Required]
        public string Email { get; set; }

        [Required]
        public string Password { get; set; }
    }
}
using System;

namespace BlazorWasmAuthentication.Shared
{
    public class LoginResult
    {
        public string Token { get; set; }
        public DateTime Expiry { get; set; }
    }
}

修改服务器项目

服务器项目需要一些修正案。它需要配置 使用JWT持票人使用ASP.NET核心身份验证管理 令牌。它还需要提供允许用户进行身份验证的API, 它需要安全地存储授权用户的凭据。

要简化事物,我不会为用户配置身份数据库 证书。 Chris Sainty提供有关如何执行此操作的清晰说明 他的文章,你应该需要帮助。这个例子的凭据将是 存储在AppSettings文件中,使用密码使用 本文介绍的身份密码HASHER.

  1. 加一个 appsettings.json. 使用以下内容文件到服务器项目:
    {
      "Jwt": {
        "Key": ITNN8mPfS2ivOqr1eRWK0Rac3sRAchQdG8BUy0pK4vQ3",
        "Issuer": "MyApp",
        "Audience": "MyAppAudience"
      },
      "Credentials": {
        "Email": "[email protected]",
        "Password": "AQAAAAEAACcQAAAAENsLEigZGIs6kEdhJ7X1d7ChFZ4TKQHHYZCDoLSiPYy/GpYw4lmMOalsn8g/7debnA=="
      }
    }
    
    密码已被哈希。它的原始值是“测试密码”。
  2. 下一步是修改项目以包括额外的包装: Microsoft.AspNetCore.Authentication.JwtBearer。您可以以任何方式添加此方法。最简单的方法是向项目文件添加包引用:
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.0.0" />
  3. 接下来,您需要将应用程序配置为使用JWT承载令牌。 这是在启动中完成的,首先需要添加一些 using directives:
    using System.Text;
    using Microsoft.IdentityModel.Tokens;
    using Microsoft.AspNetCore.Authentication.JwtBearer;
    using Microsoft.Extensions.Configuration;
  4. Then you need to access the Configuration API. Inject the IConfiguration service into a constructor, and assign it to a public property:
    public Startup(IConfiguration configuration) => Configuration = configuration;
    
    public IConfiguration Configuration { get; }
  5. Configure authentication in ConfigureServices:
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = Configuration["Jwt:Issuer"],
            ValidAudience = Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]))
        };
    }); 
  6. 然后将身份验证和授权中间件添加到请求管道中 Configure 方法。确保在路由后和端点配置之前添加它们:
    app.UseAuthentication();
    app.UseAuthorization();
  7. 加一个 [Authorize] attribute to the existing WeatherForecast controller:
    namespace BlazorWasmAuthentication.Server.Controllers
    {
        [Authorize]
        [ApiController]
        [Route("[controller]")]
        public class WeatherForecastController : ControllerBase
        {
    
    记住本文顶部的警告,这是一个重要的一步。如果您不希望未经授权的用户能够访问天气预报服务提供的信息,则使用客户端代码是不够的 防止访问。任何具有相当基本的浏览器开发者工具知识的人 可能能够规避客户端限制。
  8. 最后,创建一个名为的Web API控制器 Logincontoller.:
    using BlazorWasmAuthentication.Shared;
    using Microsoft.AspNetCore.Identity;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Configuration;
    using Microsoft.IdentityModel.Tokens;
    using System;
    using System.IdentityModel.Tokens.Jwt;
    using System.Security.Claims;
    using System.Text;
    
    namespace BlazorWasmAuthentication.Server.Controllers
    {
    
        [ApiController]
        public class LoginController : ControllerBase
        {
            private readonly IConfiguration_configuration;
    
            public LoginController(IConfiguration configuration) => _configuration = configuration;
    
            [HttpPost("api/login")]
    
            public LoginResult Login(Credentials credentials)
            {
                var expiry = DateTime.Now.AddMinutes(2);
                return ValidateCredentials(credentials) ? new LoginResult { Token = GenerateJWT(credentials.Email, expiry), Expiry = expiry } : new LoginResult();
            }
    
            bool ValidateCredentials(Credentials credentials)
            {
              var user = _configuration.GetSection("Credentials").Get<Credentials>();
              var passwordHasher = new PasswordHasher<string>();
              return passwordHasher.VerifyHashedPassword(null, user.Password, credentials.Password) == PasswordVerificationResult.Success;
            }
    
            private string GenerateJWT(string email, DateTime expiry)
            {
              var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]));
              var token = new JwtSecurityToken(
                  _configuration["Jwt:Issuer"],
                  _configuration["Jwt:Audience"],
                  new[] { new Claim(ClaimTypes.Name, email) },
                  expires: expiry,
                  signingCredentials: new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256)
              );
              var tokenHandler = new JwtSecurityTokenHandler();
              return tokenHandler.WriteToken(token);
            }
        }
    }
    出于示范的目的,令牌到期将设定为2分钟。这是为了让您可以在未衰老的情况下测试到期。 Web API入口点验证凭据。在此示例中,代码只是读取存储在配置文件中的凭据并将其与发布值进行比较。如果他们有效,一个 LoginResult 用令牌返回完整的返回。否则是空的 LoginResult 被退回。生成令牌的代码 是一个很多样板,直接抬起史蒂夫桑德森 demo.

客户应用程序

客户端应用程序中的身份验证管理 依靠两个主要演员:源于的阶级 AuthenticationStateProvider, implementing its GetAuthenticationStateAsync method; and a CascadingAuthenticationState component. The CascadingAuthenticationState component obtains the current authentication state of the user by subscribing to the AuthenticationStateProvider's AuthenticationStateChanged event. Then the CascadingAuthenticationState component makes that information available to children via a cascading value of type Task<AuthenticationState>. The AuthenticationStateProvider is responsible for setting the 用户身份验证状态。

  1. 首先添加包引用 Microsoft.AspNetCore.Components.Authorization 在客户项目中 csproj file:
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.0-preview1.19508.20" />
  2. Add a using directive to the _imports.razor. 文件将包的内容带入范围 以及ASP.NET核心身份验证包:
    @using Microsoft.AspNetCore.Authorization
    @using Microsoft.AspNetCore.Components.Authorization
  3. 添加名为的文件夹 AuthenticationStateProviders.在它内部,添加一个名为c#类文件 tokenauthenticationstateprovider.cs. 使用以下代码:
    using Microsoft.AspNetCore.Components.Authorization;
    using Microsoft.JSInterop;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Security.Claims;
    using System.Text.Json;
    using System.Threading.Tasks;
    
    namespace BlazorWasmAuthentication.Client.AuthenticationStateProviders.
    {
        public class TokenAuthenticationStateProvider : AuthenticationStateProvider
        {
            private readonly IJSRuntime_jsRuntime;
    
            public TokenAuthenticationStateProvider(IJSRuntime jsRuntime)
            {
                _jsRuntime = jsRuntime;
            }
            
            public async Task SetTokenAsync(string token, DateTime expiry = default)
            {
                if (token == null)
                {
                    await _jsRuntime.InvokeAsync<object>("localStorage.removeItem", "authToken");
                    await _jsRuntime.InvokeAsync<object>("localStorage.removeItem", "authTokenExpiry");
                }
                else
                {
                    await _jsRuntime.InvokeAsync<object>("localStorage.setItem", "authToken", token);
                    await _jsRuntime.InvokeAsync<object>("localStorage.setItem", "authTokenExpiry", expiry);
                }
    
                NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
            }
    
            public async Task<string> GetTokenAsync()
            {
                var expiry = await _jsRuntime.InvokeAsync<object>("localStorage.getItem", "authTokenExpiry");
                if(expiry != null)
                {
                    if(DateTime.Parse(expiry.ToString()) > DateTime.Now)
                    {
                        return await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "authToken");
                    }
                    else
                    {
                        await SetTokenAsync(null);
                    }
                }    
                return null;
            }
    
    
            public override async Task<AuthenticationState> GetAuthenticationStateAsync()
            {
                var token = await GetTokenAsync();
                var identity = string.IsNullOrEmpty(token)
                    ? new ClaimsIdentity()
                    : new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
                return new AuthenticationState(new ClaimsPrincipal(identity));
            }
    
            private static IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
            {
                var payload = jwt.Split('.')[1];
                var jsonBytes = ParseBase64WithoutPadding(payload);
                var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
                return keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()));
            }
    
            private static byte[] ParseBase64WithoutPadding(string base64)
            {
                switch (base64.Length % 4)
                {
                    case 2: base64 += "=="; break;
                    case 3: base64 += "="; break;
                }
                return Convert.FromBase64String(base64);
            }
        }
    }
    This code is largely based on the Mission Control demo. The AuthenticationStateProvider includes a SetTokenAsync 方法和A. GetTokenAsync method. The SetTokenAsync method uses Blazor's JavaScript interop service to use the browser's local storage feaure to store the token, if one is provided. It also stores the token's expiry time. If no token is provided, the method removes both the 与令牌有关的存储键及其到期时间,有效地记录用户。最后,方法调用 NotifyAuthenticationStateChanged,它提升了 AuthenticationStateChanged 事件是 CascadingAuthenticationState 组件订阅,更新 CascadingAuthenticationState 关于当前的组件 用户身份验证状态。

    The GetTokenAsync method checks the expiry time of the token. If the expiry time has expired, the SetToken 在没有提供令牌的情况下调用方法,记录用户。否则返回有效的令牌,如果存在一个。

    最终的公共方法,必须在派生中覆盖 AuthenticationStateProvider, is the GetAuthenticationStateAsync method. This method parses the JSON Web Token and creates a ClaimsPrincipal (表示当前用户) identity information (ClaimsIdentity) obtained from the token, or an empty ClaimsIdentity if no token exists.
    info 解析JWT的方法取自任务控制演示。 JWT包含三个部分:标题,有效载荷(源代码) ClaimsIdentity 信息)和签名。每个部分都是 Base64 URL编码,然后使用点连接部件。最终输出例如 header.payload.signature 形成令牌。使用Base64 URL编码时, 输出填充 是可选的,实际上不包括在JWT的生成中。这 System.Convert.FromBase64String 方法期望输入字符串具有输出填充 where necessary, and will raise a FormatException if it is 丢失的。因此 课程结尾的其他私有方法用于放置填充 characters (=) on to the end of the payload if they are needed before the string is decoded.
  4. The AuthenticationStateProvider needs to be registered with 依赖注入系统。这是在的 ConfigureServices 启动方法。还会向应用程序添加身份验证服务:
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthorizationCore();
        services.AddScoped<TokenAuthenticationStateProvider>();
        services.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<TokenAuthenticationStateProvider>());
    }
    The TokenAuthenticationStateProvider is registered so that it can be injected directly into components etc, and then the injected service 注册为实施 AuthenticationStateProvider。这不一定是推荐的模式。但它使演示更简单。如果要采用更强大的方法,请移动获取和设置令牌的方法 from the TokenAuthenticationStateProvider into a separate service and use that where this demo explicitly injects the TokenAuthenticationStateProvider。查看 Chris Sainty's authservice 有些灵感。
  5. 下一步涉及创建登录表单。向其中添加新的剃刀组件 页面 文件夹命名 login.razor. 以下代码:
    @inject HttpClient Http
    @inject TokenAuthenticationStateProvider AuthStateProvider
    
    <div class="container col-6">
        @if (loginFailure)
        {
            <div class="alert alert-danger">Your credentials did not work. Please try again.</div>
        }
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">Login</h5>
                  <EditForm @ref="loginform" Model="credentials" OnValidSubmit="SubmitCredentials">
                    <DataAnnotationsValidator />
    
                    <div class="form-group">>
                        <label>Email address</label>
                        <InputText class="form-control" @bind-Value="credentials.Email" />
                        <ValidationMessage For="@(()=> credentials.Email)" />
                    </div>
                    <div class="form-group">
                        <label>Password</label>
                        <InputText type="password" class="form-control" @bind-Value="credentials.Password" />
                        <ValidationMessage For="@(()=> credentials.Password)" />
                    <div/>
                    <button type="submit" class="btn btn-outline-primary btn-sm">Submit</button>
                </EditForm>
            </div>
        </div>
    </div>
    @code {
        Credentials credentials = new Credentials();
        bool loginFailure;
    
        EditForm loginform { get; set; }
    
        async Task SubmitCredentials()
        {
            var result = await Http.PostJsonAsync<LoginResult>("api/login", credentials);
            loginFailure = result.Token == null;
            if (!loginFailure)
            {
                await AuthStateProvider.SetTokenAsync(result.Token, result.Expiry);
            }
        }
    }
    
    There is not much to explan here. If the form validation succeeds, the SubmitCredentials method is called. If the login is successful (indicated by the presence of a token in the response from the LoginController), the injected TokenAuthenticationStateProvider 设置令牌,您记得,您记得,导致身份验证状态正在使用订阅的任何组件更新 NotifyAuthenticationStateChanged 事件。
  6. Now it's time to introduce the component that does subscribe to the NotifyAuthenticationStateChanged event, the CascadingAuthenticationState 成分。打开 app.razor. 文件并用以下内容替换现有内容:
    <CascadingAuthenticationState>
        <Router AppAssembly="@typeof(Program).Assembly">
            <Found Context="routeData">
                <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                    <NotAuthorized>
                        <Login/>
                    </NotAuthorized>
                </AuthorizeRouteView>
            </Found>
            <NotFound>
                <LayoutView Layout="@typeof(MainLayout)">
                    <p>Sorry, there's nothing at this address.</p>
                </LayoutView>
            </NotFound>
        </Router>
    </CascadingAuthenticationState>
    
    First, you added the wrapped the entire application in the CascadingAuthenticationState component, ensuring that any other application component is able to receive its Task<AuthenticationState> 级联值作为参数。你改变了 RouteView component for an AuthorizeRouteView, which does the same except that it only displays the content of the page if the user is authenticated. If the user is not authenticated, the child content of the NotAuthorized 组件显示,即您刚刚创建的登录组件。
  7. 更改fetchdata.razor文件的顶部,查看如下所示:
    @page "/fetchdata"
    @using BlazorWasmAuthentication.Shared
    @using System.Net.Http.Headers;
    @inject HttpClient Http
    @inject TokenAuthenticationStateProvider TokenProvider
    @attribute[Authorize]
    <h1>Weather forecast</h1>
    You changes involve the addition of a using directive to bring System.Net.Http.Headers into scope; you injected the TokenAuthenticationStateProvider;你添加了一个 [Authorize] 属于页面的属性。如果您尝试在此阶段运行该页面,则应查看您创建的登录表单:
    登录表格
  8. Now amend the @code block in fetchdata. as follows:
    @code {
        private WeatherForecast[] forecasts;
    
        protected override async Task OnInitializedAsync()
        {
            var token = await TokenProvider.GetTokenAsync();
            if (token != null)
            {
                Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
                forecasts = await Http.GetJsonAsync<WeatherForecast[]>("WeatherForecast");
            }
        }
    }
    
    已更改现有代码以获取JWT令牌,然后将其添加到API请求作为请求标题的天气预报数据。如果没有此情况,API无法验证用户的方法。 Remember, the GetTokenAsync method will log the user out if the 令牌已过期。如果发生这种情况,请将用户呈现登录 form again.
  9. 打开 mainlayout.razor. 文件并使用以下代码替换约会链接:
    <AuthorizeView>Logged in as @context.User.Identity.Name 
        <button class="btn btn-sm btn-outline-dark" @onclick="@(() => TokenProvider.SetTokenAsync(null))">Logout</button>
    </AuthorizeView>
    

最后一步完成了演示。确保服务器项目设置为启动项目,请在浏览器中运行应用程序。导航到FetchData页面并登录。您应该看到数据,以及页面顶部的消息告诉您您将与注销按钮一起登录。单击它,您应该再次与登录表格呈现。 这次,在您登录后,等待几分钟。然后 刷新页面。您应该注销并呈现登录 form again.

概括

海滩 Application中最重要的身份验证部分是 保护服务器上的资源。客户端身份验证 管理主要在控制用户可以看到的内容周围旋转。该过程的一部分包括确保用户没有得到任何不幸的体验,例如尝试使用过期令牌而产生的错误或冻结屏幕。本文显示了一个管理该方法的方法。