• 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

  • 相关阅读:
    oracle常规操作
    shell 的算数运算
    sed 命令小结
    swing
    索引失效
    poj--1258--Agri-Net(最小生成树)
    CodeForces--626C--Block Towers (二分)
    Codeforces--629A--Far Relative’s Birthday Cake(暴力模拟)
    Codeforces--629B--Far Relative’s Problem(模拟)
    hdoj--5104--Primes Problem(素数打表)
  • 原文地址:https://www.cnblogs.com/sunnytrudeau/p/16001129.html
Copyright © 2020-2023  润新知