Blazor.Server以正确的方式集成Ids4

白昼怎懂夜的黑 提交于 2020-10-29 23:25:20

(一个真正的以后端形式来集成认证中心的方案)


本文导读

首先特别感谢张善友老师提供技术指导,源于上周我发了一篇文章

[Mvp.Blazor] 集成Ids4,实现统一授权认证》,

我本来是想通过像vue框架那样,通过引oidc-client.js的方式,来实现Ids4的集成问题,我当时以为已经很好的,后来看了张队发的文章以后,发现好像我写的那种方式并不优雅。


所以我又重新改了一次,(但是代码保留了,新建了对应的分支),以适应在Blazor服务端集成ids4的完美体验,如果你是wasm的项目,也不需要引用,张队已经写好了组件,大家看看引用下即可:

https://github.com/BlazorHub/AntDesignTemplate



那今天我就快速的给大家说一下,如何在Blazor服务端来设计和集成认证中心,当然里边会涉及一些基础知识点,我就不展开了,所以你自己需要先掌握以下知识储备:

Ids4配置授权码模式客户端

Razor page的On{handler}{Async}()语法

HttpContext.User基本使用




第一部分:配置认证方案


在上一篇文章中,我们主要是通过oidc-client.js的形式进行ids4的连接的。

但是我们的项目毕竟是服务端,Blazor服务端使用ids4,感觉和MVC还是有些相似的,都是基于Cookie的oidc认证模式。


认证中心配置下客户




你可以看到,基本就是和MVC配置是一样的,不仅认证中心的客户端配置很像,就连项目中,认证服务的注册的方式也是几乎一样:


引用nuget包


Microsoft.AspNetCore.Authentication.OpenIdConnect


startup中,注册认证服务


 // 第一步:配置认证方案 services.AddAuthentication(options => {     options.DefaultScheme = "Cookies";     options.DefaultChallengeScheme = "oidc"; }) .AddCookie("Cookies") .AddOpenIdConnect("oidc", options => {     options.Authority = "https://ids.neters.club/";     options.ClientId = "blazorserver"; // 75 seconds     options.ClientSecret = "secret";     options.ResponseType = "code";     options.SaveTokens = true;
// 为api在使用refresh_token的时候,配置offline_access作用域 options.GetClaimsFromUserInfoEndpoint = true; // 作用域获取 options.Scope.Clear(); options.Scope.Add("roles");//"roles" options.Scope.Add("rolename");//"rolename" options.Scope.Add("blog.core.api"); options.Scope.Add("profile"); options.Scope.Add("openid");
options.Events = new OpenIdConnectEvents { // called if user clicks Cancel during login OnAccessDenied = context => { context.HandleResponse(); context.Response.Redirect("/"); return Task.CompletedTask; } }; });


相应的注释,我简单的写了写,当然文章的开篇我也说了,这一块属于ids4的基础部分,以前的文章和视频说了很多了,以后我就不打算讲解了。


重点是要配置那几个Scope作用域,然后可以看到有ids4的授权页面,当然,这个页面也可以屏蔽掉不显示。


注册好了服务,那肯定是要开启中间件了:

开启中间件


app.UseAuthentication();




第二部分:登录、登出的页面设计

这里我们使用到了Razor的Page功能,添加登录和登出功能,具体的使用方法可以在微软官网查看,相应的代码很简单:


登录、登出


 // 这里用到了缓存来管理我们的用户登录信息,下文会讲到 // 第二部分: 配置razor page,定义登录,登出等逻辑 public class _HostAuthModel : PageModel    {        public readonly AuthStateCache Cache;
public _HostAuthModel(AuthStateCache cache) { Cache = cache; }
// 每次刷新页面异步加载 public async Task<IActionResult> OnGet() { System.Diagnostics.Debug.WriteLine($"\n_Host OnGet IsAuth? {User.Identity.IsAuthenticated}");
// 判断Httpcontext是否登录状态 if (User.Identity.IsAuthenticated) { var sid = User.Claims .Where(c => c.Type.Equals("sid")) .Select(c => c.Value) .FirstOrDefault();
System.Diagnostics.Debug.WriteLine($"sid: {sid}");
// 如果缓存中不存在 if (sid != null && !Cache.HasSubjectId(sid)) { var authResult = await HttpContext.AuthenticateAsync("oidc"); DateTimeOffset expiration = authResult.Properties.ExpiresUtc.Value; string accessToken = await HttpContext.GetTokenAsync("access_token"); string refreshToken = await HttpContext.GetTokenAsync("refresh_token"); Cache.Add(sid, expiration, accessToken, refreshToken); } }
return Page(); }
// 登录 public IActionResult OnGetLogin() { System.Diagnostics.Debug.WriteLine("\n_Host OnGetLogin"); var authProps = new AuthenticationProperties { IsPersistent = true, // 设置token的过期时间,相当于前端的localstorage ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1), RedirectUri = Url.Content("~/") };
// 认证中心登录 return Challenge(authProps, "oidc"); }
// 登出 public async Task OnGetLogout() { System.Diagnostics.Debug.WriteLine("\n_Host OnGetLogout"); var authProps = new AuthenticationProperties { RedirectUri = Url.Content("~/") }; await HttpContext.SignOutAsync("Cookies"); await HttpContext.SignOutAsync("oidc", authProps); } }


代码中,我已经增加了相应的注释信息,你应该能看的明白。

只不过具体的写法有些小伙伴可能没用过RazorPage,这里简单的说一下:

因为我们的Index页面没有绑定任何数据,所以这里基本上只继承了PageModel,OnGet方法是个约定,查看mvc的源码你会发现它会获取On{handler}{Async}()。比如OnGet,它会在Get Index的时候被执行,我们可以通过这个约定进行数据绑定,这里知道下在Razor Page下HttpMethod也是一个handler,所以Razor Page的处理方式是通过handler进行的。


为了实现这个效果,我们还需要配置主页面_Host.cshtml的路由:

@page "/{handler?}"


你可能会好奇,那既然要使用到认证中心了,为啥还需要登录登出呢,其实客户端都是需要的,不信你用mvc项目,也需要配置的。


权限组件


Blazor自带了相应的授权组件,可以很好的帮助我们来实现对权限的控制,只需要在App.razor中:

@inject NavigationManager NavManager
<Router AppAssembly="@typeof(Program).Assembly"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <NotAuthorized> @{ // 使用权限组件,如果当然组件配置Authorize,并且用户未登录,则跳转登录页(这里是ids4) NavManager.NavigateTo("/Login", true); } </NotAuthorized> <Authorizing> <h1>Authentication in progress</h1> <p>Only visible while authentication is in progress.</p> </Authorizing> </AuthorizeRouteView> </Found> <NotFound> <CascadingAuthenticationState> <LayoutView Layout="@typeof(MainLayout)"> <h1>Sorry</h1> <p>Sorry, there's nothing at this address.</p> </LayoutView> </CascadingAuthenticationState> </NotFound></Router>


大概意思就是,我们可以指定我们的razor页面是否需要加权,如果不配置,那就是很正常的浏览,比如我们的博客index首页,肯定不能加权,除非是后台管理系统,那就需要每个页面都加权了,配置好后,如果用户未登录,那就会立刻跳转到上边我们配置的登录地址,跳转到认证中心。

那如何对特定页面加权呢,很简单。


razor页面加权


只需要在需要的页面内增加特性即可:

@attribute [Authorize]



展示用户状态


刚刚上边我们已经配置好了用户登录和登出接口,也对页面进行了加权,用来引导用户去认证中心登录,或者单点登录,拉取用户信息,那如何展示呢?

很简单,在主页面_Host.cshtml中,使用User属性来实现:

@model _HostAuthModel
@if (User.Identity.IsAuthenticated) { <div id="logined" style="display: contents;"> <div class="menu-item my-2 my-md-0 mr-md-3 dropdown"> <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown"> 设置 - <span id="username">@(userName) </span> </button> <div class="dropdown-menu">
</div> </div> <a class="menu-item my-2 btn btn-outline-primary" href="/logout">注销</a> </div> } else { <div id="accessed"> <a class="menu-item my-2 btn btn-outline-primary" href="/login">登入</a> </div> }


具体的代码看我的项目即可。

那到了这里,我们已经完成了Blazor服务端如何集成ids4的代码,不过这样还是有些问题的,比如:

如果获取access_token来访问第三方的资源服务器api呢?




第三部分:管理用户授权状态

之前我们用js方法的时候,还记得吗,我们使用的是localstorage的形式,存在了客户端,包括用户信息,令牌,过期时间等等,然后通过JSRuntime来实现对js的控制和使用,那今天我们不用js了,如何来管控呢,我这里用的是内存缓存的形式,当然你可以使用Redis来实现分布式,思路都一样。


用户数据存储cache


在上边的登录的时候,我们看到了,每次登录成功回调的时候,都会刷新页面,也当然会执行OnGet()方法,这样,就会把当然用户的信息,通过特定的sid作为缓存key的形式来保存到内存里,这个sid就像是session一样,每次登录成功回调后,都会有一个唯一的字符串,作为标识,开发过微信的应该都知道。


那就定义一个cache管理类:

    public class AuthStateCache    {        private ConcurrentDictionary<string, ServerAuthModel> Cache            = new ConcurrentDictionary<string, ServerAuthModel>();
public bool HasSubjectId(string subjectId) => Cache.ContainsKey(subjectId);
public void Add(string subjectId, DateTimeOffset expiration, string accessToken, string refreshToken) { System.Diagnostics.Debug.WriteLine($"Caching sid: {subjectId}");
var data = new ServerAuthModel { SubjectId = subjectId, Expiration = expiration, AccessToken = accessToken, RefreshToken = refreshToken }; Cache.AddOrUpdate(subjectId, data, (k, v) => data); }
public ServerAuthModel Get(string subjectId) { Cache.TryGetValue(subjectId, out var data); return data; }
public void Remove(string subjectId) { System.Diagnostics.Debug.WriteLine($"Removing sid: {subjectId}"); Cache.TryRemove(subjectId, out _); } }


这个很简单,就不多说了,就是对用户数据的增删改查,标识就是sid。那现在就有了一个问题,我们知道,登录的时候是存到cache里的,那什么时候删除呢?

请往下看。



AuthenticationStateProvider 服务


这个服务是今天的重头戏,你需要好好的了解一下它的作用:

内置的 AuthenticationStateProvider 服务可从 ASP.NET Core 的 HttpContext.User 获取身份验证状态数据。 身份验证状态就是这样与现有 ASP.NET Core 身份验证机制集成。


AuthenticationStateProvider 服务可以提供当前用户的 ClaimsPrincipal 数据。


简单的概况呢,就是开启这个服务,我们可以获取当前用户的claim声明,并且定期的做一个筛查,就像是一个定时器,每十秒执行一次,判断当前用户是否过期,如果正好过期了,就把这个cache记录给删掉。

  /// <summary>    /// 配置状态服务处理器,定时校验授权状态    /// RevalidationInterval为刷新时间,类似于滑动时间    /// </summary>    public class AuthStateHandler         : RevalidatingServerAuthenticationStateProvider    {        private readonly AuthStateCache Cache;
public AuthStateHandler( ILoggerFactory loggerFactory, AuthStateCache cache) : base(loggerFactory) { Cache = cache; }
protected override TimeSpan RevalidationInterval => TimeSpan.FromSeconds(10); // TODO read from config
protected override Task<bool> ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken) { var sid = authenticationState.User.Claims .Where(c => c.Type.Equals("sid")) .Select(c => c.Value) .FirstOrDefault();
if (sid != null && Cache.HasSubjectId(sid)) { var data = Cache.Get(sid);
System.Diagnostics.Debug.WriteLine($"NowUtc: {DateTimeOffset.UtcNow.ToString("o")}"); System.Diagnostics.Debug.WriteLine($"ExpUtc: {data.Expiration.ToString("o")}");
if(DateTimeOffset.UtcNow >= data.Expiration) { System.Diagnostics.Debug.WriteLine($"*** EXPIRED ***"); Cache.Remove(sid); return Task.FromResult(false); } } else { System.Diagnostics.Debug.WriteLine($"(not in cache)"); }
return Task.FromResult(true); } }


思路就是这样,自己应该能看明白,就是定时做了一个判断,然后删除cache。


服务注册容器


把上边的两个服务注册下:

 // 第三部分:授权状态的保护与管理 services.AddSingleton<AuthStateCache>(); // 开启AuthenticationStateProvider 服务 services.AddScoped<AuthenticationStateProvider, AuthStateHandler>();



第四部分:获取token,访问api


这一块和之前的逻辑是一样的,通过HttpClient来实现对第三方资源服务器的api访问,那肯定需要获取token,这个就从上边的cache中获取:

 public async Task<string> GetAccessToken() {     // 注意这获取声明数据有问题,参考我的代码。获取当前用户的sid唯一标志     var sid = _accessor.HttpContext.User.Claims            .Where(c => c.Type.Equals("sid"))            .Select(c => c.Value)            .FirstOrDefault();
// 正常,则返回结果 if (sid != null && _cache.HasSubjectId(sid)) { return _cache.Get(sid).AccessToken; }
// 否则,跳转登录页,去认证中心拉取 _navigationManager.NavigateTo("/Login", true);
     return await Task.FromResult(string.Empty); }


到了这里,我们的Blazor.Server服务端集成Ids4已经完成了,是不是完全没用到任何的js,来查看下效果吧:


可以看到完成了这样的流程:

首页不需要权限;

博客操作页需要登录,并成功跳转认证中心;

登录后,成功回调到首页,并获取用户信息;

实现单点登录;

编辑的时候,test用户返Forbidden,表明已经登录,并实现了权限控制;


好啦,自己动手试试吧。




参考文章:

1、https://mcguirev10.com/2019/12/15/blazor-authentication-with-openid-connect.html

2、https://github.com/BlazorHub/AntDesignTemplate

本文分享自微信公众号 - dotNET跨平台(opendotnet)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!