自定义用户与权限插件路由

页面摘要: 用户与权限功能暴露了 /users 和 /auth 路由,可以使用插件扩展系统进行扩展或覆盖。本指南展示了如何向 User 集合添加自定义策略、覆盖控制器以及添加新路由。

用户与权限功能随附了用于身份验证(/auth)和用户管理(/users)的内置路由。由于这些路由属于插件而非用户创建的内容类型,因此无法使用 createCoreRouter 进行自定义。相反,请通过使用 /src/extensions/users-permissions/ 文件夹中的 strapi-server 文件,通过插件扩展系统来扩展它们。

WARNING

工作原理

用户与权限使用一套与标准内容类型不同的路由数组和控制器对象。在自定义它们之前,理解其结构至关重要。

路由结构

与你创建的内容类型(例如 api::restaurant.restaurant)不同,用户与权限插件在其 plugin.routes['content-api'].routes 数组中注册其路由。该数组包含所有的 /users、/auth、/roles 和 /permissions 路由定义。

每条路由都是一个具有以下形状的对象:

{
  method: 'GET',          // HTTP 方法
  path: '/users',         // URL 路径(相对于 /api)
  handler: 'user.find',   // controller.action
  config: {
    prefix: '',           // 路径前缀(空表示 /api)
  },
}

路由配置也可以包含可选的 policies 和 middlewares 数组(请参阅添加自定义策略)。

strapi-server 扩展文件 {#extend-routes}

对用户与权限插件的所有自定义都放在一个文件中:

JavaScript

module.exports = (plugin) => {
  // Your customizations here

  return plugin;
};

TypeScript

export default (plugin) => {
  // Your customizations here

  return plugin;
};

该函数接收完整的插件对象,并且必须返回该插件。在返回之前,你可以修改 plugin.routes、plugin.controllers、plugin.policies 和 plugin.services。

可用的操作 {#available-actions}

user 控制器是一个暴露以下操作的普通对象:

操作方法路径描述
user.countGET/users/count统计用户数量
user.findGET/users查找所有用户
user.meGET/users/me获取已通过身份验证的用户
user.findOneGET/users/:id查找单个用户
user.createPOST/users创建用户
user.updatePUT/users/:id更新用户
user.destroyDELETE/users/:id删除用户

auth 控制器是一个工厂函数 ({ strapi }) => ({...}),暴露以下操作:

操作方法路径是否限流
auth.callbackPOST/auth/local是
auth.callbackGET/auth/:provider/callback否
auth.registerPOST/auth/local/register是
auth.connectGET/connect/(.*)是
auth.forgotPasswordPOST/auth/forgot-password是
auth.resetPasswordPOST/auth/reset-password是
auth.changePasswordPOST/auth/change-password是
auth.emailConfirmationGET/auth/email-confirmation否
auth.sendEmailConfirmationPOST/auth/send-email-confirmation否
auth.refreshPOST/auth/refresh否
auth.getSessionsGET/auth/sessions否
auth.revokeSessionDELETE/auth/sessions/:sessionId否
auth.logoutPOST/auth/logout否
NOTE

由于 user 和 auth 控制器具有不同的类型(普通对象 vs. 工厂函数),它们需要不同的覆盖模式(请参阅覆盖 user 控制器操作和覆盖 auth 控制器操作)。

自定义路由 {#customize-routes}

你可以通过修改扩展文件中的 plugin.routes['content-api'].routes 数组来添加策略、注册新的端点或删除现有的端点。

添加自定义策略 {#add-custom-policy}

一个常见需求是限制谁可以更新或删除用户账户:例如,确保用户只能更新自己的个人资料。

1. 创建策略文件

创建一个全局策略,用于检查已通过身份验证的用户是否与目标用户匹配。策略函数接收 Koa 上下文(可访问 state.user 和 params)、一个可选的配置对象以及 { strapi }:

JavaScript

"use strict";

module.exports = (policyContext, config, { strapi }) => {
  const currentUser = policyContext.state.user;

  if (!currentUser) {
    return false;
  }

  const targetUserId = Number(policyContext.params.id);

  if (currentUser.id !== targetUserId) {
    return false;
  }

  return true;
};

TypeScript

export default (policyContext, config, { strapi }) => {
  const currentUser = policyContext.state.user;

  if (!currentUser) {
    return false;
  }

  const targetUserId = Number(policyContext.params.id);

  if (currentUser.id !== targetUserId) {
    return false;
  }

  return true;
};
TIP

上面的 is-own-user 策略特别适用于用户与权限插件路由。对于标准内容类型上的类似模式(限制访问条目作者),请参阅 is-owner 中间件示例 和 is-owner-review 策略示例。

2. 将策略附加到用户路由

在插件扩展文件中,找到 update 和 delete 路由并添加该策略:

JavaScript

module.exports = (plugin) => {
  // Find the routes that need the policy
  const routes = plugin.routes['content-api'].routes;

  // Add the 'is-own-user' policy to the update route
  const updateRoute = routes.find(
    (route) => route.handler === 'user.update'
  );

  if (updateRoute) {
    updateRoute.config = updateRoute.config || {};
    updateRoute.config.policies = updateRoute.config.policies || [];
    updateRoute.config.policies.push('global::is-own-user');
  }

  // Add the same policy to the delete route
  const deleteRoute = routes.find(
    (route) => route.handler === 'user.destroy'
  );

  if (deleteRoute) {
    deleteRoute.config = deleteRoute.config || {};
    deleteRoute.config.policies = deleteRoute.config.policies || [];
    deleteRoute.config.policies.push('global::is-own-user');
  }

  return plugin;
};

TypeScript

export default (plugin) => {
  // Find the routes that need the policy
  const routes = plugin.routes['content-api'].routes;

  // Add the 'is-own-user' policy to the update route
  const updateRoute = routes.find(
    (route) => route.handler === 'user.update'
  );

  if (updateRoute) {
    updateRoute.config = updateRoute.config || {};
    updateRoute.config.policies = updateRoute.config.policies || [];
    updateRoute.config.policies.push('global::is-own-user');
  }

  // Add the same policy to the delete route
  const deleteRoute = routes.find(
    (route) => route.handler === 'user.destroy'
  );

  if (deleteRoute) {
    deleteRoute.config = deleteRoute.config || {};
    deleteRoute.config.policies = deleteRoute.config.policies || [];
    deleteRoute.config.policies.push('global::is-own-user');
  }

  return plugin;
};

通过此配置,如果已通过身份验证的用户与 URL 中的 :id 不匹配,PUT /api/users/:id 和 DELETE /api/users/:id 将返回 403 Forbidden 错误。

TIP

要获得更具信息量的错误消息,请抛出 PolicyError 而不是返回 false:

const { errors } = require('@strapi/utils');
const { PolicyError } = errors;

// Inside the policy:
throw new PolicyError('You can only modify your own account');

有关策略模式和错误处理的更多详情,请参阅策略文档。

添加新路由 {#add-new-route}

你可以向用户与权限插件添加自定义路由。例如,按如下方式添加一个用于停用用户账户的端点:

JavaScript

module.exports = (plugin) => {
  // Add a new controller action
  plugin.controllers.user.deactivate = async (ctx) => {
    const { id } = ctx.params;

    const user = await strapi
      .plugin('users-permissions')
      .service('user')
      .edit(id, { blocked: true });

    ctx.body = { message: `User ${user.username} has been deactivated` };
  };

  // Register the route
  plugin.routes['content-api'].routes.push({
    method: 'POST',
    path: '/users/:id/deactivate',
    handler: 'user.deactivate',
    config: {
      prefix: '',
      policies: ['global::is-own-user'],
    },
  });

  return plugin;
};

TypeScript

export default (plugin) => {
  // Add a new controller action
  plugin.controllers.user.deactivate = async (ctx) => {
    const { id } = ctx.params;

    const user = await strapi
      .plugin('users-permissions')
      .service('user')
      .edit(id, { blocked: true });

    ctx.body = { message: `User ${user.username} has been deactivated` };
  };

  // Register the route
  plugin.routes['content-api'].routes.push({
    method: 'POST',
    path: '/users/:id/deactivate',
    handler: 'user.deactivate',
    config: {
      prefix: '',
      policies: ['global::is-own-user'],
    },
  });

  return plugin;
};

重启 Strapi 后,POST /api/users/:id/deactivate 即可使用。在管理面板中,为应当访问此端点的角色,在 用户与权限插件 > Roles 下授予相应的权限。

删除路由 {#remove-route}

你可以通过将路由从路由数组中过滤掉来禁用某条路由。例如,按如下方式禁用用户计数端点:

JavaScript

module.exports = (plugin) => {
  plugin.routes['content-api'].routes = plugin.routes['content-api'].routes.filter(
    (route) => route.handler !== 'user.count'
  );

  return plugin;
};

TypeScript

export default (plugin) => {
  plugin.routes['content-api'].routes = plugin.routes['content-api'].routes.filter(
    (route) => route.handler !== 'user.count'
  );

  return plugin;
};

覆盖控制器 {#override-controllers}

除了路由级别的自定义之外,你还可以覆盖控制器操作本身,以改变插件处理请求的方式。user 和 auth 控制器使用不同的模式,因此各自需要特定的方法。

覆盖 user 控制器操作 {#override-controller}

user 控制器是一个普通对象,因此你可以在扩展文件中直接读取并替换其方法。例如,要向 me 端点添加自定义逻辑:

JavaScript

module.exports = (plugin) => {
  const originalMe = plugin.controllers.user.me;

  plugin.controllers.user.me = async (ctx) => {
    // Call the original controller
    await originalMe(ctx);

    // Add extra data to the response
    if (ctx.body) {
      ctx.body.timestamp = new Date().toISOString();
    }
  };

  return plugin;
};

TypeScript

export default (plugin) => {
  const originalMe = plugin.controllers.user.me;

  plugin.controllers.user.me = async (ctx) => {
    // Call the original controller
    await originalMe(ctx);

    // Add extra data to the response
    if (ctx.body) {
      ctx.body.timestamp = new Date().toISOString();
    }
  };

  return plugin;
};
WARNING

当包装一个控制器时,请始终先调用原始函数以保留默认行为。跳过原始函数意味着你接管了完整的请求处理,包括清理(sanitization)和错误处理。

覆盖 auth 控制器操作 {#override-auth-route}

auth 控制器使用工厂模式:它导出一个函数 ({ strapi }) => ({...}),而不是普通对象。当你的扩展代码运行时,Strapi 尚未解析该工厂。因此,plugin.controllers.auth 是一个函数,而不是带有方法的对象。

要覆盖一个 auth 操作,请包装工厂本身:

JavaScript

module.exports = (plugin) => {
  const originalAuthFactory = plugin.controllers.auth;

  plugin.controllers.auth = ({ strapi }) => {
    // Resolve the original factory to get the controller methods
    const originalAuth = originalAuthFactory({ strapi });

    // Override the register method
    const originalRegister = originalAuth.register;

    originalAuth.register = async (ctx) => {
      // Call the original register logic
      await originalRegister(ctx);

      // Custom post-registration logic
      if (ctx.body && ctx.body.user) {
        strapi.log.info(`New user registered: ${ctx.body.user.email}`);
      }
    };

    return originalAuth;
  };

  return plugin;
};

TypeScript

export default (plugin) => {
  const originalAuthFactory = plugin.controllers.auth;

  plugin.controllers.auth = ({ strapi }) => {
    // Resolve the original factory to get the controller methods
    const originalAuth = originalAuthFactory({ strapi });

    // Override the register method
    const originalRegister = originalAuth.register;

    originalAuth.register = async (ctx) => {
      // Call the original register logic
      await originalRegister(ctx);

      // Custom post-registration logic
      if (ctx.body && ctx.body.user) {
        strapi.log.info(`New user registered: ${ctx.body.user.email}`);
      }
    };

    return originalAuth;
  };

  return plugin;
};
WARNING

不要直接访问 plugin.controllers.auth.register。由于在扩展时 auth 还是一个工厂函数,其方法在 Strapi 调用该工厂之前是不可访问的。请始终像上面所示那样包装工厂。

完整示例 {#combine-customizations}

以下示例在单个文件中组合了多项自定义:它向 update 和 delete 添加策略、包装 me 控制器,并添加一个新 profile 路由。

JavaScript

module.exports = (plugin) => {
  const routes = plugin.routes['content-api'].routes;

  // 1. Add 'is-own-user' policy to update and delete
  for (const route of routes) {
    if (route.handler === 'user.update' || route.handler === 'user.destroy') {
      route.config = route.config || {};
      route.config.policies = route.config.policies || [];
      route.config.policies.push('global::is-own-user');
    }
  }

  // 2. Wrap the 'me' controller to include the user's role
  const originalMe = plugin.controllers.user.me;

  plugin.controllers.user.me = async (ctx) => {
    await originalMe(ctx);

    if (ctx.state.user && ctx.body) {
      const user = await strapi
        .plugin('users-permissions')
        .service('user')
        .fetch(ctx.state.user.id, { populate: ['role'] });

      ctx.body.role = user.role;
    }
  };

  // 3. Add a custom route
  plugin.controllers.user.profile = async (ctx) => {
    const user = await strapi
      .plugin('users-permissions')
      .service('user')
      .fetch(ctx.state.user.id, { populate: ['role'] });

    ctx.body = {
      username: user.username,
      email: user.email,
      role: user.role?.name,
      createdAt: user.createdAt,
    };
  };

  routes.push({
    method: 'GET',
    path: '/users/profile',
    handler: 'user.profile',
    config: { prefix: '' },
  });

  return plugin;
};

TypeScript

export default (plugin) => {
  const routes = plugin.routes['content-api'].routes;

  // 1. Add 'is-own-user' policy to update and delete
  for (const route of routes) {
    if (route.handler === 'user.update' || route.handler === 'user.destroy') {
      route.config = route.config || {};
      route.config.policies = route.config.policies || [];
      route.config.policies.push('global::is-own-user');
    }
  }

  // 2. Wrap the 'me' controller to include the user's role
  const originalMe = plugin.controllers.user.me;

  plugin.controllers.user.me = async (ctx) => {
    await originalMe(ctx);

    if (ctx.state.user && ctx.body) {
      const user = await strapi
        .plugin('users-permissions')
        .service('user')
        .fetch(ctx.state.user.id, { populate: ['role'] });

      ctx.body.role = user.role;
    }
  };

  // 3. Add a custom route
  plugin.controllers.user.profile = async (ctx) => {
    const user = await strapi
      .plugin('users-permissions')
      .service('user')
      .fetch(ctx.state.user.id, { populate: ['role'] });

    ctx.body = {
      username: user.username,
      email: user.email,
      role: user.role?.name,
      createdAt: user.createdAt,
    };
  };

  routes.push({
    method: 'GET',
    path: '/users/profile',
    handler: 'user.profile',
    config: { prefix: '' },
  });

  return plugin;
};

验证

做出更改后,重启 Strapi 并验证你的自定义是否生效:

  1. 运行 yarn strapi routes:list 以确认你的新路由或修改过的路由已出现。
  2. 在未经身份验证的情况下测试受保护的路由,以验证策略返回 403 Forbidden。
  3. 使用已通过身份验证的用户进行测试,以确认预期的行为。
  4. 检查 Strapi 服务器日志,查看启动期间是否有错误。

故障排查

症状可能的原因
找不到路由(404)新路由未被推送到 plugin.routes['content-api'].routes,或者其 prefix 属性缺失。
策略未应用策略名称不正确。全局策略需要 global:: 前缀(例如 global::is-own-user)。
控制器返回 500控制器操作名称与路由定义中的 handler 值不匹配。
更改未生效修改扩展文件后未重启 Strapi。扩展在启动时加载。
权限被拒绝(403)新操作未对该角色启用。在 用户与权限插件 > Roles 中启用它。
无法读取 auth 控制器的属性auth 控制器是一个工厂函数,而不是普通对象。请包装工厂,而不是直接访问方法(请参阅覆盖 auth 控制器操作)。