经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » 移动开发 » 微信开发 » 查看文章
微服务下认证授权框架的探讨
来源:cnblogs  作者:提伯斯  时间:2024/5/24 10:27:50  对本文有异议

前言

市面上关于认证授权的框架已经比较丰富了,大都是关于单体应用的认证授权,在分布式架构下,使用比较多的方案是--<应用网关>,网关里集中认证,将认证通过的请求再转发给代理的服务,这种中心化的方式并不适用于微服务,这里讨论另一种方案--<认证中心>,利用jwt去中心化的特性,减轻认证中心的压力,有理解错误的地方,欢迎拍砖,以免误人子弟,有点干货,但是不多
image

需求背景

一个项目拆分为若干个微服务,根据业务形态,大致分为以下几种工程
1.纯前端应用
示例,一个简单的H5活动页面,商户仅仅需要登录,就可以参与活动
2.前后端分离应用
示例,如xxx后台,xxxApi,由一个前端项目+一个后端项目组成
3.客户端应用
示例,控制台项目,如任务调度,挂机服务
现在有N个项目,每个项目又由N个微服务组成,微服务之间需要一套统一的权限管理,它需要同时满足商户(客户)在多个项目间无感切换,也需要满足开发者应用之间调用的认证授权
示例,xxx开放平台,一般有两个角色,商家和开发者, 开发者创建应用,研发,上线应用, 商家申请应用,使用应用
开发者A,注册成为xxx开放平台的开发者,创建了一个测试应用,测试应用依赖其它应用的某些能力(如,短信,短链....),申请获得这些能力后,开发完成,将测试应用发布到应用市场,
商家B,申请开通了测试应用和XXX应用,它可以无感的在两个应用间切换(单点登录)

OAuth2.0

OAuth 引入了一个授权层,用来分离两种不同的角色:客户端和资源所有者。......资源所有者同意以后,资源服务器可以向客户端颁发令牌。客户端通过令牌,去请求数据。
OAuth 2.0 规定了四种获得令牌的流程。你可以选择最适合自己的那一种,向第三方应用颁发令牌。下面就是这四种授权方式。

  • 授权码(authorization-code)
  • 隐藏式(implicit)
  • 密码式(password)
  • 客户端凭证(client credentials)

image

演示效果

  1. https://localhost:6201 认证中心
  2. https://localhost:9001 应用A implicit模式
  3. https://localhost:9002 应用B implicit模式
  4. https://localhost:9003 应用C authorization-code模式
    image

解决的问题

  1. 单点登录
  2. 单点退出
  3. 统一登录中心(通行证)
  4. 用户身份鉴权
  5. 服务的最小作用域为api

找个靠谱点的开源认证授权框架

在.net里,比较靠前的两个框架(IdentityServer4,OpenIddict),这两个都实现了OAuth2.0,相较而言对IdentityServer4更加熟悉点,就基于这个开始了,顺便扫盲,听说后面不开源了,不过对于我来说并没有影响,现有的功能已经完全够用了

IdentityServer4 网上的资料非常多,稍微爬点坑就能搭建起来,并将OAuth2.0的4种认证模式都体验一遍,这里就不多介绍了,这里强烈推荐Skoruba.IdentityServer4.Admin 这个开源项目,方便熟悉ids4里的各种配置,有助于理解

踏坑第一步,弄个自定义的登录页面

把数据持久化到数据库,登录用的是Identity,这个可以根据自己的需求自行拓展,不用也行,我这里还是用的原来的表,只是重写了登录逻辑,方便后面拓展更多的登录方式,看着挺简单,其实一点也不复杂

  1. /// <summary>
  2. /// 登录
  3. /// </summary>
  4. /// <param name="model"></param>
  5. /// <returns></returns>
  6. [HttpPost]
  7. public async Task<IActionResult> Login(LoginRequest model)
  8. {
  9. model.ReturnUrl = model.ReturnUrl ?? "/";
  10. var user = await _context.Users.FirstOrDefaultAsync(m => m.UserName == model.UserName && m.PasswordHash == model.Password.Sha256());
  11. if (user != null)
  12. {
  13. AuthenticationProperties props = new AuthenticationProperties
  14. {
  15. IsPersistent = true,
  16. ExpiresUtc = DateTimeOffset.UtcNow.Add(TimeSpan.FromDays(1))
  17. };
  18. Claim[] claim = new Claim[] {
  19. new Claim(ClaimTypes.Role, "admin"),
  20. new Claim(ClaimTypes.Name, user.UserName),
  21. new Claim(ClaimTypes.MobilePhone, user.PhoneNumber ?? "-"),
  22. new Claim("userId", user.Id),
  23. new Claim("phone",user.PhoneNumber ?? "-")
  24. };
  25. await HttpContext.SignInAsync(new IdentityServer4.IdentityServerUser(user.Id) { AdditionalClaims = claim }, props);
  26. return Ok(Model.Response.JsonResult.Success(message:"登录成功",returnUrl: model.ReturnUrl));
  27. }
  28. return Ok(Model.Response.JsonResult.Error(message: "登录失败", returnUrl: model.ReturnUrl));
  29. }
  1. @{
  2. Layout = null;
  3. }
  4. <body>
  5. <div class="login-container">
  6. <h2>登录</h2>
  7. <form id="myForm">
  8. <label for="username">用户名:</label>
  9. <input type="text" id="userName" name="userName" value="test" required>
  10. <label for="password">密码:</label>
  11. <input type="password" id="password" name="password" value="123456" required>
  12. <button type="submit">登录</button>
  13. </form>
  14. </div>
  15. </body>
  16. <script src="/js/jquery.min.js"></script>
  17. <script src="/js/jquery.unobtrusive-ajax.js"></script>
  18. <script>
  19. document.getElementById("myForm").addEventListener("submit", function (event) {
  20. event.preventDefault(); // 阻止表单默认提交行为
  21. var inputs = document.querySelectorAll("form input[required]");
  22. var hasError = false;
  23. // 遍历所有required的input元素
  24. inputs.forEach(function (input) {
  25. if (input.checkValidity() === false) {
  26. // 如果验证失败,标记错误并阻止AJAX请求
  27. input.classList.add("error"); // 你可以添加一个错误样式
  28. hasError = true;
  29. } else {
  30. input.classList.remove("error"); // 清除错误样式
  31. }
  32. });
  33. if (!hasError) {
  34. // 如果没有错误,执行AJAX请求
  35. performAjaxRequest();
  36. }
  37. });
  38. function performAjaxRequest() {
  39. const urlParams = new URLSearchParams(window.location.search);
  40. const returnUrl = urlParams.get('ReturnUrl') || '';
  41. let param = {
  42. "userName": $("#userName").val(),
  43. "password": $("#password").val(),
  44. "returnUrl": returnUrl
  45. }
  46. $.post("/account/login", param, function (data) {
  47. console.log(data)
  48. if (data.code != "0") {
  49. alert(data.message)
  50. } else {
  51. window.location.href = data.returnUrl;
  52. }
  53. })
  54. }
  55. </script>
  56. <style>
  57. body {
  58. font-family: Arial, sans-serif;
  59. background-color: #f0f2f5;
  60. display: flex;
  61. justify-content: center;
  62. align-items: center;
  63. height: 100vh;
  64. margin: 0;
  65. }
  66. .login-container {
  67. background-color: white;
  68. padding: 20px;
  69. border-radius: 5px;
  70. box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
  71. }
  72. input[type="text"], input[type="password"] {
  73. width: 100%;
  74. padding: 10px;
  75. margin-bottom: 15px;
  76. border: 1px solid #ddd;
  77. border-radius: 3px;
  78. }
  79. button {
  80. width: 100%;
  81. padding: 10px;
  82. background-color: #007bff;
  83. color: white;
  84. border: none;
  85. border-radius: 3px;
  86. cursor: pointer;
  87. }
  88. button:hover {
  89. background-color: #0056b3;
  90. }
  91. </style>

踏坑第二步,单点登录

implicit
这个网上有示例,照着抄就可以了,基本没有坑

  1. var config = {
  2. authority: "https://localhost:6201",
  3. client_id: "3",
  4. redirect_uri: "https://localhost:9001/callback.html",
  5. //这里别写错
  6. response_type: "id_token token",
  7. post_logout_redirect_uri: "https://localhost:9001/logout.html",
  8. scope: "openid profile api" //范围一定要写,不然access_token访问资源会401
  9. };
  1. <script src="/js/oidc-client.js"></script>
  2. <script src="/js/config.js"></script>
  3. <script>
  4. mgr.signinRedirectCallback().then(function () {
  5. window.location = "/index.html";
  6. }).catch(function (e) {
  7. console.log(e);
  8. });
  9. </script>

client_credentials
这个有大坑,网上90%的文档都是错的,然后抄来抄去,或者说我的oidc-client.js 版本不对,这里要加入点自己的理解

  1. var config = {
  2. authority: "https://localhost:6201",
  3. client_id: "20231020001",
  4. redirect_uri: "https://localhost:9003/signin-oidc.html",
  5. //这里别写错,
  6. response_type: "code",
  7. post_logout_redirect_uri: "https://localhost:9003/logout.html",
  8. scope: "openid offline_access api testScope" //范围一定要写,不然access_token访问资源会401
  9. };

对比这两个模式,验证码模式返回的是code,并不是access_token,所以还用上面的回调页面,肯定报错,熟悉OAuth2.0的同学,都知道缺少一个通过code换取access_token步骤,这里我们从新写回调页面,核心代码就是获取url上的code,然后换取access_token,再将凭证信息写入到缓存

  1. var urlParams = getURLParams();
  2. let url = "https://localhost:5002/api/authorization_code";
  3. var param = {...urlParams,"redirect_uri":config.redirect_uri}
  4. console.log(url)
  5. $.post(url,param,function(data){
  6. console.log(data)
  7. if(data.code != "0"){
  8. alert(data.message)
  9. }else{
  10. let user = new User(data.data);
  11. console.log(user)
  12. mgr.storeUser(user).then(function(e){
  13. window.location.href="https://localhost:9003"
  14. })
  15. }
  16. })
  17. function getURLParams() {
  18. const searchURL = location.search; // 获取到URL中的参数串
  19. const params = new URLSearchParams(searchURL);
  20. const valueObj = Object.fromEntries(params); // fromEntries是es10提出来的方法polyfill和babel都不转换这个方法
  21. return valueObj;
  22. }

真正的坑点在oidc-client.js写入凭证,各种GPT提问,最终弄出来,再弄不出来,我就要考虑手动写入缓存了,但是为了单点登录里统一管理凭证,还是选择用oidc-client.js内置的方法

  1. //重新定义用户对象
  2. var User = function () {
  3. function User(_ref) {
  4. var id_token = _ref.id_token,
  5. session_state = _ref.session_state,
  6. access_token = _ref.access_token,
  7. token_type = _ref.token_type,
  8. scope = _ref.scope,
  9. profile = _ref.profile,
  10. expires_at = _ref.expires_in,
  11. state = _ref.state;
  12. this.id_token = id_token;
  13. this.session_state = session_state;
  14. this.access_token = access_token;
  15. this.token_type = token_type;
  16. this.scope = scope;
  17. this.profile = profile;
  18. this.expires_at = expires_at;
  19. this.state = state;
  20. }
  21. User.prototype.toStorageString = function toStorageString() {
  22. return JSON.stringify({
  23. id_token: this.id_token,
  24. session_state: this.session_state,
  25. access_token: this.access_token,
  26. token_type: this.token_type,
  27. scope: this.scope,
  28. profile: this.profile,
  29. expires_at: this.expires_at
  30. });
  31. };
  32. User.fromStorageString = function fromStorageString(storageString) {
  33. return new User(JSON.parse(storageString));
  34. };
  35. return User;
  36. }();

踏坑第三步,单点退出

不出意外,肯定是有坑的,细心的同学已经发现应用C,单点退出失败了,我们来盘一下这里的逻辑
在ids4里面,客户端会配置两个退出通道,FrontChannelLogoutUri(前端退出通道),BackChannelLogoutUri(后端退出通道),怎么调用这个取决于项目,我们这里主要是web项目,所以配置前端退出通道就可以了,实现也很简单,应用退出的时候,重定向到认证中心的统一退出页面,认证中心退出成功后,再使用iframe调用其它应用配置的前端退出通道

统一退出流程图

image

  1. public async Task<IActionResult> Logout(string logoutId)
  2. {
  3. await _signInManager.SignOutAsync();
  4. var refererUrl = Request.Headers["Referer"].ToString();
  5. if (string.IsNullOrEmpty(refererUrl))
  6. {
  7. refererUrl = "/account/login";
  8. }
  9. var frontChannelLogoutUri = await _configDbContext.Clients.AsNoTracking().Where(m => m.Enabled).Where(m=>!string.IsNullOrEmpty(m.FrontChannelLogoutUri)).Select(m=>m.FrontChannelLogoutUri).ToListAsync();
  10. ViewBag.FrontChannelLogoutUri = frontChannelLogoutUri;
  11. ViewBag.RefererUrl = refererUrl;
  12. return View();
  13. }

回到前面应用C没有正常退出的原因,仔细观察,原来oidc-client.js默认的存储策略是将凭证存储在SessionStorage,在浏览器里每个页签的SessionStorage都是独立的,所以iframe里调用退出页面,是无法清除当前页面的凭证的,解决方案就是修改oidc-client.js默认的存储策略,改为LocalStorage,问题解决

  1. class LocalStorageStateStore extends Oidc.WebStorageStateStore {
  2. constructor() {
  3. super(window.localStorage);
  4. }
  5. }
  6. //配置信息
  7. var config = {
  8. ...
  9. userStore: new LocalStorageStateStore({ store: localStorage })
  10. ...
  11. };

踏坑第四步,访问受保护的资源

客户端拿到了access_token,只要客户端包含对应的作用域,就能访问对应的api,不出意外,这里肯定要出点幺蛾子,前面都是铺垫,好戏才刚刚开始
问题出在作用域上,同一个客户端,配置了client credentials 与 authorization-code,它们获取的作用域是不一样的,这里对应不同的场景
authorization-code 这里涉及到登录,那么作用域一般包含openId,phone.... 用户身份相关的信息,属于前端调用,access_token对用户可见,这里我用前端作用域代替,且作用域必须显示声明(也就是在前端配置文件里写死,可以翻翻上面的config里scope属性)
client credentials 不涉及登录,可以理解成后端调用,access_token对用户不可见,这里我用后端作用域代替

那它们的意义(粒度)也是完全不同的,作用域可以有多种用途,所以通过authorization-code获取的access_token,不能直接访问受保护的资源,而是应该调用它的后端服务,这里作用域的意义是指服务本身,config.scope = 'openId a.api b.api',然后再通过凭证里携带的用户身份标识,做具体接口的鉴权
通过client credentials获取的access_token,它的作用域意义是指资源服务的具体api,这里我画了个图,便于理解
image

原文链接:https://www.cnblogs.com/tibos/p/18208102

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号