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

前言

市面上關於認證授權的框架已經比較豐富了,大都是關於單體應用的認證授權,在分佈式架構下,使用比較多的方案是--<應用網關>,網關裏集中認證,將認證通過的請求再轉發給代理的服務,這種中心化的方式並不適用於微服務,這裏討論另一種方案--<認證中心>,利用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,這個可以根據自己的需求自行拓展,不用也行,我這裏還是用的原來的表,只是重寫了登錄邏輯,方便後面拓展更多的登錄方式,看着挺簡單,其實一點也不復雜

/// <summary>
/// 登錄
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[HttpPost]
public async 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: 0 2px 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
這個網上有示例,照着抄就可以了,基本沒有坑

var config = {
    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 版本不對,這裏要加入點自己的理解

var config = {
    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"
        })
        }
    })

    function getURLParams() {
        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調用其它應用配置的前端退出通道

統一退出流程圖

image

public async 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,問題解決

class LocalStorageStateStore extends Oidc.WebStorageStateStore {
    constructor() {
        super(window.localStorage);
    }
}

//配置信息
var config = {
    ...
    userStore: new LocalStorageStateStore({ 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,這裏我畫了個圖,便於理解
image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章