前言
市面上關於認證授權的框架已經比較豐富了,大都是關於單體套用的認證授權,在分布式架構下,使用比較多的方案是--< 套用閘道器 >,閘道器裏集中認證,將認證透過的請求再轉發給代理的服務,這種中心化的方式並不適用於微服務。
這裏討論另一種方案--< 認證中心 >,利用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模式
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技能
點贊和在看就是最大的支持❤️