extension-authorization

extension-authorization

授權系統,具備基於角色的存取控制。所有管理個人或受限制資料的應用程式必備。

0星標
0分支
更新於 2026/7/13
SKILL.md
readonlyread-only
name
extension-authorization
description

授權系統,具備基於角色的存取控制。所有管理個人或受限制資料的應用程式必備。

version
1.0.0

授權

授權擴充套件,用於 Caffeine AI

概述

此技能透過 mixin 模式新增一個具備角色存取控制的驗證與授權系統。MixinAuthorization mixin 會自動提供標準的授權端點。

後端

具備角色存取控制的驗證系統。

有一個預先建置好的函式庫 mo:caffeineai-authorization/access-control.mo,提供核心驗證與角色存取控制。

模組 API

module {
  public type UserRole = {
    #admin;
    #user;
    #guest;
  };

  public type AccessControlState = { /* 內部狀態 */ };

  public func initState() : AccessControlState;
  public func getUserRole(state : AccessControlState, caller : Principal) : UserRole;
  public func assignRole(state : AccessControlState, caller : Principal, user : Principal, role : UserRole);
  public func isAdmin(state : AccessControlState, caller : Principal) : Bool;
  public func hasPermission(state : AccessControlState, caller : Principal, requiredRole : UserRole) : Bool;
};

初始化由 MixinAuthorization 內部處理——請勿直接呼叫 initialize。第一個登入的已驗證使用者會自動成為管理員;不需要 token 或密鑰。

重要:include MixinAuthorization(accessControlState) 這行必須放在 main.mo 中,而不是自訂的 mixin 檔案。

選擇退出 / 自訂驗證

如果使用者要求用自訂驗證(例如使用者名稱/密碼驗證、不使用 Internet Identity、或不使用 MixinAuthorization)取代此授權系統,請從專案中移除 caffeineai-authorization mops 套件。這是移除 include-authorization lint 要求的唯一支援方式,因為該 lint 規則是由套件提供的。請勿加入抑制註解,或在安裝套件的情況下省略 include MixinAuthorization(accessControlState)

移除套件時,也請移除所有 mo:caffeineai-authorization/* 匯入、accessControlState 初始化、include MixinAuthorization(accessControlState),以及任何屬於此元件的 AccessControl 防護呼叫。並以使用者要求的自訂驗證與授權檢查取代。

main.mo 中的設定

import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import Types "types";
import ProfileMixin "mixins/Profile";

actor {
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState, null);

  let userProfiles = Map.empty<Principal, Types.UserProfile>();

  include ProfileMixin(accessControlState, userProfiles);
};

types.mo 中的型別定義

module {
  public type UserProfile = {
    name : Text;
  };
};

自訂 Mixin 範例 (mixins/Profile.mo)

前端需要 getCallerUserProfilesaveCallerUserProfilegetUserProfile。將 accessControlState 傳遞給您的 mixin,以便檢查權限。

import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import AccessControl "mo:caffeineai-authorization/access-control";
import Types "../types";

mixin (
  accessControlState : AccessControl.AccessControlState,
  userProfiles : Map.Map<Principal, Types.UserProfile>,
) {
  public query ({ caller }) func getCallerUserProfile() : async ?Types.UserProfile {
    if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
      Runtime.trap("未授權");
    };
    userProfiles.get(caller);
  };

  public shared ({ caller }) func saveCallerUserProfile(profile : Types.UserProfile) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
      Runtime.trap("未授權");
    };
    userProfiles.add(caller, profile);
  };

  public query ({ caller }) func getUserProfile(user : Principal) : async ?Types.UserProfile {
    if (caller != user and not AccessControl.isAdmin(accessControlState, caller)) {
      Runtime.trap("未授權:只能檢視自己的個人資料");
    };
    userProfiles.get(user);
  };
};

防護模式

對每個公開函式套用適當的防護:

// 僅管理員:
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
  Runtime.trap("未授權:只有管理員可以執行此操作");
};

// 僅使用者:
if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
  Runtime.trap("未授權:只有使用者可以執行此操作");
};

// 任何使用者(包括訪客):無需檢查

設計指南

  • 匿名主體視為訪客。
  • assignRole 內部已包含僅管理員的防護。
  • 修改資料的已驗證端點使用 shared({ caller })
  • 擷取資料的已驗證端點使用 query({ caller })
  • 必要時處理所有權驗證。
  • 授權失敗時使用 Runtime.trap

電子郵件屬性

MixinAuthorization 可以在登入時擷取使用者已驗證的 Internet Identity 屬性(姓名和電子郵件)。將回呼作為第二個參數傳遞,而非 null;該回呼在每次登入後、屬性套件驗證完成時執行一次。

請勿直接使用 mo:identity-attributes mixin——一律透過 MixinAuthorization。回呼接收呼叫者主體和已驗證的屬性:

{
  name : ?Text;   // 已驗證的顯示名稱(若存在)
  email : ?Text;  // 一律為已驗證的地址——來自 II 的 `verified_email`,絕非未驗證的 `email` 鍵
  sso : ?Text;    // 若身分來自 SSO,則為 SSO 網域,否則為 null
}

欄位名稱為 email,但只會包含 II 的 verified_email 值——從不讀取未驗證的 email 鍵。在回呼中使用 attrs.email(沒有 attrs.verified_email 欄位)。

將它們儲存在您自己的狀態中,並公開一個 getter 來讀取:

import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";

actor {
  let accessControlState = AccessControl.initState();

  let emails = Map.empty<Principal, Text>();

  include MixinAuthorization(
    accessControlState,
    ?(func(caller : Principal, attrs : { name : ?Text; email : ?Text; sso : ?Text }) {
      switch (attrs.email) {
        case (?email) { emails.add(caller, email) };
        case null {};
      };
    }),
  );

  public query ({ caller }) func getCallerEmail() : async ?Text {
    emails.get(caller);
  };
};

屬性驗證所需的 trusted_attribute_signersfrontend_origins 容器環境變數由 Caffeine 平台自動設定——您無需設定。

在前端擷取電子郵件

登入後,像其他已驗證的 actor 方法一樣查詢 getter:

const { data: callerEmail } = useQuery<string | null>({
  queryKey: ['callerEmail'],
  queryFn: () => actor.getCallerEmail(),
  enabled: !!actor && isAuthenticated,
});

前端

具備角色存取控制的驗證系統。

使用者個人資料設定

使用 Internet Identity 時,使用者只有在登入後才會取得主體 ID。匿名主體視為訪客。主體 ID 不易閱讀——當使用者首次以新主體登入時,請詢問他們的名稱。

個人資料的後端 API:

  • getCallerUserProfile(): Promise<UserProfile | null> —— 若無個人資料則回傳 null
  • saveCallerUserProfile(profile: UserProfile): Promise<void> —— 儲存名稱和個人資料
  • getUserProfile(user: Principal): Promise<UserProfile | null> —— 擷取其他使用者的個人資料

規則:

  • 登入時,若使用者已有個人資料,請勿再次詢問名稱
  • 顯示使用者的個人資料名稱,而非主體 ID
  • 確保使用者在看到任何應用程式資料前必須先登入
  • 登出時,清除所有快取的應用程式資料,包括快取的使用者個人資料

防止個人資料設定彈窗閃爍

export function useGetCallerUserProfile() {
  const { actor, isFetching: actorFetching } = useActor();

  const query = useQuery<UserProfile | null>({
    queryKey: ['currentUserProfile'],
    queryFn: async () => {
      if (!actor) throw new Error('Actor 不可用');
      return actor.getCallerUserProfile();
    },
    enabled: !!actor && !actorFetching,
    retry: false,
  });

  return {
    ...query,
    isLoading: actorFetching || query.isLoading,
    isFetched: !!actor && query.isFetched,
  };
}

然後在您的元件中:

const showProfileSetup = isAuthenticated && !profileLoading && isFetched && userProfile === null;

驗證狀態生命週期

useInternetIdentity 鉤子暴露兩種狀態——請使用正確的狀態:

情境 loginStatus isAuthenticated
頁面載入,無儲存的工作階段 "idle" false
頁面載入,還原儲存的工作階段 "initializing" falsetrue
重新載入後還原儲存的工作階段 "idle" true
互動式登入進行中(彈出視窗開啟) "logging-in" false
互動式登入剛完成 "success" true
登入彈出視窗失敗/取消 "loginError" false

重要: isLoginSuccessloginStatus === "success")僅在透過彈出視窗進行互動式登入後為 true。在頁面重新載入還原已儲存身分時不會true。切勿使用 isLoginSuccess 來判斷已驗證與未驗證的 UI——一律使用 isAuthenticated

登入按鈕的關鍵狀態:

  • isInitializing —— AuthClient 正在從 IndexedDB 載入;停用按鈕以防止在客戶端準備好之前點擊。
  • isLoggingIn —— II 彈出視窗已開啟;停用按鈕以防止重複彈出視窗。

登入元件

import { useInternetIdentity } from '@caffeineai/core-infrastructure';
import { useQueryClient } from '@tanstack/react-query';

export default function LoginButton() {
  const { login, clear, isAuthenticated, isInitializing, isLoggingIn } = useInternetIdentity();
  const queryClient = useQueryClient();

  const handleAuth = () => {
    if (isAuthenticated) {
      clear();
      queryClient.clear();
    } else {
      login();
    }
  };

  return (
    <button
      onClick={handleAuth}
      disabled={isInitializing || isLoggingIn}
      className={`px-6 py-2 rounded-full transition-colors font-medium ${
        isAuthenticated
          ? 'bg-gray-200 hover:bg-gray-300 text-gray-800'
          : 'bg-blue-600 hover:bg-blue-700 text-white'
      } disabled:opacity-50`}
    >
      {isInitializing ? '載入中...' : isAuthenticated ? '登出' : '登入'}
    </button>
  );
}

login()clear() 函式是 fire-and-forget(它們不會回傳追蹤完整流程的 Promise)。鉤子的 isLoggingIn / isInitializing 狀態會追蹤非同步生命週期——請將它們包裝在本機的 useState / isPending 邏輯中。

使用 isAuthenticated 來控制已驗證 UI 的顯示(涵蓋新登入和頁面重新載入後的還原工作階段):

{isAuthenticated ? (
  <AuthenticatedApp />
) : (
  <LoginScreen />
)}

比較目前使用者與資料作者

import { useInternetIdentity } from '@caffeineai/core-infrastructure';
import type { Principal } from '@icp-sdk/core/principal';

const { identity } = useInternetIdentity();

const isAuthor = (authorPrincipal: Principal): boolean => {
  if (!identity) return false;
  return authorPrincipal.toString() === identity.getPrincipal().toString();
};

存取控制 UI

對於僅管理員或個人應用程式,當未授權使用者嘗試存取應用程式時,顯示 AccessDeniedScreen 元件。

錯誤處理

在前端優雅地處理來自後端 Debug.trap 呼叫的授權錯誤,並向使用者顯示適當的錯誤訊息。

注意:第一個管理員的初始化由 @caffeineai/core-infrastructure 自動完成。第一個登入的已驗證使用者會成為管理員;不需要 token 或密鑰。