diff --git a/packages/acl/src/__tests__/skip.test.ts b/packages/acl/src/__tests__/skip.test.ts new file mode 100644 index 000000000..154b1dfbc --- /dev/null +++ b/packages/acl/src/__tests__/skip.test.ts @@ -0,0 +1,93 @@ +import { ACL } from '..'; + +describe('skip', () => { + let acl: ACL; + + beforeEach(() => { + acl = new ACL(); + }); + + it('should skip action', async () => { + const middlewareFunc = acl.middleware(); + const ctx: any = { + state: {}, + action: { + resourceName: 'users', + actionName: 'login', + }, + app: { + acl, + }, + throw() {}, + }; + + const nextFunc = jest.fn(); + + await middlewareFunc(ctx, nextFunc); + expect(nextFunc).toHaveBeenCalledTimes(0); + + acl.skip('users', 'login'); + + await middlewareFunc(ctx, nextFunc); + expect(nextFunc).toHaveBeenCalledTimes(1); + }); + + it('should skip action with condition', async () => { + const middlewareFunc = acl.middleware(); + const ctx: any = { + state: {}, + action: { + resourceName: 'users', + actionName: 'login', + }, + app: { + acl, + }, + throw() {}, + }; + + const nextFunc = jest.fn(); + + let skip = false; + acl.skip('users', 'login', (ctx) => { + return skip; + }); + + await middlewareFunc(ctx, nextFunc); + expect(nextFunc).toHaveBeenCalledTimes(0); + + skip = true; + await middlewareFunc(ctx, nextFunc); + expect(nextFunc).toHaveBeenCalledTimes(1); + }); + + it('should skip action with registered condition', async () => { + const middlewareFunc = acl.middleware(); + + const conditionFn = jest.fn(); + acl.skipManager.registerSkipCondition('superUser', () => { + conditionFn(); + return true; + }); + + const ctx: any = { + state: {}, + action: { + resourceName: 'users', + actionName: 'login', + }, + app: { + acl, + }, + throw() {}, + }; + + const nextFunc = jest.fn(); + + acl.skip('users', 'login', 'superUser'); + + await middlewareFunc(ctx, nextFunc); + expect(nextFunc).toHaveBeenCalledTimes(1); + expect(conditionFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/acl/src/acl.ts b/packages/acl/src/acl.ts index 66d36b22e..9369146a9 100644 --- a/packages/acl/src/acl.ts +++ b/packages/acl/src/acl.ts @@ -5,6 +5,7 @@ import lodash from 'lodash'; import { AclAvailableAction, AvailableActionOptions } from './acl-available-action'; import { ACLAvailableStrategy, AvailableStrategyOptions, predicate } from './acl-available-strategy'; import { ACLRole, RoleActionParams } from './acl-role'; +import { SkipManager } from './skip-manager'; const parse = require('json-templates'); interface CanResult { @@ -46,6 +47,8 @@ export class ACL extends EventEmitter { protected availableStrategy = new Map(); protected middlewares = []; + public skipManager = new SkipManager(this); + roles = new Map(); actionAlias = new Map(); @@ -81,6 +84,8 @@ export class ACL extends EventEmitter { } } }); + + this.middlewares.push(this.skipManager.aclMiddleware()); } define(options: DefineOptions): ACLRole { @@ -207,6 +212,10 @@ export class ACL extends EventEmitter { this.middlewares.push(fn); } + skip(resourceName: string, actionName: string, condition?: any) { + this.skipManager.skip(resourceName, actionName, condition); + } + middleware() { const acl = this; diff --git a/packages/acl/src/skip-manager.ts b/packages/acl/src/skip-manager.ts new file mode 100644 index 000000000..cf6fccca6 --- /dev/null +++ b/packages/acl/src/skip-manager.ts @@ -0,0 +1,76 @@ +import { ACL } from './acl'; + +type ConditionFunc = (ctx: any) => boolean; + +export class SkipManager { + protected skipActions = new Map>(); + + protected registeredCondition = new Map(); + + constructor(public acl: ACL) { + this.registerSkipCondition('logged-in', (ctx) => { + return ctx.state.currentUser; + }); + } + + skip(resourceName: string, actionName: string, condition?: string | ConditionFunc) { + const actionMap = this.skipActions.get(resourceName) || new Map(); + actionMap.set(actionName, condition || true); + + this.skipActions.set(resourceName, actionMap); + } + + getSkippedConditions(resourceName: string, actionName: string): Array { + const fetchActionSteps: string[] = ['*', resourceName]; + + const results = []; + + for (const fetchActionStep of fetchActionSteps) { + const resource = this.skipActions.get(fetchActionStep); + if (resource) { + const condition = resource.get('*') || resource.get(actionName); + if (condition) { + results.push(typeof condition === 'string' ? this.registeredCondition.get(condition) : condition); + } + } + } + + return results; + } + + registerSkipCondition(name: string, condition: ConditionFunc) { + this.registeredCondition.set(name, condition); + } + + aclMiddleware() { + return async (ctx, next) => { + const { resourceName, actionName } = ctx.action; + const skippedConditions = ctx.app.acl.skipManager.getSkippedConditions(resourceName, actionName); + let skip = false; + + for (const skippedCondition of skippedConditions) { + if (skippedCondition) { + let skipResult = false; + + if (typeof skippedCondition === 'function') { + skipResult = skippedCondition(ctx); + } else if (skippedCondition) { + skipResult = true; + } + + if (skipResult) { + skip = true; + break; + } + } + } + + if (skip) { + ctx.permission = { + skip: true, + }; + } + await next(); + }; + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 5be894645..d6aadecfd 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -53,13 +53,6 @@ for (const plugin of plugins) { api.plugin(require(plugin).default); } -api.acl.use(async (ctx, next) => { - ctx.permission = { - skip: true, - }; - await next(); -}); - if (process.argv.length < 3) { // @ts-ignore process.argv.push('start', '--port', process.env.API_PORT || 12302); diff --git a/packages/plugin-collection-manager/src/plugin.ts b/packages/plugin-collection-manager/src/plugin.ts index 1b278d5de..06b5fc42c 100644 --- a/packages/plugin-collection-manager/src/plugin.ts +++ b/packages/plugin-collection-manager/src/plugin.ts @@ -77,6 +77,8 @@ export class CollectionManagerPlugin extends Plugin { } await next(); }); + + this.app.acl.skip('collections', 'list', 'logged-in'); } async load() { diff --git a/packages/plugin-ui-schema-storage/src/server.ts b/packages/plugin-ui-schema-storage/src/server.ts index 62d181568..16dc223c0 100644 --- a/packages/plugin-ui-schema-storage/src/server.ts +++ b/packages/plugin-ui-schema-storage/src/server.ts @@ -60,6 +60,8 @@ export class UiSchemaStoragePlugin extends Plugin { name: 'uiSchemas', actions: uiSchemaActions, }); + + this.app.acl.skip('uiSchemas', '*', 'logged-in'); } async load() { diff --git a/packages/plugin-users/src/__tests__/role.test.ts b/packages/plugin-users/src/__tests__/role.test.ts index ba3e487e2..1f799bc5f 100644 --- a/packages/plugin-users/src/__tests__/role.test.ts +++ b/packages/plugin-users/src/__tests__/role.test.ts @@ -1,4 +1,4 @@ -import Database from '@nocobase/database'; +import Database, { BelongsToManyRepository } from '@nocobase/database'; import PluginACL from '@nocobase/plugin-acl'; import { MockServer, mockServer } from '@nocobase/test'; @@ -69,4 +69,50 @@ describe('role', () => { expect(roles.length).toEqual(1); expect(roles[0].get('name')).toEqual('test2'); }); + + it('should set users default role', async () => { + await db.getRepository('roles').create({ + values: { + name: 'test1', + title: 'Admin User', + allowConfigure: true, + default: true, + }, + }); + + await db.getRepository('roles').create({ + values: { + name: 'test2', + title: 'test2 user', + allowConfigure: true, + }, + }); + + const user = await db.getRepository('users').create({ + values: { + token: '123', + }, + }); + + const userRolesRepo = db.getRepository('users.roles', user.get('id') as string); + await userRolesRepo.add('test1'); + await userRolesRepo.add('test2'); + + const response = await api + .agent() + .post('/users:setDefaultRole') + .send({ + defaultRole: 'test2', + }) + .set({ + Authorization: `Bearer ${user.get('token')}`, + }); + + expect(response.statusCode).toEqual(200); + + const userRoles = await userRolesRepo.find(); + const defaultRole = userRoles.find((userRole) => userRole.get('rolesUsers').default); + + expect(defaultRole['name']).toEqual('test2'); + }); }); diff --git a/packages/plugin-users/src/actions/users.ts b/packages/plugin-users/src/actions/users.ts index 8b077e1c1..1805b01d5 100644 --- a/packages/plugin-users/src/actions/users.ts +++ b/packages/plugin-users/src/actions/users.ts @@ -148,3 +148,34 @@ export async function changePassword(ctx: Context, next: Next) { ctx.body = ctx.state.currentUser.toJSON(); await next(); } + +export async function setDefaultRole(ctx: Context, next: Next) { + const { + values: { defaultRole }, + } = ctx.action.params; + + const currentUserId = ctx.state.currentUser.id; + + await ctx.db.getRepository('rolesUsers').update({ + filter: { + userId: currentUserId, + }, + values: { + default: false, + }, + }); + + await ctx.db.getRepository('rolesUsers').update({ + filter: { + userId: currentUserId, + roleName: defaultRole, + }, + values: { + default: true, + }, + }); + + ctx.body = 'ok'; + + await next(); +} diff --git a/packages/plugin-users/src/collections/roles-users.ts b/packages/plugin-users/src/collections/roles-users.ts new file mode 100644 index 000000000..c59a4564c --- /dev/null +++ b/packages/plugin-users/src/collections/roles-users.ts @@ -0,0 +1,6 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'rolesUsers', + fields: [{ type: 'boolean', name: 'default' }], +} as CollectionOptions; diff --git a/packages/plugin-users/src/middlewares/parseToken.ts b/packages/plugin-users/src/middlewares/parseToken.ts index 1528c5df5..97ea0c690 100644 --- a/packages/plugin-users/src/middlewares/parseToken.ts +++ b/packages/plugin-users/src/middlewares/parseToken.ts @@ -6,19 +6,41 @@ import { Context, Next } from '@nocobase/actions'; // 因为是否提供匿名访问资源是应用决定的,不是使用插件就一定不能匿名访问。 export function parseToken(options?: any) { return async function parseToken(ctx: Context, next: Next) { - const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); - const User = ctx.db.getCollection('users'); - const user = await User.repository.findOne({ - filter: { - token, - }, - appends: ['roles'], - }); + const user = await findUserByToken(ctx); if (user) { ctx.state.currentUser = user; + setCurrentRole(ctx, user); } - return next(); }; } + +function setCurrentRole(ctx, user) { + const userRoles = user.get('roles'); + let userRole; + + if (userRoles.length == 1) { + userRole = userRoles[0].get('name'); + } else if (userRoles.length > 1) { + const defaultRole = userRoles.findIndex((role) => role.get('rolesUsers').default); + userRole = defaultRole !== -1 ? userRoles[defaultRole] : userRoles[0]; + } + + if (userRole) { + ctx.state.currentRole = userRole; + } +} + +async function findUserByToken(ctx: Context) { + const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); + const User = ctx.db.getCollection('users'); + const user = await User.repository.findOne({ + filter: { + token, + }, + appends: ['roles'], + }); + + return user; +} diff --git a/packages/plugin-users/src/server.ts b/packages/plugin-users/src/server.ts index c5e6a5796..72b93203b 100644 --- a/packages/plugin-users/src/server.ts +++ b/packages/plugin-users/src/server.ts @@ -59,6 +59,16 @@ export default class UsersPlugin extends Plugin { } this.app.resourcer.use(middlewares.parseToken()); + + const publicActions = ['check', 'signin', 'signup', 'lostpassword', 'resetpassword', 'getUserByResetToken']; + const loggedInActions = ['signout', 'updateProfile', 'changePassword', 'setDefaultRole']; + + publicActions.forEach((action) => this.app.acl.skip('users', action)); + loggedInActions.forEach((action) => this.app.acl.skip('users', action, 'logged-in')); + + this.app.acl.skip('*', '*', (ctx) => { + return ctx.state.currentUser?.id == 1; + }); } async load() { @@ -67,13 +77,22 @@ export default class UsersPlugin extends Plugin { }); } - async install() { + getRootUserInfo() { const { adminNickname = 'Super Admin', adminEmail = 'admin@nocobase.com', adminPassword = 'admin123', } = this.options; + return { + adminNickname, + adminEmail, + adminPassword, + }; + } + async install() { + const { adminNickname, adminPassword, adminEmail } = this.getRootUserInfo(); + const User = this.db.getCollection('users'); await User.repository.create({ values: { diff --git a/packages/server/src/application.ts b/packages/server/src/application.ts index 0ce33811b..f357e0e21 100644 --- a/packages/server/src/application.ts +++ b/packages/server/src/application.ts @@ -102,6 +102,7 @@ export class Application exten }); registerMiddlewares(this, options); + if (options.registerActions !== false) { registerActions(this); }