前言
在大型项目内, 权限控制也是个老生常谈的问题了, 目前已有的权限系统没有继承的功能, 在多维度管理的时候非常难用而且无法统一管理, 所以我独立设计了一套基于RBAC
的带继承的权限系统, 我的设计理念也非常简单, 权限只需要提供一个最简单的查询接口: 根据子应用(app
)/项目(project
)/用户(user
)返回他所有的权限点集合就行.
具体的权限设计这里不展开详细讲解, 重点核心内容是基础组与继承, 子应用和项目实际用到的角色都是由基础组派生出来的, 所以在新增权限点和修改角色权限的时候都非常简单. 而且能生成一份完整的角色树缓存, 在修改时修改db
后更新缓存树, 在查询的时候的性能也非常高.
这里主要说说前端如何最简单的使用这套权限系统
应用
用户信息
一般的流程是进入系统后发现没有登录, 自动跳转到登录页, 只需要在Root
组件里加上登录状态的检查即可
这里需要注意的是为了减少渲染和防止信息泄露, 需要在登录之后再去加载子应用和菜单组件
登录状态检查不依赖任何数据检查, 直接尝试请求用户信息接口, 如果请求成功则认为用户有效, 并写入全局store
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| const dispatch = useDispatch(); const userInfo = useSelector((state: AppState) => state.globalConfig.userInfo);
const [loginState, setLoginState] = useState<{ loading: boolean; data: AuthUserInfo; error: Error; }>({ loading: false, data: null, error: null, });
useEffect(() => { if (userInfo) { return; } (async () => { setLoginState({ loading: true, data: null, error: null, }); try { const result = await UTILS.get<{ code: number; msg: string; data: AuthUserInfo }>( `${api.uauth.getUserInfo()}?${queryString.stringify( { apps: _.uniq(_.map(CONSTANT.ALL_APPS, (app) => app.authApp)) }, { arrayFormat: 'index' }, )}`, );
if (result.status === 200) { setLoginState({ loading: false, data: result.data.data, error: null, }); dispatch(userInfoDucks.refreshUserInfo(result.data.data)); } else if (result.status !== 200) { throw new Error(result.data.msg); } } catch (e) { console.warn(`Login error:`, e); setLoginState({ loading: false, data: null, error: e, }); goLogin(); } })(); }, [userInfo]);
|
项目列表
在上面的userInfo
接口中, 已经返回了该用户拥有的各子应用有权限的项目, 这里只需要在项目列表渲染时取出对应的项目信息即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const RootAppHeaderNav = () => { const projects = globalConfig.appConfig[globalConfig.app]?.allProjects || [];
return ( <div> {projectSearchShowState && ( <HeaderProjectSelect projects={projects} project={globalConfig.project} onSelect={(project: Project) => { dispatchProjectSearchShowState({ type: 'HIDE' }); changeProjectWithRouter(project); }} onCancel={() => dispatchProjectSearchShowState({ type: 'HIDE' })} /> )} </div> ); };
|
在main
组件渲染时候需要检查是否选择过项目以及项目是否有权限, 否则默认跳转到上次打开的项目或者第一个有权限的项目
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| const RootAppProjectContainer: React.FC<{ routeProps: RouteComponentProps, }> = ({ routeProps }) => { const { globalConfig, userInfo } = useSelector((state: AppState) => ({ globalConfig: state.globalConfig, userInfo: state.userInfo }), _.isEqual);
const projects = globalConfig.appConfig[globalConfig.app].allProjects; return !userInfo ? null : projects.length === 0 ? ( <Redirect to='/user/project-list' /> ) : ( <Switch> <Route exact path={`${routeProps.match.url}`} render={(props) => { const project = getProject(app, projects); return <TransformDefaultProject project={project} url={routeProps.match.url} />; }} /> <Route path={`${routeProps.match.url.replace(/\/$/, '')}/:project`} render={(props) => { const project = getProject(app, projects, props.match.params.project); return globalConfig.project?.code !== project.code ? ( <TransformProject project={project} changeRouter={project.code !== props.match.params.project} /> ) : ( <RouteLazyComponent app={app} isApp project={project} title={title} import={import(/* webpackChunkName: "[request]" */ `./../../../containers/${app}/index.tsx`)} routeProps={props} /> ); }} /> <Route render={() => <Redirect to={routeProps.match.url} />} /> </Switch> ); };
|
项目权限
在切换项目或者切换app
的时候, 都会触发changeProjectApp
的action
, 这时候通过redux-thunk
去获取用户在项目中的权限点, 并且更新到全局store
中.
这里注意在reduce
的时候, 需要判断传入的信息和当前系统状态是否一致, 否则可能发生快速多次切换项目后权限出错的情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| dispatch({ type: 'GLOBAL_CHANGE_USER_PROJECT_PERMISSION_LOADING_STATUS', payload: { project: project, app: app, loading: true }, }); try { const permission = await UTILS.restful.fetch<{ code: number; data: { codes: string[] }; msg: string }>( api.uauth.getUserPermission(CONSTANT.ALL_APPS[app].authApp, project.code), ); if (permission.data?.code !== 200) { throw new Error(permission.data?.msg); } permissionSet = new Set(permission.data?.data?.codes || []); } catch (e) { console.warn(`get [${app}] [${project.code}] user permission error`, e); } finally { dispatch({ type: 'GLOBAL_CHANGE_USER_PROJECT_PERMISSION', payload: { project: project, app: app, permission: permissionSet }, }); dispatch({ type: 'GLOBAL_CHANGE_USER_PROJECT_PERMISSION_LOADING_STATUS', payload: { project: project, app: app, loading: false }, }); }
|
菜单权限
这一步就比较简单了, 直接从全局store
里获取到用户当前权限, 判断菜单配置的权限点, 如果没有配置或者拥有对应权限, 就进行直接渲染
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| const AppLeftMenu = () => { const permission = useSelector((state: AppState) => state.globalConfig.permission); const noEmptyPermission = useMemo(() => permission || new Set(), [permission]);
return ( <div> {menu.children .filter((c) => !c.hide && (!c.permission || noEmptyPermission.has(c.permission))) .map((c) => ( <NavLink exact={!!c.exact || c.path === ''} activeClassName='active' className='menu-item-children-item' to={c.to ? c.to : `${!c.path ? match.url : match.url.replace(/\/$/, '')}${c.path}`} key={c.name} > <span className='menu-item-children-item-icon' /> <span className='menu-item-children-item-text'>{t(c.name)}</span> </NavLink> ))} </div> ); };
const AppMain = () => { const permission = useSelector((state: AppState) => state.globalConfig.permission); return ( <> {!permission ? null : ( <Switch> {menu .filter((c) => !c.permission || permission.has(c.permission)) .map((p) => { const pc = p as SubAppMenuParentHasChildren; const pi = p as SubAppMenuParentItem; return _.size(pc.children) > 0 ? ( pc.children .filter((c) => !c.permission || permission.has(c.permission)) .map((c) => ( <Route exact={!c.path ? true : !!c.exact} key={c.name} path={!c.path ? match.url : `${match.url.replace(/\/$/, '')}${c.path}`} render={(props) => ( <RouteLazyComponent app={app} subApp={subApp} isApp={false} path={c.component_name} title={c.title} project={project} import={import(/* webpackChunkName: "[request]" */ `./../../containers/${app}/containers/${c.component_name}/index.tsx`)} routeProps={props} /> )} /> )) ) : ( <Route exact={!pi.path ? true : !!pi.exact} key={p.name} path={!pi.path ? match.url : `${match.url.replace(/\/$/, '')}${pi.path}`} render={(props) => ( <RouteLazyComponent app={app} subApp={subApp} isApp={false} path={pi.component_name} title={p.title} project={project} import={import(/* webpackChunkName: "[request]" */ `./../../containers/${app}/containers/${pi.component_name}/index.tsx`)} routeProps={props} /> )} /> ); })} {_.size(menu) && <Route render={() => <Redirect to={`${match.url}`} />} />} </Switch> )} </> ); };
|
权限组件
为了减少需要使用权限的组件的开发量, 提供一个通用的权限控制组件, 可以减少代码中的store
的引用
1 2 3 4 5 6 7 8
| const PermissionComponent: React.FC<{ code: string, children: ReactElement, empty?: ReactElement, }> = ({ code, children, empty = null }) => { const permission = useSelector((state: AppState) => state.globalConfig.permission); return permission.has(code) ? children : empty; };
|
小结
外层将权限封装完成后, 对于各子应用来说, 各自的权限使用和限制就非常简单了, 也不用担心出bug
, 不过后端接口的权限限制还是必须得有的, 同样是通过上面的用户项目权限接口验证即可, 对于egg
来说, 使用一个通用的权限中间件就能简单的搞定