Blazor Server获取Token访问外部Web Api
Identity Server系列目录
- Blazor Server访问Identity Server 4单点登录 - SunnyTrudeau - 博客园 (cnblogs.com)
- Blazor Server访问Identity Server 4单点登录2-集成Asp.Net角色 - SunnyTrudeau - 博客园 (cnblogs.com)
- Blazor Server访问Identity Server 4-手机验证码登录 - SunnyTrudeau - 博客园 (cnblogs.com)
- Blazor MAUI客户端访问Identity Server登录 - SunnyTrudeau - 博客园 (cnblogs.com)
- 在Identity Server 4项目集成Blazor组件 - SunnyTrudeau - 博客园 (cnblogs.com)
- Identity Server 4退出登录自动跳转返回 - SunnyTrudeau - 博客园 (cnblogs.com)
- Identity Server通过ProfileService返回用户角色 - SunnyTrudeau - 博客园 (cnblogs.com)
- Identity Server 4返回自定义用户Claim - SunnyTrudeau - 博客园 (cnblogs.com)
一个企业内部可能包含好几个不同业务的子系统,所有子系统共用一个Identity Server 4认证中心,用户在一个子系统登录之后,可以获取token访问其他子系统受保护的Web Api。关于Blazor Server项目如何获取token,微软官网有介绍:ASP.NET Core Blazor Server 其他安全方案 | Microsoft Docs
新建Web Api项目
项目名称MyWebApi,用模板创建的WeatherForecastController足以。
Program.cs增加认证和授权的配置,Web Api项目采用Bearer认证。
//NuGet安装Microsoft.AspNetCore.Authentication.JwtBearer //NuGet安装IdentityServer4.AccessTokenValidation builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddIdentityServerAuthentication(JwtBearerDefaults.AuthenticationScheme, options => { //指定IdentityServer的地址 options.Authority = "https://localhost:5001"; ; options.ApiName = "https://localhost:5001/resources"; }); //添加认证和授权 app.UseAuthentication(); app.UseAuthorization();
WeatherForecastController.cs控制器增加访问权限
[ApiController] [Route("[controller]")] [Authorize] public class WeatherForecastController : ControllerBase
增加打印HttpContext获取token和用户声明claims调试信息。
[HttpGet(Name = "GetWeatherForecast")] public async Task<IEnumerable<WeatherForecast>> Get() { var claims = User.Claims.Select(x => $"{x.Type}={x.Value}").ToList(); var accessToken = await HttpContext.GetTokenAsync("access_token"); var refreshToken = await HttpContext.GetTokenAsync("refresh_token"); string msg = $"从HttpContext获取accessToken={accessToken}{Environment.NewLine}, refreshToken={refreshToken}{Environment.NewLine}, 用户声明={string.Join(", ", claims)}"; _logger.LogInformation(msg); return Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateTime.Now.AddDays(index), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }) .ToArray(); }
Blazor Server项目获取token
BlzOidc项目参考官网代码,通过_Host.cshtml网页的HttpContext,获取token。
首先定义token提供者
public class TokenProvider { public string? AccessToken { get; set; } public string? RefreshToken { get; set; } }
Program.cs注册Token提供者
//注册Token提供者
builder.Services.AddScoped<TokenProvider>();
在 Pages/_Host.cshtml 文件中,通过HttpContext获取Token,作为参数传递到App.razor组件。
@page "/" @namespace BlzOidc.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Authentication @using BlzOidc.Data @{ Layout = "_Layout"; } @{ var tokens = new TokenProvider { AccessToken = await HttpContext.GetTokenAsync("access_token"), RefreshToken = await HttpContext.GetTokenAsync("refresh_token") }; } <component type="typeof(App)" render-mode="ServerPrerendered" param-InitialToken="tokens" />
App.razor组件保存Token,这些都可以抄跟官网代码,但是我不明白,为什么在_Host.cshtml中获取到Token,还要传到App.razor去保存呢?直接在_Host.cshtml保存不行吗?
@using BlzOidc.Data @inject TokenProvider TokenProvider @code { [Parameter] public TokenProvider? InitialToken { get; set; } protected override Task OnInitializedAsync() { TokenProvider.AccessToken = InitialToken?.AccessToken; TokenProvider.RefreshToken = InitialToken?.RefreshToken; Console.WriteLine($"初始化获取AccessToken={TokenProvider.AccessToken}, RefreshToken={TokenProvider.RefreshToken}"); return base.OnInitializedAsync(); } }
官网代码是每次在HttpClient手动填充token然后访问外部Web Api的
public class WeatherForecastService { private readonly HttpClient http; private readonly TokenProvider tokenProvider; public WeatherForecastService(IHttpClientFactory clientFactory, TokenProvider tokenProvider) { http = clientFactory.CreateClient(); this.tokenProvider = tokenProvider; } public async Task<WeatherForecast[]> GetForecastAsync() { var token = tokenProvider.AccessToken; var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:5003/WeatherForecast"); request.Headers.Add("Authorization", $"Bearer {token}"); var response = await http.SendAsync(request); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsAsync<WeatherForecast[]>(); } }
我更喜欢写一个类型化的HttpClient,然后注册服务。
using System.Net.Http.Headers; namespace BlzOidc.Data { public class WeatherForecastApiClient { private readonly HttpClient _httpClient; private readonly TokenProvider _tokenProvider; public WeatherForecastApiClient(HttpClient httpClient, TokenProvider tokenProvider) { _httpClient = httpClient; _tokenProvider = tokenProvider; } public async Task<WeatherForecast[]?> GetForecastAsync() { var token = _tokenProvider.AccessToken; _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var result = await _httpClient.GetFromJsonAsync<WeatherForecast[]>("/WeatherForecast"); return result; } } }
Program.cs注册服务
//注册WeatherForecastApiClient
builder.Services.AddHttpClient<WeatherForecastApiClient>(client => client.BaseAddress = new Uri("https://localhost:5601"));
然后在网页上注入类型化的WeatherForecastApiClient去MyWebApi获取天气数据。
@page "/fetchdataapi" @attribute [Authorize] <PageTitle>Weather forecast</PageTitle> @using BlzOidc.Data @inject WeatherForecastApiClient ForecastService <h1>Weather forecast</h1> <p>This component demonstrates fetching data from a service.</p> @if (forecasts == null) { <p><em>Loading...</em></p> } else { <table class="table"> <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> </tr> </thead> <tbody> @foreach (var forecast in forecasts) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> </tr> } </tbody> </table> } @code { private WeatherForecast[]? forecasts; protected override async Task OnInitializedAsync() { forecasts = await ForecastService.GetForecastAsync(); } }
同时运行AspNetId4Web认证服务器,MyWebApi项目,BlzOidc项目,在BlzOidc项目未登录状态下直接访问Fetch Data Api菜单,浏览器自动跳转到AspNetId4Web登录页面,输入种子用户的手机号13512345001获取验证码,看AspNetId4Web控制台输出获取验证码,填写验证码登录,浏览器自动跳转回到BlzOidc项目的Fetch Data Api页面,获取到了天气数据。
问题
MyWebApi认证参数ApiName究竟应该填写什么值?默认情况下,它是Identity Server 4服务器的一个固定路由:
options.ApiName = "https://localhost:5001/resources";
我也可以修改config.cs
public static IEnumerable<ApiResource> ApiResources => new ApiResource[] { new ApiResource("api1", "api1") { Scopes = { "scope1" }, //认证服务器返回的附加身份属性 UserClaims = { //增加aud="api1" JwtClaimTypes.Audience, }, } };
AddIdentityServer增加配置AddInMemoryApiResources
var builder = services.AddIdentityServer(options => { options.Events.RaiseErrorEvents = true; options.Events.RaiseInformationEvents = true; options.Events.RaiseFailureEvents = true; options.Events.RaiseSuccessEvents = true; // see https://identityserver4.readthedocs.io/en/latest/topics/resources.html options.EmitStaticAudienceClaim = true; }) .AddInMemoryIdentityResources(Config.IdentityResources) .AddInMemoryApiScopes(Config.ApiScopes) .AddInMemoryClients(Config.Clients) .AddExtensionGrantValidator<PhoneCodeGrantValidator>() .AddInMemoryApiResources(Config.ApiResources) .AddAspNetIdentity<ApplicationUser>();
Web Api的认证参数就可以采用”api1”了
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddIdentityServerAuthentication(JwtBearerDefaults.AuthenticationScheme, options => { //指定IdentityServer的地址 options.Authority = "https://localhost:5001"; ; //默认aud="https://localhost:5001/resources" //options.ApiName = "https://localhost:5001/resources"; //Bearer was not authenticated. Failure message: IDX10214: Audience validation failed. Audiences: 'System.String'. Did not match: validationParameters.ValidAudience: 'System.String' or validationParameters.ValidAudiences: 'System.String'. //Identity Server 4 config.cs的ApiResources增加JwtClaimTypes.Audience,AddInMemoryApiResources(Config.ApiResources),可以增加aud="api1" options.ApiName = "api1"; });
此时可以查看Identity Server 4返回的token,它确实有2个aud:
[20:54:59 Debug] IdentityServer4.Validation.TokenValidator
Token validation success
{"ClientId": null, "ClientName": null, "ValidateLifetime": true, "AccessTokenType": "Jwt", "ExpectedScope": "openid", "TokenHandle": null, "JwtId": "4F3D0DE1EE8E8DEFD3EC27E602F0790C", "Claims": {"nbf": 1638708899, "exp": 1638712499, "iss": "https://localhost:5001", "aud": ["api1", "https://localhost:5001/resources"], "client_id": "BlazorServerOidc", "sub": "d2f64bb2-789a-4546-9107-547fcb9cdfce", "auth_time": 1638708898, "idp": "local", "name": "Alice Smith", "role": ["Admin", "Guest"], "email": "AliceSmith@email.com", "phone_number": "13512345001", "nation": "汉族", "jti": "4F3D0DE1EE8E8DEFD3EC27E602F0790C", "sid": "FDB59080B24468B76300AE9554354D67", "iat": 1638708899, "scope": ["openid", "profile", "scope1"], "amr": "pwd"}, "$type": "TokenValidationLog"}
在MyWebApi项目打印出来的token也有2个aud:
info: MyWebApi.Controllers.WeatherForecastController[0]
从HttpContext获取accessToken=eyJh...WlA
, refreshToken=
, 用户声明=nbf=1638708899, exp=1638712499, iss=https://localhost:5001, aud=api1, aud=https://localhost:5001/resources, client_id=BlazorServerOidc, sub=d2f64bb2-789a-4546-9107-547fcb9cdfce, auth_time=1638708898, idp=local, name=Alice Smith, role=Admin, role=Guest, email=AliceSmith@email.com, phone_number=13512345001, nation=汉族, jti=4F3D0DE1EE8E8DEFD3EC27E602F0790C, sid=FDB59080B24468B76300AE9554354D67, iat=1638708899, scope=openid, scope=profile, scope=scope1, amr=pwd
我也不是很理解,但是感觉用api1好一点。
DEMO代码地址:https://gitee.com/woodsun/blzid4