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