从壹开始 [ Ids4实战 ] 之四 ║ 用户数据管理 & 前后端授权联调
前言
哈喽~~~ 大家周一好!夏天到了,大家舒服了没有,熟话说,打败你的不是天真,是天真热!😂
过去的一周里,发生了两件事跟大家分享下:
①、有两个小伙伴给我提供了 Working Online 的工作,简单说了说,感觉应该不太适合,至少我不适合,前期双方试探的成分太多了,我是不喜欢,同时也建议正在找OnLine工作的小伙伴需多多考虑可行性。
②、我决定开始《微讲堂》了,具体可以参考右侧公告栏,因为有些基础比较薄弱的小伙伴,单单提供思路还是无法入门,所以提供在线手把手教学吧,这个我是完全无所谓,看你心情吧。
这几天通过晚上对 IdentityServer4 的学习和研究,发现这个就是一个“大坑”(不是说功能不好,是里边有很多很多的内容需要学习,暂时把开发的 Demo 开放出来了,很简单的,随便看看,之前看官网, 关于 IdentityServer4 的教程,洋洋洒洒就过去了,感觉还挺简单,发现要真是落地到项目里了,自我感觉又有了压迫感,文末结语中,我简单的说了几点问题,大家可以慢慢往下走,不过知识嘛,无外乎就是自己开心学习 和 自己学习挣钱,这两个心理,加油吧。
当然平时工作之余,还是要照顾下前后端分离项目的一些东西的,基础不能丢,主要是三块地方做了修改,这里简单的列一下,就不单独的写文章了,希望一直在看第一个项目的小伙伴,有缘可以看到吧,不过,就算是看不到也没事儿,遇到了自然就知道了:
1、Blog.Vue 首页的闪屏处理;// 知名博主@张飞洪提出的问题,不知道我是否修改对了;http://vueblog.neters.club/
2、Blog.Admin 后台框架调整优化;// ①登录页样式改版,②Tabs 导航条优化,③兼容手机屏幕等;http://vueadmin.neters.club/
3、Blog.Core 后端项目增加 Wiki 页;// 为了让刚接触框架的小伙伴能快速一览,特地在 Github 上,创建了 Wiki ,只不过现在才打了个目录,内容慢慢填,如果还有其他的不足之处,欢迎提建议;https://github.com/anjoy8/Blog.Core/wiki
突然转话题,上次咱们第一次对项目进行持久化操作《三║ 详解授权持久化 & 用户数据迁移》,不知道小伙伴都看了多少,这里再把几个重要问题提一下,希望不要忘记了才好:
1、Ids4 一共用到了几个上下文,分别的用处是什么? 2、在迁移中,数据库生成了多少表,各个模块又是干什么的? 3、Ids4 的良好扩展性,体现在哪里?丰富性又体现在哪里? 4、ApplicationUser 类是起到什么作用的?
如果脑子里有些东西,那就恭喜了,如果第一次看,或者完全不知道我在说什么的话,请看上一集,今天会说说我在研究的过程中,遇到的两个 Flag 🚩,也就是两个问题,希望有心的小伙伴,可以帮忙思考下,欢迎找我讨论,废话不多说,开车,马上讲解今天的内容!🛴🚄
零、今天要实现绿色的部分
(知识结构图,注意这是我自己的讲解结构,和Ids4知识图解无关)
一、用户数据处理 —— Identity
咱们在上篇文章中,简单的将 IdentityServer4 的结构进行持久化处理,并把前后端项目中的用户数据进行迁移处理,最后修改了登录页的样式,基本满足了登录和登出的操作,作为一个授权服务中心,仅仅只有登录是完全解决不了什么问题的,至少应该对用户数据进行常规操作处理,比如 CURD 等基础操作。
正好,我们使用了 NetCore 自带的 Identity 机制,可以帮助我们做一部分工作,因为它自己也封装了一些方法,我们可以根据他们的方法,实当的做些扩展,从而达到相应的目的,具体有哪些操作,请往下看:
1、用户数据展示(有权限)
既然有数据处理,肯定得有展示出来,当然,这个不是一定的,只是做下处理,如果你担心会有数据安全问题的话,要么不显示数据,要么只显示无关痛痒的两列,甚至可以直接加上权限,只有超级管理员或者技术人员可以看到就行。我这里仅仅是加了个登录权限,只有登录的用户才能看的到:
// 注入用户管理 private readonly UserManager<ApplicationUser> _userManager; [HttpGet] [Route("account/users")] [Authorize]//可以自定义规则 public IActionResult Users(string returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; var users = _userManager.Users.Where(d => !d.tdIsDelete).OrderBy(d => d.UserName).ToList();//Identity 已经对内部的一些方法做了封装,直接使用即可,如果你对 Net 自带的 Identity 使用过的话,应该很容易上手。 return View(users); }
注意下上边的红色标注的地方,下文会说到为啥这里用到了 isDelete 。
我们简单的对 User 页面做了授权处理,必须登录状态下才能有权访问,如果是没有登录,会直接跳转到登录页面:
(带权限的用户展示页)
2、注册
关于注册其实我们之前已经说过了,为什么呢,因为我们在之前导入用户数据的时候,就已经用到了这个方法,只不过这里单拎出来了,但是这里有一个问题需要我们好好的思考思考,那就是角色的获取!这里就是我下边要说的第一个“Flag”🚩,为什么重要呢,不知道现在读的你是否使用过 IdentityServer4 ,我也这几天在考虑这个问题,授权中心肯定需要有用户管理的,那很自然的,就会出现 “ 区分控制 ” 的问题,这里简单说下会出现的两个情况:
1、前台展示项目:如果我们的vue 项目,是一个前台网站,比如 电商类 的或者 Blog.Vue 这样的,很简单,我们只需要在 api 上加上 [Authorize] 这个无具体规则的授权特性就行,大家先不要往下看,先停一分钟想一想是不是这个情况。商城嘛,只需要用户登录一下就可以购买了,我们不需要特地的区分商城用户有什么区别,有什么三六九等,大家都是一样,登录了,就可以任何操作,无论是买东西,还是写文章,亦或者投票等等;
2、后台管理项目:但是!还有另一种情况,那就是后台管理,一个对用户身份要求特别严格的一个系统,我们肯定不能仅仅在 api 接口地址上,加上 [Authorize] 这个简单的特性就完事儿了,就比如我们的 Blog.Admin 项目,肯定需要一套复杂的授权策略机制,那就不得不用到用户的角色信息,或者其他的模块信息,这就是我上边说的 “区分控制”;(至于是基于角色的策略,还是模块化,我还在考虑中,目前先尝试角色管理)
3、猜想:你是不是想说使用基于角色+策略授权的 Hybrid Flow 混合模式?别着急,以后的问题会说到,这里提出这个问题,就是向给大家一个思路的过程。
如果是第二种情况的话,我们在用户注册的时候,就需要带上 “角色” 这个信息,比如我这里先默认是一个 test 系统测试管理员的角色(这个暂时这么处理,后期我会再深入研究下,是不是这个模式,或者如果正再看的你很懂的话,欢迎指导下,不胜感激!),当然,如果你的项目不需要对用户的权限进行划分,就比如我上边的第一种情况,电商类,博客类,只要不是后台管理这种的前台系统,都很简单,只需要在 api 上加上 [Authorize] ,然后授权中心是不需要角色这个概念的。
我们学术讨论嘛,当然是从复杂的着手,就把角色给考虑进去了,现在先写死一个角色,我们以后的文章中会进一步讨论这个复杂的情况:
[HttpPost] [Route("account/register")] [ValidateAntiForgeryToken] public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null, string rName = "AdminTest") { ViewData["ReturnUrl"] = returnUrl; IdentityResult result = new IdentityResult(); // 模型校验 if (ModelState.IsValid) { // 判断用户名是否存在,说明:如果是DDD设计思想,这中查重应该是写在领域模型的。 var userItem = _userManager.FindByNameAsync(model.LoginName).Result; if (userItem == null) { // 转成我们的实体模型,说明:这种多个实体转换,可以使用 Dto var user = new ApplicationUser { Email = model.Email, UserName = model.LoginName, LoginName = model.RealName, sex = model.Sex, age = model.Birth.Year - DateTime.Now.Year, birth = model.Birth, addr = "", tdIsDelete = false }; // 创建用户,注意密码的规范,比如必须有大小写字母+数字+符号 result = await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { // 用户添加成功后,就需要添加声明了,看自己需要多少吧,可以自定义扩展 result = await _userManager.AddClaimsAsync(user, new Claim[]{ // 这个 Name ,就是 Jwt 的唯一名字,也是页面里展示的名称,比如是“测试账号”,而不是登录名的“test1” new Claim(JwtClaimTypes.Name, model.RealName), new Claim(JwtClaimTypes.Email, model.Email), // 是否需要进行 Email 邮件验证 new Claim(JwtClaimTypes.EmailVerified, "false", ClaimValueTypes.Boolean), // 这里就是角色声明 new Claim(JwtClaimTypes.Role, rName) }); if (result.Succeeded) { // 添加成功,可以直接登录,这个就比如是我们的博客项目或者电商项目,我们在授权中心注册成功后,直接登录了,跳转到前台了。 //await _signInManager.SignInAsync(user, isPersistent: false); return RedirectToLocal(returnUrl); } } } else { ModelState.AddModelError(string.Empty, $"{userItem?.UserName} already exists"); } // 收集全部异常数据,返回前台 AddErrors(result); } return View(model); }
上边的就是注册的主要代码,大家可以自己任意的扩展,然后重要的部分,我已经标红,也写上了详细的注释,特别简单,都能看懂。
这一 Part 都很平常,最重要的一个问题还是那个角色这一块,希望读到这里的都能看懂,想一想到底你的项目里需不需要这样的 Claim,不懂的欢迎来讨论。
3、更新 与 逻辑删除(有权限)
上边咱们说到了展示和添加,那下边就是说到更新了(这个操作我带上了最高的权限,必须是超级管理员才能操作 [Authorize(Roles = "SuperAdmin")] ),你会问,为啥要把删除和更新放到一起呢?其实我个人感觉逻辑是一样的,平时开发肯定也都知道,逻辑删除其实就是把“是否删除” 这个字段设置成 True 就行了,但是真的是这样么,我们慢慢往下看。
首先更新用户这个很简单的,我就不多说什么了,具体的可以看看代码,主要的逻辑就是平时的三步走:
1、查询出当前人Model;
2、用视图模型修改Model;
3、执行更新操作 _userManager.UpdateAsync(userItem); // 这里要说下就是,Identity 自带了很多扩展方法,大家需要自己好好的研究下,从而达到自己的相应目的。
更新说完了,下边说说删除,删除其实本身就有两种情况:
1、逻辑删除,很自然,就是将数据更新下状态,比如我们可以用上边的方法,把当前操作人的 IsDeleted=True 即可,很简单;
2、物理删除,这个还是需要好好研究研究,我在官方的代码里,没有找到如何物理删除的方法,可能还是需要开发者自己定义扩展吧;
这就是我说的第二个 “Flag”🚩 ,需要好好的思考思考,如果你已经忘了第一个 Flag 的话,请向上看,用户注册章节里的角色问题。
(更新 & 删除 有权限 动图)
4、重置密码
这个是目前为止稍微复杂一点的,需用用到流程,首先看动图吧:
(重置/更新密码 动图)
这个过程其实很简单,也是项目中必须使用到的功能,我相信任何一个网站,必须要用到这个重置和找回密码的功能吧,当然生产环境很复杂,可能需要邮箱或者手机等来处理动态链接,我这里只是提供一个思路,总结来说,流程说明如下:
1、输入当时注册邮箱;
2、获取包含动态 Code 的安全链接(可通过发邮件的形式);
3、根据安全链接,设置新密码;
4、重新登录;
核心代码(节选):
// 1、判断邮箱 var user = await _userManager.FindByEmailAsync(model.Email); // 2、生成重置密码回调链接 var code = await _userManager.GeneratePasswordResetTokenAsync(user); var callbackUrl = Url.ResetPasswordCallbackLink(user.Id, code, Request.Scheme); var ResetPassword = $"Please reset your password by clicking here: <a href='{callbackUrl}'>link</a>"; // 3、重置密码 var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password);
5、其他情况处理?
通过上边的简单说明,AccountController 这个控制器的内容, 咱们说完了,是不是就没有问题了呢,不是!我们要研究,就要研究透彻,大家肯定注意到了这个项目中,基本都说到了,但是在核心的快速启动文件夹 Quickstart 中,还有几个控制器没有说到:
不光如此,在平时的开发中,我们还会遇到下边这几个业务逻辑操作:
1、如何找回注册邮箱? 2、如何通过发送邮件,从而达到邮件确认的目的? 3、如何实现FaceBook、Google登录? 4、如何更新用户的角色等Claims?
5、如何刷新 Token ?
上边红框中的那几个控制器都是什么意思?
下边四条业务逻辑又该如何实现?
当前项目是不是还有其他不为我们知道的秘密?以后的章节再慢慢展开,请关注。
不过我们既然已经完成用户的基本操作,我们就先停下上边的疑惑问题,往下走走,看看 IdentityServer4 到底是如何通过 OpenID Connect 来操作的。
二、简单授权模式 —— Implicit Flow OpenID
0、OpenID Connect授权模式
OPID 认证流程主要是由 OAuth2 的五种授权流程延伸而来的,它有以下 3 种:
- Authorization Code Flow(授权码模式):基于OAuth2的授权码来换取Id Token和Access Token。
- Implicit Flow(简化模式):基于OAuth2的Implicit流程获取Id Token和Access Token。
- Hybrid Flow:混合Authorization Code Flow+Implici Flow获取Id Token和Access Token。
注:OpenID Connect 为什么没有基于OAuth2的Resource Owner Password Credentials Grant和Client Credentials Grant扩展,Resource Owner Password Credentials Grant是需要应用提供账号密码的,账号密码都有了在获取Id Token意义不大。Client Credentials Grant没有用户的参与所以获取Id Token 也没意义。这也能反映授权和认证的差异,以及只使用OAuth2来做身份认证的事情是远远不够的,也是不合适的。
1、概念
简化模式用于获取访问令牌(但它不支持令牌的刷新,之所以所以称为简化模式,和授权码模式比少了获取授权码的步骤),并对运行特定重定向URI的公共客户端进行优化,而这一些列操作通常会使用脚本语言在浏览器中完成,令牌对访问者是可见的,且客户端也不需要验证。
简化模式,主要有下边三个特点:
1、用于“公共”客户端;
2、客户端应用直接从浏览器访问资源;
3、没有显式的客户端身份认证;
2、结构图
为了配合大家理解,我这里有两个场景,大家脑子里先有个画面,然后往下看四个角色和流程图:
场景一:博客园登录,需要获取腾讯的某一个QQ用户的头像和昵称等资源;
场景二:前后端分离,Vue 项目需要获取 Core 项目的 当前test1账号的 数据;
首先先理解下四个角色:
1、Resource Owner(资源拥有者) —— 资源所有者,就比如我们授权登录中的,QQ用户,他才是资源的拥有者。3143422472 / test1账号
2、Resource Server(资源服务器) —— 资源服务器,用来存储用户资源(头像,昵称等)的服务器,比如腾讯QQ。腾讯QQ服务器 / Blog.Core
3、Client(客户端) —— 第三方客户端,比如博客园;https://www.cnblogs.com / Blog.Vue
4、Authorization Server(授权服务器)—— 授权服务器,用来作为认证第三方平台的服务,比如腾讯的QQ互联平台。https://graph.qq.com/oauth2.0/show?whic...... / Blog.Idp
然后咱们看看具体的流程是怎样的:
(流程1:参考网上画的,可能不是很明了)
(流程2:自己根据官网图片做了下修改)
Tips:Web-Hosted Client Resource 服务器相当于是一个存储 accessToken 的地方,通常指浏览器中的存储(cookie、localStorage、SessionStorge、js变量等),一般这个页面是看不到的,而且一般情况是和 Client 客户端写在一起的,当然也有分开的。
步骤解析:
-
客户端携带客户端标识以及重定向URI到授权服务器;
-
用户确认是否要授权给客户端;
-
授权服务器得到许可后,跳转到指定的重定向地址,并将令牌也包含在了里面;
-
客户端不携带上次获取到的包含令牌的片段,去请求资源服务器;
-
资源服务器会向浏览器返回一个脚本;
-
浏览器会根据上一步返回的脚本,去提取在C步骤中获取到的令牌;
-
浏览器将令牌推送给客户端。
(A步骤)中需要用到的参数,注意在这里要使用"application/x-www-form-urlencoded"格式:
-
response_type 必选项,此值必须为"token"
-
client_id 必选项
-
redirect_uri 可选项
-
scope 可选项
-
state 建议选项
例如:
(C步骤)中返回的参数包含:
-
access_token 必选项
-
token_type 必选项
-
expires_in 建议选项
-
scope 可选项
-
state 必选项
例如:
上边我们简单的说了说 Implicit Flow 模式的相关知识点,不知道大家有没有一点点感觉,如果不是很懂,正好感觉配合着下边的代码研究下,二者结合会更好。
三、前后端项目授权联调
因为我们用到了前后端分离项目,所以一定是要三方处理,如果你现在使用的是 MVC 模式的话,我们以后的章节也会说到 授权码授权模式(Authorization Code Flow),这里先把简化模式调通了:
1、授权服务端 —— Implicit(Blog.Idp)
这个配置很简单,在 Blog.Idp 项目中,大家别看是在 Config.cs 文件里,其实它已经在我们上一篇文章中,生成到了数据库中,不懂的请回看上一篇文章
new Client { ClientId = "blogvuejs",//客户端id ClientName = "Blog.Vue JavaScript Client", AllowedGrantTypes = GrantTypes.Implicit, AllowAccessTokensViaBrowser = true, RedirectUris = { "http://localhost:6688/callback" },//回调页面 PostLogoutRedirectUris = { "http://localhost:6688" }, AllowedCorsOrigins = { "http://localhost:6688" }, // 允许的前端获取的作用域 AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, "roles", "blog.core.api" } }
2、资源服务端 —— Bearer(Blog.Core)
这里的配置是在 Blog.Core 我们的资源服务器中,在启动文件 Startup.cs 中,大家自行查看,注意如果使用这个的话,请把 Jwt 认证给注释掉:
services.AddAuthentication("Bearer") .AddIdentityServerAuthentication(options => { options.Authority = "http://localhost:5002";//授权服务器地址 options.RequireHttpsMetadata = false;//是否Https options.ApiName = "blog.core.api";//我们在 Blog.Idp 中配置的资源服务器名 });
添加过程中,可能会需要引用扩展包 : IdentityServer4.AccessTokenValidation 这都是小问题,大家自行检查即可。
3、请求客户端 —— Oidc(Blog.Vue)
上边我们已经在两个服务端做好了配置,客户端如何处理,这个地方才是今天的重头戏,无论是什么客户端,JS 或者 Vue、React、Ng 等等前端框架,都需要用到 oidc-client 这个插件库:
1、安装
执行命令:npm install oidc-client --save
2、封装
注意这个是一个js库,我们就像之前将 SignalR 那样,直接使用就行,不用在 main.js 中引用,但是还是需要先实例化一个用户管理类 ApplicationUserManager 并配置构造函数,请注意这些参数都要和 Blog.Idp 授权服务器配置一致。
在 src 文件夹下 新建 Auth 文件夹,并添加 applicationusermanager.js 来封装我们的连接管理:
import { UserManager } from 'oidc-client' class ApplicationUserManager extends UserManager { constructor () { super({ authority: 'http://localhost:5002',// 授权服务中心地址 client_id: 'blogvuejs',// 客户端 id redirect_uri: 'http://localhost:6688/callback',// 登录回调地址 response_type: 'id_token token', scope: 'openid profile roles blog.core.api',// 作用域也要一一匹配 post_logout_redirect_uri: 'http://localhost:6688' //登出后回调地址 }) } async login () { await this.signinRedirect() return this.getUser() } async logout () { return this.signoutRedirect() } }
同时为了配合其他页面使用,我们封装几个常用的方法,在 Auth 文件夹下,新建 UserAuth.js 来封装用户的一些基本信息:
import applicationUserManager from "./applicationusermanager"; const userAuth = { data() { return { user: { name: "", isAuthenticated: false } }; }, methods: { async refreshUserInfo() {//获取用户信息 const user = await applicationUserManager.getUser(); if (user) { this.user.name = user.profile.name; this.user.isAuthenticated = true; } else { this.user.name = ""; this.user.isAuthenticated = false; } } }, async created() { await this.refreshUserInfo(); } }; export default userAuth;
3、发起 登录/登出 请求
我们封装好了方法,下边就是直接设计业务逻辑了,过程很简单,在 App.vue 组件中:
1、每次路由跳转需异步获取用户数据;
2、发起异步登录请求;
3、发起异步登出请求;
import applicationUserManager from "./Auth/applicationusermanager"; import userAuth from "./Auth/UserAuth"; export default { name: "app", mixins: [userAuth], data: function() { return {}; }, watch: { $route: async function(to, from) { //这里使用Id4授权认证,用Jwt,请删之; // await this.refreshUserInfo(); } }, methods: { async login() { try { await applicationUserManager.login(); } catch (error) { console.log(error); this.$root.$emit("show-snackbar", { message: error }); } }, async logout() { try { await applicationUserManager.logout(); this.$store.commit("saveToken", ""); } catch (error) { console.log(error); this.$root.$emit("show-snackbar", { message: error }); } } } };
4、回调
在上边的用户管理配置中,我们用到了一个回调页面,这个很重要,因为我们在登录成功后,需要调整到客户端,并且需要将信息给存储下来,就是上边流程图中,我们说到的 客户端资源
具体怎么写的,很简单,在 views 视图页面文件夹下,新建一个 LoginCallbackView.vue 页面:
import applicationUserManager from '../Auth/applicationusermanager' export default { async created () { try { // 核心的就是这里了 await applicationUserManager.signinRedirectCallback() let user = await applicationUserManager.getUser() // 将 token 存储在客户端 this.$store.commit("saveToken", user.access_token); // 调整首页 this.$router.push({name: 'home'}) } catch (e) { console.log(e) this.$root.$emit('show-snackbar', { message: e }) } } }
四、结语
本文还是延续上篇文章的快速讲解的风格,简单连贯的把用户管理和前后端联调的内容通了一遍,总结一下:
1、分析了用户是否需要角色等策略的缘由;
2、实现了对用户的基本操作——CURD+重置密码;
3、授权项目中还遗留了一片未知的知识块,亟待探索;
4、实现了客户端、资源服务器、授权服务器的第一次联调;
5、重点讲解了五大模式中的 Implicit Flow 简化模式的概念和应用场景;
6、同时也把 Hybrid Flow 混合模式给引申出来,因为它基于 角色+策略 的授权;
当然,通过这一篇的学习,又开拓出了更多的未知领域,IdentityServer4 没有我们想想的那么难,但是肯定也不是一个 Demo 就能说的完的简单,
如何解决文章中提到的,打算提到的,未提到的各种问题呢,请持续关注吧。