⚠️ 本篇將先以舊版 Backstage 開始介紹,Backstage OIDC 插件目前仍未遷移至新版架構。
目前在自己的案例中,成功透過遷移插件到新版的 Backstage,截至本篇撰寫時間,官方尚未將該插件完成遷移,詳情請看下方 Backstage Github issue。但本篇將會由舊版 Backstage 的架構來演示串接過程,是為了後續實做遷移插件到新版架構時,讀者能更深刻的感受到變化,也更能藉此理解 Backstage 的架構,畢竟在新版架構中,一切插件都包裝的非常完整,我們只需 import 一行程式,無意間忽略了很多細節,所以接下來的過程將以概念為重,省略掉一些不必要的舊版架構過程。
要實現 SSO 單點登入的身份驗證,市面上有許多現成的解決方案可供選擇。與其從頭開始自行開發驗證架構,採用這些經過考驗的方案不僅能節省時間,還能確保安全性,因為它們已經通過了長期使用的檢驗。
其中比較常見的解決方案之一是 Keycloak。這是一款以 Java 開發的身份驗證和授權管理系統,由 Red Hat 紅帽公司旗下的 JBoss 開發。Keycloak 擁有悠久的發展歷史,提供一鍵開箱即用的使用體驗,幾乎所有的設定都能在介面上完成,這使得它成為許多開源應用的首選 SSO 解決方案。
由於公司中大部分系統架構都是基於 .NET,為了後續更好維護並充分利用人才與資源,我們選擇使用基於 C# 的 IdentityServer (現為 Duende IdentityServer) 作為身份驗證解決方案。IdentityServer 在微軟官方文檔中被作為保護 .NET Web 應用的示範工具,而以代碼為基底的建構模式,不僅提供靈活的自定義設定,也能無縫整合多種 .NET 技術。由於公司目前的身份驗證架構相對複雜,我們需要一個可高度自定義的解決方案,最後透過 IdentityServer 將 OIDC 驗證流程與公司的 OTP(一次性密碼)登入模式整合。相較之下,像 Keycloak 這類強調即插即用的解決方案則不太適合我們的需求。
選擇合適的 SSO 方案取決於專案的需求情境。對於需要高度客製化的應用,IdentityServer 提供了強大的擴展性,能夠滿足複雜的身份驗證要求。若專案的重點在於快速部署且希望依賴於穩定的開源社群支援,Keycloak 可能會是一個更好的選擇。
IdentityServer 的歷史可以追溯到 2015 年或更早。最初作為一個開源項目,IdentityServer 經過多年的優化與迭代,成為網絡上以高安全性著稱的解決方案。其最新版本由 Duende Software 公司負責營利維護,稱為 Duende IdentityServer。舊版本 IdentityServer4 (.NET Core 3.1) 已於 2022 年 12 月 13 日停止更新。目前,Duende IdentityServer 使用 .NET Core 6,完全符合協議標準,並已獲得 OpenID 基金會的正式認證。
OIDC (OpenID Connect),是一種基於 OAuth 2.0 的擴展身份驗證協議。在 OAuth 2.0 的基礎上增加了一層身份驗證功能,在驗證使用者的身份同時獲取用戶的基本資料,比如用來映射 Backstage 實體電子郵件地址。而OAuth 2.0 本身僅是一種授權協議,主要用於允許第三方應用程序安全地訪問用戶的資源,不涉及用戶身份的驗證。
OIDC code 模式流程圖 / 圖片來源 - https://darutk.medium.com/diagrams-of-all-the-openid-connect-flows-6968e3990660
簡單來說,在 OAuth 2.0 中,客戶端首先向伺服器請求授權碼,當用戶通過身份驗證後,伺服器會返回一個授權碼。客戶端再使用這個授權碼向伺服器請求訪問令牌,用於存取受保護的資源。如果應用加入了 OIDC,那麼伺服器還會返回一個身份令牌(ID Token),這個令牌包含了用戶的基本資料,讓應用能夠驗證用戶身份。
註: 本篇只對 OAuth 2.0 與 OIDC 概念做簡單提要,詳細運作流程請參考最下方連結。
IdentityServer 預設用戶端需要使用 SSL 憑證與服務端進行驗證,所以我們必須先為 Backstage 設定 localhost 的自簽署憑證,讓 Backstage 以 https://localhost 的形式啟用。
https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/enable-ssl-self-signed.md
首先我們需先透過 mkcert
這個工具來替本機產自簽憑證,首先可以透過指令來安裝 :
# 在 macOS 我使用 Homebrew 安裝
brew install mkcert
# 在 Windows 我使用 Chocolatey 安裝
choco install mkcert
接著可以放到 Backstage 專案中創建一個資料夾,並移動到該目錄底下直接下產生憑證指令,為本機創建一個可被系統信任的自簽憑證,範例如下 :
mkdir cert
cd cert
mkcert localhost
成功訊息
接著到 app-config.yaml
中為 app
backend
新增以下的設定,將剛剛創建的憑證路徑引入,並記得修改 Url 成 https
,包含 cors
的部分也要記得。
https:
certificate:
cert:
$file: ./cert/localhost.pem
key:
$file: ./cert/localhost-key.pem
在修改完畢後啟動時,你可能會遇到類似以下的錯誤,大致上是在說明驗證失敗等問題。這時我們可以透過在終端設定指令export NODE_TLS_REJECT_UNAUTHORIZED=0
(macOS環境),讓 Node.js 忽略 SSL的證書驗證 ,本地開發時可以透過這個方式避免很多憑證相關的問題,但在部署環境下這種繞過安全檢查的方式,可能會讓系統處於風險中,這點在後續正式部署時會再詳細說明。
這時可以看到我們成功為 Backstage 加入 SSL 憑證,網址也變成 https 了,接著我們就可以進行後續與 IdentityServer 串接 OIDC 協議。
官方提供的 OIDC 插件尚未遷移至新版系統,以下程式碼展示了舊版 Backstage 架構下的安裝步驟。讀者可以參考這些步驟了解舊版 Backstage 的運作邏輯,後面我們將以新版系統作為示範。
以下是 Backstage 文件中提到的五個步驟,我將展示先前已成功實做的程式碼進行說明。
首先將第一第二步一起實做在app/src/api.ts
,provider ID 可以自行定義作為唯一識別名稱,我命名為「sso-auth-provider」它將會被 createApiFactory
所引用,並在 app-config.yaml
中設定為認證提供者。
💡 在定義 Scopes 記得加入 offline_access,讓 Backstage 可以跟 IdentityServer 做 refresh token,否則每次在重新整理頁面時就會被登出。
第三第四步需要將 OIDC 插件自行定義解析器,在舊版 Backstage 會以一個檔案的方式去擴充或定義一些插件內容,再將該檔案加入到 packages/backend/src/index.ts
中啟用。以下針對舊版的解析器習慣上會將檔案放置到backend/src/plugins
目錄下。我們命名檔案名稱為 auth.ts
,程式碼當中依賴 Backstage 相關 api 功能,最後在驗證後的解析這邊,我們建立一個 userRef
實體並為其產生 issueToken
,實際上這個實體並不會永久存在 Backstage 之中,因為是為每個登入者產生臨時實體,而非將已存在的實體對應上。不過在開發測試時,可以先以成功登入為目標,後續會再提及如何進一步設定。
import {
createRouter,
providers,
defaultAuthProviderFactories,
} from '@backstage/plugin-auth-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import { DEFAULT_NAMESPACE, stringifyEntityRef } from '@backstage/catalog-model';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
logger: env.logger,
config: env.config,
database: env.database,
discovery: env.discovery,
tokenManager: env.tokenManager,
providerFactories: {
...defaultAuthProviderFactories,
'sso-auth-provider': providers.oidc.create({
signIn: {
resolver(info, ctx) {
const userRef = stringifyEntityRef({
kind: 'User',
name: info.result.userinfo.sub,
namespace: DEFAULT_NAMESPACE,
});
return ctx.issueToken({
claims: {
sub: userRef, // The user's own identity
ent: [userRef], // A list of identities that the user claims ownership through
},
});
},
},
}),
},
});
}
要正式啟用功能,首先需要將該檔案加入到 backend/src/index.ts
,然後將引入的 auth
模組添加到 apiRouter
中。相比起來,新版後端的結構透過大量的重構封裝,讓代碼更加整齊、易於維護。例如,先前我們啟用插件只需一行 import
,大幅簡化了流程,而舊版的 Backstage 則顯得更為複雜且充斥著大量重複代碼。
import {
createServiceBuilder,
loadBackendConfig,
getRootLogger,
useHotMemoize,
notFoundHandler,
CacheManager,
DatabaseManager,
HostDiscovery,
UrlReaders,
ServerTokenManager,
} from '@backstage/backend-common';
import { TaskScheduler } from '@backstage/backend-tasks';
import { Config } from '@backstage/config';
import app from './plugins/app';
import catalog from './plugins/catalog';
import scaffolder from './plugins/scaffolder';
import proxy from './plugins/proxy';
import techdocs from './plugins/techdocs';
import search from './plugins/search';
import { PluginEnvironment } from './types';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
import express from 'express';
import auth from './plugins/auth'; // 剛剛新增的自定義解析
function makeCreateEnv(config: Config) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = HostDiscovery.fromConfig(config);
const cacheManager = CacheManager.fromConfig(config);
const databaseManager = DatabaseManager.fromConfig(config, { logger: root });
const tokenManager = ServerTokenManager.noop();
const taskScheduler = TaskScheduler.fromConfig(config, { databaseManager });
const identity = DefaultIdentityClient.create({
discovery,
});
const permissions = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
root.info(`Created UrlReader ${reader}`);
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
const database = databaseManager.forPlugin(plugin);
const cache = cacheManager.forPlugin(plugin);
const scheduler = taskScheduler.forPlugin(plugin);
return {
logger,
database,
cache,
config,
reader,
discovery,
tokenManager,
scheduler,
permissions,
identity,
};
};
}
async function main() {
const config = await loadBackendConfig({
argv: process.argv,
logger: getRootLogger(),
});
const createEnv = makeCreateEnv(config);
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
const searchEnv = useHotMemoize(module, () => createEnv('search'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
const authEnv = useHotMemoize(module, () => createEnv('auth'));
const apiRouter = express();
apiRouter.use(express.json());
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/search', await search(searchEnv));
apiRouter.use('/auth', await auth(authEnv));
// Add backends ABOVE this line; this 404 handler is the catch-all fallback
apiRouter.use(notFoundHandler());
const service = createServiceBuilder(module)
.loadConfig(config)
.addRouter('/api', apiRouter)
.addRouter('', await app(appEnv));
await service.start().catch(err => {
console.log(err);
process.exit(1);
});
}
module.hot?.accept();
main().catch(error => {
console.error('Backend failed to start up', error);
process.exit(1);
});
第五步在app-config.yaml
設定相關的參數,請參考以下範例。其中需注意的點是,由於我們需要使用者輸入帳號密碼登入的形式,在 OIDC 採用了 code 模式,記得設定prompt: login
,到時在 Backstage 按下登入時,才會另外跳出 IdentityServer 的登入視窗,我們才能進行登入驗證。另外必須設定 session.secret
屬性才能完整啟用 OIDC 依賴 session 儲存登入狀態。
auth:
environment: development
session:
secret: secret
providers:
sso-auth-provider:
development:
metadataUrl: https://localhost:5001/.well-known/openid-configuration
clientId: backstage
clientSecret: backstage
prompt: login
signIn:
resolvers:
# one of the following resolvers
- resolver: emailMatchingUserEntityAnnotation
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
最後一樣將登入選項加入到 Backstage 登入選擇頁面,同時並存其他登入選項。
# app/src/App.tsx
import { apis, identityServerSSO } from './apis';
...
components: {
SignInPage: props => (
<SignInPage
{...props}
auto
providers={[
{
id: 'sso-auth-provider',
title: 'SSO OIDC identity server',
message: 'Sign in using SSO',
apiRef: identityServerSSO ,
}]
}
title="Select a sign-in method"
align="center"
/>
),
},
到這一步,我們已經完成了舊版 Backstage 的 OIDC 插件基本設定。關於 IdentityServer 的服務設定,我們暫時跳過,並會在下一章節進行實際操作。
讓我們先來看看 Backstage 的呈現結果 : 透過 OIDC 身份認證協議,我們在 bob 登入後成功後,取得該使用者的相關資訊,透過解析器將得到得使用者名稱 (Bob Smith) 創建一個臨時實體,並頒布憑證給該實體存取 Backstage,圖中為 Bob Smith 的登入結果,實務上還可以包含使用者的 mail、電話、員工編號等等資訊。
在本文中,我們簡要介紹了 OIDC(OpenID Connect)的基本概念,並推薦了兩款身份驗證解決方案。由於這兩款解決方案都遵循 OAuth 擴展的 OIDC 協議,因此在使用邏輯上並無太大差異。考慮到 IdentityServer 預設要求應用客戶端必須使用 HTTPS,我們首先為 Backstage 設定了 SSL 保護,這也是後續跨網域請求整合其他應用的基礎。本篇基於過往的開發案例,簡要說明了相關概念。在下一篇文章中,我們將會把 OIDC 插件遷移至新版系統,並重新演示整個過程。
https://backstage.io/docs/auth/oidc/
https://medium.com/@jincoco/backstage-io-oidc-authentication-with-duende-identityserver-a6c076eb69d0
https://learn.microsoft.com/zh-tw/aspnet/core/blazor/security/webassembly/hosted-with-identity-server?view=aspnetcore-7.0&tabs=visual-studio
https://medium.com/@jincoco/worknote-身分驗證工具-持續更新2023-1-12-13b38024ea6c
https://leastprivilege.com/2020/10/01/the-future-of-identityserver
https://darutk.medium.com/diagrams-of-all-the-openid-connect-flows-6968e3990660
https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/enable-ssl-self-signed.md
https://github.com/FiloSottile/mkcert
https://github.com/manfredsteyer/angular-oauth2-oidc/issues/1241