Blazor Server通过RefreshToken更新AccessToken
Blazor Server通过RefreshToken更新AccessToken
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)
- Blazor Server获取Token访问外部Web Api - SunnyTrudeau - 博客园 (cnblogs.com)
Identity Server 4返回Refresh Token
Identity Server 4返回的AccessToken默认有效期只有1小时,如果过期了,可以通过Refresh Token去更新。
为了研究AccessToken过期更新的问题,把AccessToken有效期改为1分钟,同时增加返回Refresh Token。修改AspNetId4Web项目的config.cs
new Client() { ClientId="BlazorServerOidc", ClientName = "BlazorServerOidc", ClientSecrets=new []{new Secret("BlazorServerOidc.Secret".Sha256())}, AllowedGrantTypes = GrantTypes.Code, AllowedCorsOrigins = { "https://localhost:5501" }, RedirectUris = { "https://localhost:5501/signin-oidc" }, PostLogoutRedirectUris = { "https://localhost:5501/signout-callback-oidc" }, //效果等同客户端项目配置options.GetClaimsFromUserInfoEndpoint = true //AlwaysIncludeUserClaimsInIdToken = true, //AllowedScopes = { "openid", "profile", "scope1", "role", } //通过ProfileService返回用户角色 AllowedScopes = { "openid", "profile", "scope1", }, //如果要获取refresh_tokens ,必须把AllowOfflineAccess设置为true AllowOfflineAccess = true, //AccessToken有效期,默认1小时,改为1分钟做试验 AccessTokenLifetime = 60, },
MyWebApi项目增加打印AccessToken有效期:
[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 accessTokenExpire = ""; if (!string.IsNullOrWhiteSpace(accessToken)) { var jwtSecurityToken = new JwtSecurityToken(accessToken); accessTokenExpire = $"accessTokenExpire={jwtSecurityToken.ValidTo.ToLocalTime():F}"; } string msg = $"{DateTime.Now}, 从HttpContext获取accessToken={accessToken}, {accessTokenExpire}, {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(); }
在program.cs配置Bearer认证参数设置Token有效期的宽限时间为0秒,一旦过期就认为无效,它默认是5分钟的
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"; //验证token有效期允许的宽限时间 options.JwtValidationClockSkew = TimeSpan.Zero; });
BlzOidc项目AddOpenIdConnect增加获取Refresh Token的配置:options.Scope.Add("offline_access");
FetchDataApi.razor页面增加显示错误信息的功能
@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 (!string.IsNullOrWhiteSpace(err)) { <div class="text-danger m-4">@err</div> } @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; private string err = ""; protected override async Task OnInitializedAsync() { err = ""; try { forecasts = await ForecastService.GetForecastAsync(); } catch (Exception ex) { err = ex.Message; } } }
把AspNetId4Web项目、MyWebApi项目、BlzOidc项目一起跑起来,打开BlzOidc项目FetchDataApi.razor页面,它自动跳转到AspNetId4Web项目登录,输入alice种子用户的手机号获取验证码登录,自动跳回BlzOidc项目FetchDataApi.razor页面,此时可以MyWebApi项目控制台输出,token有效期确实只有1分钟
2022/3/13 16:52:47, 从HttpContext获取accessToken=eyJ……, accessTokenExpire=2022年3月13日 16:53:44,
refreshToken=,
用户声明=nbf=1647161564, exp=1647161624, iss=https://localhost:5001, aud=api1, aud=https://localhost:5001/resources, client_id=BlazorServerOidc, sub=d2f64bb2-789a-4546-9107-547fcb9cdfce, auth_time=1647157381, idp=local, name=Alice Smith, role=Admin, role=Guest, email=AliceSmith@email.com, phone_number=13512345001, nation=汉族, jti=45E0FD10A02D64DC10D76D43279870D5, sid=5E787D0CA1E67B7DCC695659798E5CF4, iat=1647161564, scope=openid, scope=profile, scope=scope1, scope=offline_access, amr=pwd
过了1分钟再点击BlzOidc项目切换到FetchDataApi.razor页面,让它重新访问MyWebApi项目的Web Api,提示401未授权错误
MyWebApi项目控制台提示token过期,符合预期。
info: IdentityServer4.AccessTokenValidation.IdentityServerAuthenticationHandler[7]
Bearer was not authenticated. Failure message: IDX10223: Lifetime validation failed. The token is expired. ValidTo: 'System.DateTime', Current time: 'System.DateTime'.
info: IdentityServer4.AccessTokenValidation.IdentityServerAuthenticationHandler[12]
AuthenticationScheme: Bearer was challenged.
给BlzOidc项目FetchDataApi.razor页面增加401自动跳转到登录路由
protected override async Task OnInitializedAsync() { err = ""; try { forecasts = await ForecastService.GetForecastAsync(); } catch (Exception ex) { err = ex.Message; if ((ex is HttpRequestException requestException) && (requestException.StatusCode == System.Net.HttpStatusCode.Unauthorized)) { //如果token过期,自动跳转登录 navManager.NavigateTo($"account/login?returnUrl={Uri.EscapeDataString(navManager.Uri)}", true); } }
继续测试,当token过期后,切换到FetchDataApi.razor页面,可以看到一闪而过的错误页面,然后马上显示获取到了天气数据。因为浏览器还保存着登录状态session,因此可以自动跳转到AspNetId4Web项目完成登录,拿到新的token。MyWebApi项目控制台可以看到2次不同的token信息。
info: MyWebApi.Controllers.WeatherForecastController[0]
2022/3/13 16:57:12, 从HttpContext获取accessToken=eyJ……, accessTokenExpire=2022年3月13日 16:58:08,
refreshToken=,
用户声明=nbf=1647161828, exp=1647161888, iss=https://localhost:5001, aud=api1, aud=https://localhost:5001/resources, client_id=BlazorServerOidc, sub=d2f64bb2-789a-4546-9107-547fcb9cdfce, auth_time=1647157381, idp=local, name=Alice Smith, role=Admin, role=Guest, email=AliceSmith@email.com, phone_number=13512345001, nation=汉族, jti=DF78D30251DA0D251B19C6F423F77740, sid=5E787D0CA1E67B7DCC695659798E5CF4, iat=1647161828, scope=openid, scope=profile, scope=scope1, scope=offline_access, amr=pwd
Bearer was not authenticated. Failure message: IDX10223: Lifetime validation failed. The token is expired. ValidTo: 'System.DateTime', Current time: 'System.DateTime'.
info: IdentityServer4.AccessTokenValidation.IdentityServerAuthenticationHandler[12]
AuthenticationScheme: Bearer was challenged.
info: MyWebApi.Controllers.WeatherForecastController[0]
2022/3/13 16:58:12, 从HttpContext获取accessToken=eyJ……, accessTokenExpire=2022年3月13日 16:59:12,
refreshToken=,
用户声明=nbf=1647161892, exp=1647161952, iss=https://localhost:5001, aud=api1, aud=https://localhost:5001/resources, client_id=BlazorServerOidc, sub=d2f64bb2-789a-4546-9107-547fcb9cdfce, auth_time=1647157381, idp=local, name=Alice Smith, role=Admin, role=Guest, email=AliceSmith@email.com, phone_number=13512345001, nation=汉族, jti=D4D5F5D3EAEE4DC444214F6FF9AE1426, sid=5E787D0CA1E67B7DCC695659798E5CF4, iat=1647161892, scope=openid, scope=profile, scope=scope1, scope=offline_access, amr=pwd
通过Refresh Token获取Access Token
如果网站的cookies已经过期,例如设置BlzOidc项目的AddOpenIdConnect的options.MaxAge = TimeSpan.FromMinutes(3);过3分钟后再访问FetchDataApi.razor网页,就要重新登录了,体验不好。
如果客户端项目不采用cookies方案认证,例如手机APP或者PC客户端。那么使用Refresh Token获取Access Token就很有价值了。
新建一个Access Token管理器TokenManager类,解决更新token的需求,每次获取Access Token之前,先判断一下有效期,如果过期了,就通过Refresh Token获取一个新的Access Token。
/// <summary> /// Access Token管理器 /// </summary> public class TokenManager { private readonly HttpClient _httpClient; private readonly TokenProvider _tokenProvider; public TokenManager(HttpClient httpClient, TokenProvider tokenProvider) { _httpClient = httpClient; _tokenProvider = tokenProvider; } /// <summary> /// 获取有效的Access Token /// </summary> /// <returns></returns> public async Task<string> GetValidAccessTokenAsync() { //从Access Token解析得到有效期 var _accessTokenExpire = GetExpireTimeFromAccessToken(_tokenProvider.AccessToken); //如果Access Token过期,更新token if (_accessTokenExpire < DateTime.UtcNow) { //更新token await RefreshTokenAsync(); } return _tokenProvider.AccessToken; } //从Access Token解析得到有效期 private DateTime GetExpireTimeFromAccessToken(string? accessToken) { if (string.IsNullOrWhiteSpace(accessToken)) return DateTime.MinValue; var jwtSecurityToken = new JwtSecurityToken(accessToken); return jwtSecurityToken.ValidTo; } //更新token private async Task RefreshTokenAsync() { //发现认证服务端点 var discoveryResponse = await _httpClient.GetDiscoveryDocumentAsync("https://localhost:5001"); if (discoveryResponse.IsError) { throw new Exception(discoveryResponse.Error); } //根据Refresh Token请求token var tokenResponse = await _httpClient.RequestRefreshTokenAsync(new RefreshTokenRequest { //https://localhost:5001/connect/token Address = discoveryResponse.TokenEndpoint, ClientId = "BlazorServerOidc", ClientSecret = "BlazorServerOidc.Secret", RefreshToken = _tokenProvider.RefreshToken, }); if (tokenResponse.IsError) { throw new Exception(tokenResponse.Error); } //保存新的token _tokenProvider.AccessToken = tokenResponse.AccessToken; _tokenProvider.RefreshToken = tokenResponse.RefreshToken; Console.WriteLine($"更新token成功,{Environment.NewLine}AccessToken={_tokenProvider.AccessToken}{Environment.NewLine}RefreshToken={_tokenProvider.RefreshToken}"); }
WeatherForecastApiClient改为获取有效的Access Token,这样每次调用都能通过认证。
public async Task<WeatherForecast[]?> GetForecastAsync() { //var token = _tokenProvider.AccessToken; //获取有效的Access Token string token = await _tokenManager.GetValidAccessTokenAsync(); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var result = await _httpClient.GetFromJsonAsync<WeatherForecast[]>("/WeatherForecast"); return result; }
把AspNetId4Web项目、MyWebApi项目、BlzOidc项目一起跑起来,等1分钟Access Token过期后再次访问FetchDataApi.razor页面,查看后台信息,可以看到自动更新了Access Token和Refresh Token。
初始化获取AccessToken=eyJh……, RefreshToken=748E51ACEF1A5174646BF8E71AD2064C7A8A232D3AEE13282E77A222E01363A3
更新token成功,
……
AccessToken=eyJh……
RefreshToken=E26CCF4F7F69E219A2AFF4E861FE696E9BB89C97494B0335FE089816664FB7F4
问题
Identity Server 4的Access Token默认有效期1小时,Refresh Token默认有效期30天,如果都过期了,就只能重新登录了。一般的客户端不会间隔这么长时间才使用token访问资源Web Api,即便真的都过期了,30天登录一次,也不算过分,所以不关心Refresh Token过期的问题了。
Refresh Token几乎是客户端必须使用的功能,我以为Identity Server 4会提供现成的函数,结果没有找到,真是非常遗憾。
DEMO代码地址:https://gitee.com/woodsun/blzid4