mcpField
E
extension-authorization

extension-authorization

github.com/caffeinelabs安全228.6k 次安装6 天前更新
代码仓库

关于 extension-authorization

为 Caffeine AI 应用提供基于角色的认证与访问控制,通过 mixin 添加标准授权端点。首次登录的认证用户会自动成为管理员,并可按管理员、用户或访客角色保护操作。

主要功能

  1. 01基于角色的访问控制
  2. 02mixin 添加授权端点
  3. 03管理员、用户和访客角色
  4. 04首次认证用户自动成为管理员
  5. 05可读取已验证的身份属性

适用场景

  1. 01限制管理员专属操作
  2. 02保护用户数据读写
  3. 03按角色或 SSO 域控制功能

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/caffeinelabs/skills --skill extension-authorization

安装后在 Claude Code 中输入/extension-authorization确认已加载。

附带文件migration/v0.x.y-to-v1.x.y.md

详细介绍

授权

适用于 Caffeine AI 的授权扩展。

概述

此技能使用 mixin 模式添加了基于角色的访问控制认证与授权系统。MixinAuthorization mixin 会自动提供标准授权端点。

后端

具有基于角色的访问控制的认证系统。

预制库 mo:caffeineai-authorization/access-control.mo 提供基于角色的访问控制核心认证功能。

模块 API

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

  public type AccessControlState = { /* internal state */ };

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

IMPORTANT: 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.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  let userProfiles : Map.Map<Principal, Types.UserProfile>;

  include ProfileMixin(accessControlState, userProfiles);
};

迁移链头部:

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

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

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    userProfiles : Map.Map<Principal, UserProfile>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      userProfiles = Map.empty<Principal, UserProfile>();
    };
  };
};

types.mo 中的类型定义

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

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

前端需要 getCallerUserProfile、saveCallerUserProfile 和 getUserProfile。将 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("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);
  };
};

守卫模式

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

// Admin-only:
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
  Runtime.trap("Unauthorized: Only admins can perform this action");
};

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

// Any user including guests: No check needed

设计指南

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

Email 属性

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

不要直接使用 mo:identity-attributes 混入模块——始终通过 MixinAuthorization 使用。回调会收到调用方主体和经过验证的属性:

{
  name : ?Text;   // verified display name, when present
  email : ?Text;  // always the verified address -- sourced from II's `verified_email`, never the unverified `email` key
  sso : ?Text;    // SSO domain when the identity came from SSO, otherwise null
}

该字段名为 email,但其中始终只保存 II 的 verified_email 值——不会读取未经验证的 email 键。在回调中使用 attrs.email(没有 attrs.verified_email 字段)。

收到哪些属性取决于前端使用的登录方式(请参阅 extension-core-infrastructure 技能中与 extension-authorization 兼容的 login() 选项):

  • login()(普通 Internet Identity):请求 verified_email,并首先显示 Internet Identity 同意步骤。仅当用户的身份关联了 Google 或 Microsoft 账户时,才会提供 email;仅使用通行密钥的身份和 Apple 身份都不会提供该值。sso 为 null。
  • login({ provider: 'google' }):提供用户 Google 账户中经过验证的 name 和 email;sso 为 null。
  • login({ provider: 'microsoft' }):提供用户 Microsoft 账户中经过验证的 name 和 email;sso 为 null。
  • login({ ssoDomain: 'acme.com' }):提供用户公司身份提供商中经过验证的 name 和 email;sso 保存域名(例如 "acme.com")。使用 attrs.sso 将功能限制为公司成员可用,或根据域名自动分配角色。

对于所有登录方式,都应将 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.AccessControlState;

  let emails : Map.Map<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);
  };
};

属性验证所需的 canister 环境变量 trusted_attribute_signers 和 frontend_origins 由 Caffeine 平台自动配置——无需手动设置。

在前端获取电子邮件

登录后,像调用其他经过身份验证的 actor 方法一样查询 getter:

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

前端

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

登录选项

登录通过 @caffeineai/core-infrastructure 中的 useInternetIdentity hook 实现(完整 API 和 UI 模式请参阅 extension-core-infrastructure 技能)。除非用户请求使用 Google、Microsoft 或公司/工作区 SSO 登录,否则默认使用普通的 login()。

规则:

  • 当用户请求使用 Google、Microsoft 或 SSO 登录时,登录界面必须显示用户请求的直接选项(Google 按钮、Microsoft 按钮和/或 SSO 域名输入框),并且保留普通的 login()“使用 Internet Identity 登录”按钮作为备用选项——没有 Google 或 Microsoft 账户或已注册公司域名的用户仍必须能够登录。
  • 对于已有用户的现有应用,绝不能移除 Internet Identity 选项。主体根据用户的 Internet Identity 派生:II 身份关联了 Google 或 Microsoft 的用户通过任一按钮登录时,都会获得相同的主体;但使用基于通行密钥的 II 身份注册的用户,如果使用未关联该身份的 Google 或 Microsoft 账户进行身份验证,则会获得不同的主体——其数据会看起来像丢失了一样。保留这两个按钮,可确保每位现有用户都能沿用原有的登录方式。

用户资料设置

使用 Internet Identity 时,用户只有登录后才会获得 principal id。匿名 principal 会被视为访客。principal id 不便于人类阅读——用户首次使用新的 principal 登录时,请询问其姓名。

资料的后端 API:

  • getCallerUserProfile(): Promise<UserProfile | null> -- 如果不存在资料,则返回 null
  • saveCallerUserProfile(profile: UserProfile): Promise<void> -- 保存姓名和资料数据
  • getUserProfile(user: Principal): Promise<UserProfile | null> -- 获取其他用户的资料

规则:

  • 登录时,如果用户已有资料,不要再次询问姓名
  • 显示用户的资料名称,而不是 principal 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 hook 会公开两类状态——请使用正确的状态:

场景loginStatusisAuthenticated
页面加载,没有已存储的会话"idle"false
页面加载,正在恢复已存储的会话"initializing"false → true
重新加载后已恢复已存储的会话"idle"true
正在进行交互式登录(弹窗已打开)"logging-in"false
交互式登录刚刚完成"success"true
登录弹窗失败 / 已取消"loginError"false
会话已过期(空闲 / TTL / 撤销)"expired"false
在另一个标签页中退出登录"idle"false

重要: isLoginSuccess(loginStatus === "success")只有在通过弹窗进行交互式登录后才为 true。重新加载页面时恢复已存储的身份,不会使其变为 true。切勿使用 isLoginSuccess 来区分已通过身份验证和未通过身份验证的 UI——始终使用 isAuthenticated。会话过期(loginStatus === "expired")并不代表用户主动退出登录。

登录按钮的关键状态:

  • 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)。hook 的 isLoggingIn / isInitializing 状态会追踪异步生命周期——不要用本地 useState / isPending 逻辑包装它们。

当应用需要 Google 或 Microsoft 登录,或公司 / 工作区 SSO 时,请使用同一个 hook 并传入选项:login({ provider: 'google' })、login({ provider: 'microsoft' }) 或 login({ ssoDomain: 'acme.com' })。所有变体都会产生相同的身份和会话行为;完整的登录 UI 模式请参阅 extension-core-infrastructure 技能。

基于 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 中自动初始化。首位登录的已通过身份验证的用户将成为管理员;无需令牌或密钥。

评论

支持 Markdown