iT邦幫忙

2024 iThome 鐵人賽

DAY 9
0
IT 管理

Backstage : 打造企業內部開發者整合平台系列 第 9

Day 9 : Backstage 內部員工身份認證:從 AD 同步到 SSO 的實施之路 - OIDC 篇

  • 分享至 

  • xImage
  •  

⚠️ 本篇將先以舊版 Backstage 開始介紹,Backstage OIDC 插件目前仍未遷移至新版架構。

簡介

目前在自己的案例中,成功透過遷移插件到新版的 Backstage,截至本篇撰寫時間,官方尚未將該插件完成遷移,詳情請看下方 Backstage Github issue。但本篇將會由舊版 Backstage 的架構來演示串接過程,是為了後續實做遷移插件到新版架構時,讀者能更深刻的感受到變化,也更能藉此理解 Backstage 的架構,畢竟在新版架構中,一切插件都包裝的非常完整,我們只需 import 一行程式,無意間忽略了很多細節,所以接下來的過程將以概念為重,省略掉一些不必要的舊版架構過程。

身份驗證的選擇

要實現 SSO 單點登入的身份驗證,市面上有許多現成的解決方案可供選擇。與其從頭開始自行開發驗證架構,採用這些經過考驗的方案不僅能節省時間,還能確保安全性,因為它們已經通過了長期使用的檢驗。

其中比較常見的解決方案之一是 Keycloak。這是一款以 Java 開發的身份驗證和授權管理系統,由 Red Hat 紅帽公司旗下的 JBoss 開發。Keycloak 擁有悠久的發展歷史,提供一鍵開箱即用的使用體驗,幾乎所有的設定都能在介面上完成,這使得它成為許多開源應用的首選 SSO 解決方案。
https://ithelp.ithome.com.tw/upload/images/20240918/201282328a71vlgVpp.png
由於公司中大部分系統架構都是基於 .NET,為了後續更好維護並充分利用人才與資源,我們選擇使用基於 C# 的 IdentityServer (現為 Duende IdentityServer) 作為身份驗證解決方案。IdentityServer 在微軟官方文檔中被作為保護 .NET Web 應用的示範工具,而以代碼為基底的建構模式,不僅提供靈活的自定義設定,也能無縫整合多種 .NET 技術。由於公司目前的身份驗證架構相對複雜,我們需要一個可高度自定義的解決方案,最後透過 IdentityServer 將 OIDC 驗證流程與公司的 OTP(一次性密碼)登入模式整合。相較之下,像 Keycloak 這類強調即插即用的解決方案則不太適合我們的需求。

選擇合適的 SSO 方案取決於專案的需求情境。對於需要高度客製化的應用,IdentityServer 提供了強大的擴展性,能夠滿足複雜的身份驗證要求。若專案的重點在於快速部署且希望依賴於穩定的開源社群支援,Keycloak 可能會是一個更好的選擇。

關於 IdentityServer 的歷史

IdentityServer 的歷史可以追溯到 2015 年或更早。最初作為一個開源項目,IdentityServer 經過多年的優化與迭代,成為網絡上以高安全性著稱的解決方案。其最新版本由 Duende Software 公司負責營利維護,稱為 Duende IdentityServer。舊版本 IdentityServer4 (.NET Core 3.1) 已於 2022 年 12 月 13 日停止更新。目前,Duende IdentityServer 使用 .NET Core 6,完全符合協議標準,並已獲得 OpenID 基金會的正式認證。

OIDC 是什麼?

OIDC (OpenID Connect),是一種基於 OAuth 2.0 的擴展身份驗證協議。在 OAuth 2.0 的基礎上增加了一層身份驗證功能,在驗證使用者的身份同時獲取用戶的基本資料,比如用來映射 Backstage 實體電子郵件地址。而OAuth 2.0 本身僅是一種授權協議,主要用於允許第三方應用程序安全地訪問用戶的資源,不涉及用戶身份的驗證。
https://ithelp.ithome.com.tw/upload/images/20240918/20128232Nz7EdVxtLm.png
OIDC code 模式流程圖 / 圖片來源 - https://darutk.medium.com/diagrams-of-all-the-openid-connect-flows-6968e3990660

簡單來說,在 OAuth 2.0 中,客戶端首先向伺服器請求授權碼,當用戶通過身份驗證後,伺服器會返回一個授權碼。客戶端再使用這個授權碼向伺服器請求訪問令牌,用於存取受保護的資源。如果應用加入了 OIDC,那麼伺服器還會返回一個身份令牌(ID Token),這個令牌包含了用戶的基本資料,讓應用能夠驗證用戶身份。

註: 本篇只對 OAuth 2.0 與 OIDC 概念做簡單提要,詳細運作流程請參考最下方連結。

為本機測試啟用 SSL

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

https://ithelp.ithome.com.tw/upload/images/20240918/20128232GEoku9dG5i.png
成功訊息

接著到 app-config.yaml 中為 app backend 新增以下的設定,將剛剛創建的憑證路徑引入,並記得修改 Url 成 https ,包含 cors 的部分也要記得。

  https:
    certificate:
      cert:
        $file: ./cert/localhost.pem
      key:
        $file: ./cert/localhost-key.pem

https://ithelp.ithome.com.tw/upload/images/20240918/20128232qTZNhgwV3L.png

在修改完畢後啟動時,你可能會遇到類似以下的錯誤,大致上是在說明驗證失敗等問題。這時我們可以透過在終端設定指令export NODE_TLS_REJECT_UNAUTHORIZED=0 (macOS環境),讓 Node.js 忽略 SSL的證書驗證 ,本地開發時可以透過這個方式避免很多憑證相關的問題,但在部署環境下這種繞過安全檢查的方式,可能會讓系統處於風險中,這點在後續正式部署時會再詳細說明。
https://ithelp.ithome.com.tw/upload/images/20240918/20128232HYefGG5X2g.png

這時可以看到我們成功為 Backstage 加入 SSL 憑證,網址也變成 https 了,接著我們就可以進行後續與 IdentityServer 串接 OIDC 協議。
https://ithelp.ithome.com.tw/upload/images/20240918/201282325ZijiRU6Fi.png

Backstage OIDC 插件

官方提供的 OIDC 插件尚未遷移至新版系統,以下程式碼展示了舊版 Backstage 架構下的安裝步驟。讀者可以參考這些步驟了解舊版 Backstage 的運作邏輯,後面我們將以新版系統作為示範。

以下是 Backstage 文件中提到的五個步驟,我將展示先前已成功實做的程式碼進行說明。

  1. Create an API reference to identify the provider.
    建立 API 參考來識別提供者。
  2. Create the API factory that will handle the authentication.
    建立將處理身份驗證的 API 工廠。
  3. Add or reuse an auth provider so you can authenticate.
    新增或重複使用身份驗證提供者以便您可以進行身份驗證。
  4. Add or reuse a resolver to handle the result from the authentication.
    新增或重複使用解析器來處理身份驗證的結果。
  5. Configure the provider to access your 3rd party auth solution.
    配置提供者以存取您的第 3 方身份驗證解決方案。
  6. Add the provider to sign in page so users can login with it.
    將提供者新增至登入頁面,以便使用者可以使用它登入。

首先將第一第二步一起實做在app/src/api.ts,provider ID 可以自行定義作為唯一識別名稱,我命名為「sso-auth-provider」它將會被 createApiFactory 所引用,並在 app-config.yaml中設定為認證提供者。

💡 在定義 Scopes 記得加入 offline_access,讓 Backstage 可以跟 IdentityServer 做 refresh token,否則每次在重新整理頁面時就會被登出。

https://ithelp.ithome.com.tw/upload/images/20240918/20128232O7mJ9Rwq7V.png
https://ithelp.ithome.com.tw/upload/images/20240918/20128232XS8dm6n3XN.png

第三第四步需要將 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、電話、員工編號等等資訊。
https://ithelp.ithome.com.tw/upload/images/20240918/2012823220ppUNOEDs.pnghttps://ithelp.ithome.com.tw/upload/images/20240918/20128232iXlE1hCwIX.png

結論

在本文中,我們簡要介紹了 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


上一篇
Day 8 : Backstage 內部員工身份認證:從 AD 同步到 SSO 的實施之路 - AD 篇
下一篇
Day 10 : Backstage 內部員工身份認證:從 AD 同步到 SSO 的實施之路 - 遷移插件篇
系列文
Backstage : 打造企業內部開發者整合平台30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言