當前位置: 妍妍網 > 碼農

.NET 微服務下認證授權框架的探討

2024-05-26碼農

前言

市面上關於認證授權的框架已經比較豐富了,大都是關於單體套用的認證授權,在分布式架構下,使用比較多的方案是--< 套用閘道器 >,閘道器裏集中認證,將認證透過的請求再轉發給代理的服務,這種中心化的方式並不適用於微服務。

這裏討論另一種方案--< 認證中心 >,利用jwt去中心化的特性,減輕認證中心的壓力,有理解錯誤的地方,歡迎拍磚,以免誤人子弟,有點幹貨,但是不多。

需求背景

一個計畫拆分為若幹個微服務,根據業務形態,大致分為以下幾種工程

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)

  • 演示效果

    1、https://localhost:6201 認證中心

    2、https://localhost:9001 套用A implicit模式

    3、https://localhost:9002 套用B implicit模式

    4、https://localhost:9003 套用C authorization-code模式


    解決的問題

    1、單點登入

    2、單點結束

    3、統一登入中心(通行證)

    4、使用者身份鑒權

    5、服務的最小作用域為api

    找個靠譜點的開源認證授權框架

    在.NET 裏比較靠前的兩個框架(IdentityServer4,OpenIddict),這兩個都實作了OAuth2.0,相較而言對IdentityServer4更加熟悉點,就基於這個開始了,順便掃盲,聽說後面不開源了,不過對於我來說並沒有影響,現有的功能已經完全夠用了

    IdentityServer4 網上的資料非常多,稍微爬點坑就能搭建起來,並將OAuth2.0的4種認證模式都體驗一遍,這裏就不多介紹了,這裏強烈推薦Skoruba.IdentityServer4.Admin 這個開源計畫,方便熟悉ids4裏的各種配置,有助於理解

    踏坑第一步,弄個自訂的登入頁面

    把數據持久化到資料庫,登入用的是Identity,這個可以根據自己的需求自行拓展,不用也行,我這裏還是用的原來的表,只是重寫了登入邏輯,方便後面拓展更多的登入方式,看著挺簡單,其實一點也不復雜

    ///<summary>
    /// 登入
    ///</summary>
    ///<param name="model"></param>
    ///<returns></returns>
    [HttpPost]
    publicasync Task<IActionResult> Login(LoginRequest model)
    {
    model.ReturnUrl = model.ReturnUrl ?? "/";
    var user = await _context.Users.FirstOrDefaultAsync(m => m.UserName == model.UserName && m.PasswordHash == model.Password.Sha256());
    if (user != null
    {
    AuthenticationProperties props = new AuthenticationProperties
    {
    IsPersistent = true
    ExpiresUtc = DateTimeOffset.UtcNow.Add(TimeSpan.FromDays(1))
    };
    Claim[] claim = new Claim[] {
    new Claim(ClaimTypes.Role, "admin"),
    new Claim(ClaimTypes.Name, user.UserName),
    new Claim(ClaimTypes.MobilePhone, user.PhoneNumber ?? "-"),
    new Claim("userId", user.Id),
    new Claim("phone",user.PhoneNumber ?? "-")
    };
    await HttpContext.SignInAsync(new IdentityServer4.IdentityServerUser(user.Id) { AdditionalClaims = claim }, props);
    return Ok(Model.Response.JsonResult.Success(message:"登入成功",returnUrl: model.ReturnUrl));
    }
    return Ok(Model.Response.JsonResult.Error(message: "登入失敗", returnUrl: model.ReturnUrl));
    }
    @{
    Layout = null;
    }
    <body>
    <div  class="login-container">
    <h2>登入</h2>
    <form id="myForm">
    <label for="username">使用者名稱:</label>
    <input type="text" id="userName" name="userName"value="test" required>
    <label for="password">密碼:</label>
    <input type="password" id="password" name="password"value="123456" required>
    <button type="submit">登入</button>
    </form>
    </div>
    </body>
    <script src="/js/jquery.min.js"></script>
    <script src="/js/jquery.unobtrusive-ajax.js"></script>
    <script>
    document.getElementById("myForm").addEventListener("submit", function (event) {
    event.preventDefault(); // 阻止表單預設送出行為
    var inputs = document.querySelectorAll("form input[required]");
    var hasError = false;
    // 遍歷所有required的input元素
    inputs.forEach(function (input) {
    if (input.checkValidity() === false) {
    // 如果驗證失敗,標記錯誤並阻止AJAX請求
    input. classList.add("error"); // 你可以添加一個錯誤樣式
    hasError = true;
    else {
    input. classList.remove("error"); // 清除錯誤樣式
    }
    });
    if (!hasError) {
    // 如果沒有錯誤,執行AJAX請求
    performAjaxRequest();
    }
    });
    function performAjaxRequest() {
    const urlParams = new URLSearchParams(window.location.search);
    const returnUrl = urlParams.get('ReturnUrl') || '';
    let param = {
    "userName": $("#userName").val(),
    "password": $("#password").val(),
    "returnUrl": returnUrl
    }
    $.post("/account/login", param, function (data) {
    console.log(data)
    if (data.code != "0") {
    alert(data.message)
    else {
    window.location.href = data.returnUrl;
    }
    })
    }
    </script>
    < style>
    body {
    font-family: Arial, sans-serif;
    background-color: #f0f2f5;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    margin: 0;
    }
    .login-container {
    background-color: white;
    padding: 20px;
    border-radius: 5px;
    box-shadow: 02px 5px rgba(0, 0, 0, 0.1);
    }
    input[type="text"], input[type="password"] {
    width: 100%;
    padding: 10px;
    margin-bottom: 15px;
    border: 1px solid #ddd;
    border-radius: 3px;
    }
    button {
    width: 100%;
    padding: 10px;
    background-color: #007bff;
    color: white;
    border: none;
    border-radius: 3px;
    cursor: pointer;
    }
    button:hover {
    background-color: #0056b3;
    }
    </ style>




    踏坑第二步,單點登入

    implicit

    這個網上有範例,照著抄就可以了,基本沒有坑

    varconfig = {
    authority: "https://localhost:6201"
    client_id: "3"
    redirect_uri: "https://localhost:9001/callback.html"
    //這裏別寫錯
    response_type: "id_token token"
    post_logout_redirect_uri: "https://localhost:9001/logout.html"
    scope: "openid profile api"//範圍一定要寫,不然access_token存取資源會401
    };
    <script src="/js/oidc-client.js"></script>
    <script src="/js/config.js"></script>
    <script>
    mgr.signinRedirectCallback().then(function () {
    window.location = "/index.html";
    }).catch(function (e) {
    console.log(e);
    });
    </script>

    client_credentials

    這個有大坑,網上90%的文件都是錯的,然後抄來抄去,或者說我的oidc-client.js 版本不對,這裏要加入點自己的理解

    varconfig = {
    authority: "https://localhost:6201"
    client_id: "20231020001"
    redirect_uri: "https://localhost:9003/signin-oidc.html"
    //這裏別寫錯,
    response_type: "code"
    post_logout_redirect_uri: "https://localhost:9003/logout.html"
    scope: "openid offline_access api testScope"//範圍一定要寫,不然access_token存取資源會401
    };

    對比這兩個模式,驗證碼模式返回的是code,並不是access_token,所以還用上面的回呼頁面,肯定報錯,熟悉OAuth2.0的同學,都知道缺少一個透過code換取access_token步驟,這裏我們從新寫回呼頁面,核心程式碼就是獲取url上的code,然後換取access_token,再將憑證資訊寫入到緩存

    var urlParams = getURLParams();
    let url = "https://localhost:5002/api/authorization_code";
    var param = {...urlParams,"redirect_uri":config.redirect_uri}
    console.log(url)
    $.post(url,param,function(data){
    console.log(data)
    if(data.code != "0"){
    alert(data.message)
    }else{
    let user = new User(data.data);
    console.log(user)
    mgr.storeUser(user).then(function(e){
    window.location.href="https://localhost:9003"
    })
    }
    })
    functiongetURLParams() {
    const searchURL = location.search; // 獲取到URL中的參數串
    const params = new URLSearchParams(searchURL);
    const valueObj = Object.fromEntries(params); // fromEntries是es10提出來的方法polyfill和babel都不轉換這個方法
    return valueObj;
    }

    真正的坑點在oidc-client.js寫入憑證,各種GPT提問,最終弄出來,再弄不出來,我就要考慮手動寫入緩存了,但是為了單點登入裏統一管理憑證,還是選擇用oidc-client.js內建的方法

    //重新定義使用者物件
    var User = function () {
    function User(_ref) {
    var id_token = _ref.id_token,
    session_state = _ref.session_state,
    access_token = _ref.access_token,
    token_type = _ref.token_type,
    scope = _ref.scope,
    profile = _ref.profile,
    expires_at = _ref.expires_in,
    state = _ref.state;
    this.id_token = id_token;
    this.session_state = session_state;
    this.access_token = access_token;
    this.token_type = token_type;
    this.scope = scope;
    this.profile = profile;
    this.expires_at = expires_at;
    this.state = state;
    }
    User.prototype.toStorageString = function toStorageString() {
    return JSON.stringify({
    id_token: this.id_token,
    session_state: this.session_state,
    access_token: this.access_token,
    token_type: this.token_type,
    scope: this.scope,
    profile: this.profile,
    expires_at: this.expires_at
    });
    };
    User.fromStorageString = function fromStorageString(storageString) {
    return new User(JSON.parse(storageString));
    };
    return User;
    }();

    踏坑第三步,單點結束

    不出意外,肯定是有坑的,細心的同學已經發現套用C,單點結束失敗了,我們來盤一下這裏的邏輯 在ids4裏面,客戶端會配置兩個結束通道, FrontChannelLogoutUri (前端結束通道), BackChannelLogoutUri (後端結束通道),怎麽呼叫這個取決於計畫,我們這裏主要是web計畫,所以配置前端結束通道就可以了,實作也很簡單,套用結束的時候,重新導向到認證中心的統一結束頁面,認證中心結束成功後,再使用iframe呼叫其它套用配置的前端結束通道

    統一結束流程圖

    publicasync Task<IActionResult> Logout(string logoutId)
    {
    await _signInManager.SignOutAsync();
    var refererUrl = Request.Headers["Referer"].ToString();
    if (string.IsNullOrEmpty(refererUrl)) 
    {
    refererUrl = "/account/login";
    }
    var frontChannelLogoutUri = await _configDbContext.Clients.AsNoTracking().Where(m => m.Enabled).Where(m=>!string.IsNullOrEmpty(m.FrontChannelLogoutUri)).Select(m=>m.FrontChannelLogoutUri).ToListAsync();
    ViewBag.FrontChannelLogoutUri = frontChannelLogoutUri;
    ViewBag.RefererUrl = refererUrl;
    return View();
    }

    回到前面套用C沒有正常結束的原因,仔細觀察,原來oidc-client.js預設的儲存策略是將憑證儲存在 SessionStorage ,在瀏覽器裏每個頁簽的SessionStorage都是獨立的,所以iframe裏呼叫結束頁面,是無法清除當前頁面的憑證的,解決方案就是修改oidc-client.js預設的儲存策略,改為 LocalStorage ,問題解決

    classLocalStorageStateStoreextendsOidc.WebStorageStateStore{
    constructor() {
    super(window.localStorage);
    }
    }
    //配置資訊
    var config = {
    ...
    userStore: newLocalStorageStateStore({ store: localStorage })
    ...
    };

    踏坑第四步,存取受保護的資源

    客戶端拿到了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,這裏我畫了個圖,便於理解

    轉自:提伯斯

    連結:cnblogs.com/tibos/p/18208102

    - EOF -

    推薦閱讀 點選標題可跳轉

    看完本文有收獲?請轉發分享給更多人

    推薦關註「DotNet」,提升.Net技能

    點贊和在看就是最大的支持❤️