前言
市面上關於認證授權的框架已經比較豐富了,大都是關於單體應用的認證授權,在分散式架構下,使用比較多的方案是--<應用閘道器>,閘道器裡集中認證,將認證透過的請求再轉發給代理的服務,這種中心化的方式並不適用於微服務,這裡討論另一種方案--<認證中心>,利用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)
演示效果
- https://localhost:6201 認證中心
- https://localhost:9001 應用A implicit模式
- https://localhost:9002 應用B implicit模式
- https://localhost:9003 應用C authorization-code模式
解決的問題
- 單點登入
- 單點退出
- 統一登入中心(通行證)
- 使用者身份鑑權
- 服務的最小作用域為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呼叫其它應用配置的前端退出通道
統一退出流程圖
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,這裡我畫了個圖,便於理解