Blazor Server通过RefreshToken更新AccessToken

Blazor Server通过RefreshToken更新AccessToken

 

Identity Server系列目录

  1. Blazor Server访问Identity Server 4单点登录 - SunnyTrudeau - 博客园 (cnblogs.com)
  2. Blazor Server访问Identity Server 4单点登录2-集成Asp.Net角色 - SunnyTrudeau - 博客园 (cnblogs.com)
  3. Blazor Server访问Identity Server 4-手机验证码登录 - SunnyTrudeau - 博客园 (cnblogs.com)
  4. Blazor MAUI客户端访问Identity Server登录 - SunnyTrudeau - 博客园 (cnblogs.com)
  5. Identity Server 4项目集成Blazor组件 - SunnyTrudeau - 博客园 (cnblogs.com)
  6. Identity Server 4退出登录自动跳转返回 - SunnyTrudeau - 博客园 (cnblogs.com)
  7. Identity Server通过ProfileService返回用户角色 - SunnyTrudeau - 博客园 (cnblogs.com)
  8. Identity Server 4返回自定义用户Claim - SunnyTrudeau - 博客园 (cnblogs.com)
  9. 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=202231316: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项目完成登录,拿到新的tokenMyWebApi项目控制台可以看到2次不同的token信息。

 info: MyWebApi.Controllers.WeatherForecastController[0]

      2022/3/13 16:57:12, HttpContext获取accessToken=eyJ……, accessTokenExpire=202231316: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=202231316: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项目的AddOpenIdConnectoptions.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 TokenRefresh Token

初始化获取AccessToken=eyJh……, RefreshToken=748E51ACEF1A5174646BF8E71AD2064C7A8A232D3AEE13282E77A222E01363A3

更新token成功,

……

AccessToken=eyJh……

RefreshToken=E26CCF4F7F69E219A2AFF4E861FE696E9BB89C97494B0335FE089816664FB7F4

 

问题

Identity Server 4Access Token默认有效期1小时,Refresh Token默认有效期30天,如果都过期了,就只能重新登录了。一般的客户端不会间隔这么长时间才使用token访问资源Web Api,即便真的都过期了,30天登录一次,也不算过分,所以不关心Refresh Token过期的问题了。

Refresh Token几乎是客户端必须使用的功能,我以为Identity Server 4会提供现成的函数,结果没有找到,真是非常遗憾。

 

 DEMO代码地址:https://gitee.com/woodsun/blzid4

 

posted on 2022-03-13 17:38  SunnyTrudeau  阅读(719)  评论(0编辑  收藏  举报