extension-authorization

extension-authorization

授权系统,支持基于角色的访问控制。所有管理个人或受限数据的应用必备。

0Star
0Fork
更新于 2026/7/13
SKILL.md
readonly只读
name
extension-authorization
description

Authorization system with role-based access control. Must-have for all apps that manage personal or access-restricted data.

version
1.0.0

授权

授权扩展,适用于 Caffeine AI

概述

此技能通过混入模式添加了基于角色的访问控制的身份验证和授权系统。MixinAuthorization 混入会自动提供标准的授权端点。

后端

基于角色访问控制的身份验证系统。

有一个预制库 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。第一个登录的已认证用户自动成为管理员;无需令牌或密钥。

重要提示:include MixinAuthorization(accessControlState) 行必须放在 main.mo 中,而不是自定义混入文件中。

选择退出 / 自定义身份验证

如果用户要求用自定义身份验证(例如用户名/密码认证,不使用 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;
  };
};

自定义混入示例 (mixins/Profile.mo)

前端需要 getCallerUserProfilesaveCallerUserProfilegetUserProfile。将 accessControlState 传递给您的混入,以便检查权限。

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("Unauthorized");
    };
    userProfiles.get(caller);
  };

  public shared ({ caller }) func saveCallerUserProfile(profile : Types.UserProfile) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
      Runtime.trap("Unauthorized");
    };
    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("Unauthorized: Can only view your own profile");
    };
    userProfiles.get(user);
  };
};

守卫模式

对每个公共函数应用适当的守卫:

// 仅管理员:
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
  Runtime.trap("Unauthorized: Only admins can perform this action");
};

// 仅用户:
if (not AccessControl.hasPermission(accessControlState, caller, #user)) {
  Runtime.trap("Unauthorized: Only users can perform this action");
};

// 任何用户(包括访客):无需检查

设计指南

  • 匿名主体被视为访客。
  • assignRole 内部包含仅管理员的守卫。
  • 对于修改数据的已认证端点,使用 shared({ caller })
  • 对于获取数据的已认证端点,使用 query({ caller })
  • 在需要时处理所有权验证。
  • 对于授权失败,使用 Runtime.trap

电子邮件属性

MixinAuthorization 可以在登录时捕获用户已验证的 Internet Identity 属性(姓名和电子邮件)。传递一个回调作为第二个参数(而不是 null);该回调在每次登录后运行一次,在属性包验证之后。

不要直接使用 mo:identity-attributes 混入——始终通过 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 not available');
      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 ? 'Loading...' : isAuthenticated ? 'Logout' : 'Login'}
    </button>
  );
}

login()clear() 函数是即发即弃的(它们不返回跟踪完整流程的 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 调用的授权错误,在 UI 中向用户显示适当的错误消息。

注意:第一个管理员的初始化在 @caffeineai/core-infrastructure 中自动完成。第一个登录的已认证用户成为管理员;无需令牌或密钥。