关于 extension-authorization
为 Caffeine AI 应用提供基于角色的认证与访问控制,通过 mixin 添加标准授权端点。首次登录的认证用户会自动成为管理员,并可按管理员、用户或访客角色保护操作。
主要功能
- 01基于角色的访问控制
- 02mixin 添加授权端点
- 03管理员、用户和访客角色
- 04首次认证用户自动成为管理员
- 05可读取已验证的身份属性
适用场景
- 01限制管理员专属操作
- 02保护用户数据读写
- 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>-- 如果不存在资料,则返回nullsaveCallerUserProfile(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 会公开两类状态——请使用正确的状态:
| 场景 | loginStatus | isAuthenticated |
|---|---|---|
| 页面加载,没有已存储的会话 | "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 中自动初始化。首位登录的已通过身份验证的用户将成为管理员;无需令牌或密钥。