前言
绝大多数的系统都是需要登录才能访问的,因为很多信息要根据当前执行人的身份进行获取,并且不同的人可能权限也不一样。
首先,我们要区分下认证和鉴权,认证,就是确认你的身份信息,一般来说你成功登录就是通过认证。鉴权,就是鉴别你是否有权限,比如同样一个操作A可以进行,B就不能进行。
方案
那么在react的前端工程中,如果进行人认证和鉴权呢?我们主要考虑以下几个方案。
1.页面渲染的时候,进行认证校验,如果未认证则跳转到登录页或指定页面。
2.认证通过的标准是什么?web应用很多是无状态的,我们会在客户端通过 cookie或者sessionstorage来记录token,但是token的有效时间与服务端可能不一致,或者存在被T下线等情况。所以token的是否存在并不能作为是否登录的标准。
3.鉴权策略的实现。前端的功能权限控制,主流方案就是通过接口获取所有的权限资源,然后根据是否拥有当前资源的来判断是否拥有权限。那么,当前用户的权限策略请求在什么时间比较好?如果未登录,肯定获取不到权限策略。那么,如何让登录成功的时候触发获取权限策略请求。
4.接口未授权的处理。如果在操作过程中,token已经超时了那么请求就会报未授权异常,这个时候应该怎么处理,需要结合react单页应用的特性。
对于认证功能,我们需要在每个页面进行初始化的时候,进行判断,如果没有身份认证,就跳转到登录页。对于专业的需求,实现思路就是用一个认证组件对页面组件进行包裹,在挂载的时候去判断是否已登录,没有登录就执行未认证操作。对于页面的鉴权也是同样思路,在挂载的时候判断是否有权限。
一般情况下react工程的路由组件都是采用的 react-router-dom v6 ,每个页面的展示都是基于react-router-dom 的控制的。一般情况下,我们通过配置一个树形的结构来让react-router-dom进行渲染。但是官方提供的并没有认证这样的功能,所以,我们需要自己进行封装。在定义路由项的时候,需要增加是否允许匿名,是否有授权策略的配置项。所以,我们基于react-router-dom的结构进行二次封装,然后在处理的时候,再转换成react-router-dom的结构。
具体实现思路
这里主要分如下几个模块
- 认证组件: 进行身份认证
- 鉴权组件:进行权限鉴权
- 路由组件:封装路由
认证组件 Authentication
基于react的Context实现状态的共享,在与服务端交互之后,设置状态值,让各个视图组件感知状态变化驱动事件的触发。
定义context
import {createContext} from "react";
export interface AuthContextProps {
/**
* 认证是否同步
*/
authSynced?: boolean;
/**
* 是否已认证
*/
authenticated?: boolean;
/**
* 当前用户信息
*/
actor?: any;
setActor?: (actor: any) => void;
/**
* 未认证时的回调
*/
onUnAuthenticated?: () => void;
}
export const AuthContext = createContext<AuthContextProps>({});
定义Provider
import React, {FC, useMemo, useState} from "react";
import {AuthContext} from "./context";
type AuthProviderProps = {
children: React.ReactElement;
/**
* 认证请求, 可以根据token去获取当前用户信息,如果没有token可以直接返回null
*/
authRequest?: () => Promise<any>;
onUnAuthenticated?: () => void;
};
export const AuthProvider: FC<AuthProviderProps> = ({children, authRequest, onUnAuthenticated}) => {
const [authSynced, setAuthSynced] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [actor, setActor] = useState<any>();
useMemo(() => {
if (authRequest) {
authRequest()
.then((res) => {
setAuthenticated(!!res);
setActor(res || null);
})
.catch((err) => {
setAuthenticated(false);
setActor(null);
})
.finally(() => {
setAuthSynced(true);
});
} else {
setAuthSynced(true);
setAuthenticated(false);
}
}, [authRequest]);
const handleSetActor = (actor: any) => {
setAuthenticated(!!actor)
setAuthSynced(true)
setActor(actor);
};
return (
<AuthContext.Provider
value={{
authSynced,
authenticated,
actor,
setActor: handleSetActor,
onUnAuthenticated,
}}>
{children}
</AuthContext.Provider>
);
};
hooks
import {AuthContext} from "./context";
import {useContext} from "react";
export const useAuth = (): {
authSynced?: boolean;
authenticated?: boolean;
actor?: any;
setActor?: (actor: any) => void;
onUnAuthenticated?: () => void;
} => {
const {authSynced, authenticated, actor, setActor, onUnAuthenticated} = useContext(AuthContext);
return {authSynced, authenticated, actor, setActor, onUnAuthenticated};
};
Authentication组件
import React, {FC, useEffect} from "react";
import {useAuth} from "./use-auth";
type AuthenticatedProps = {
children?: React.ReactElement;
};
export const Authentication: FC<AuthenticatedProps> = ({children}) => {
const {authSynced, authenticated, onUnAuthenticated} = useAuth();
useEffect(() => {
if (authSynced && !authenticated) {
onUnAuthenticated?.(); //未认证时候触发
}
return () => {
};
}, [authSynced, authenticated]);
return authSynced && authenticated ? <>{children}</> : null;
};
鉴权组件
鉴权模块需要结合服务端的权限体系来实现,这里按照我们的方案,是服务端会返回所有的授权策略,然后根据组件需要的权限去匹配。例如,我们一个服务端返回一个security的权限
{
master:false,
policies:["security"]
}
那么,如果我们某个地方需要 security 这个权限,就是可以满足的。
这里同样是基于context来实现的。
context
import React, {createContext} from "react";
export interface PermissionContextProps {
policySynced?: boolean; //策略是否同步
master?: boolean; //super管理员
setMaster?: (value: boolean) => void;
authenticate?: (policy: string | any[]) => boolean; //鉴权
policies?: any[];
setPolicies?: (policies: any[]) => void;
unauthorized?: React.ReactElement | React.ReactNode; //未授权时的内容
onUnauthorized?: () => void; //未授权时的回调
}
export const PermissionContext = createContext<PermissionContextProps>({});
Provider
import React, {FC, useEffect, useState} from "react";
import _ from "lodash";
import {PermissionContext} from "./context";
import {PolicyResponse} from "./types";
import {useAuth} from "../authentication";
type PermissionProviderProps = {
children?: React.ReactNode;
/**
* 权限请求,返回一个PolicyResponse
* @param params
*/
permissionRequest?: (params?: any) => Promise<PolicyResponse>;
/**
* 权限转换,默认为拼接成字符串,例如参数是["a"],则返回"a",参数为["a","b"],则返回"a::b",当配置customAuthenticate 时,该参数无效
* @param policy
*/
policyTransform?: (policy: any | any[]) => any;
/**
* 自定义鉴权
* @param policy 需要的权限
* @param policies 当前用户拥有的权限策略
*/
customAuthenticate?: (policy: any | any[], policies: any[]) => boolean;
unauthorized?: React.ReactElement | React.ReactNode; //未授权时的内容
onUnauthorized?: () => void; //未授权时的回调
};
export const PermissionProvider: FC<PermissionProviderProps> = ({
children,
permissionRequest,
policyTransform = (policy) => {
return _.concat([], policy).join("::");
},
customAuthenticate,
unauthorized,
onUnauthorized
}) => {
const {authSynced, authenticated} = useAuth();
const [policySynced, setPolicySynced] = useState(false);
const [master, setMaster] = useState(false);
const [policies, setPolicies] = useState<any[]>([]);
/**
* 鉴权,如果有自定义鉴权 customAuthenticate,则使用自定义鉴权,否则使用默认鉴权
* @param policyFilter
*/
const handleAuthenticate = (policyFilter: any | any[]) => {
if (!policySynced) {
return false;
}
if (customAuthenticate) {
return customAuthenticate(policyFilter, policies);
} else {
let policyFiler = policyTransform(policyFilter);
return _.some(policies, (policy: any) => {
return policy === policyFiler;
});
}
};
useEffect(() => {
if (authSynced && authenticated) {
if (permissionRequest) {
permissionRequest?.()
.then((res: PolicyResponse) => {
setMaster?.(res.master || false);
setPolicies?.(res.policies || []);
})
.finally(() => {
setPolicySynced(true);
});
} else {
setPolicySynced(true);
}
}
}, [authSynced, authenticated]);
return (
<PermissionContext.Provider
value={{
policySynced,
master,
setMaster,
authenticate: handleAuthenticate,
policies,
setPolicies,
unauthorized,
onUnauthorized
}}>
{children}
</PermissionContext.Provider>
);
};
hooks
import {useContext} from "react";
import {PermissionContext, PermissionContextProps} from "./context";
export const usePermission = (): PermissionContextProps => {
return useContext(PermissionContext);
};
import {usePermission} from "./use-permission";
export const useAuthenticate = () => {
const {authenticate} = usePermission();
return authenticate;
};
Authorization
import React, {FC} from "react";
import {usePermission} from "./use-permission";
type AuthorizationProps = {
children?: React.ReactNode;
policy?: string | string[]; //鉴权需要的策略
authenticate?: (policies?: any[]) => boolean; //自定义鉴权
unauthorized?: React.ReactElement | React.ReactNode; //未授权时的内容
onUnauthorized?: () => void; //未授权时的回调
};
export const Authorization: FC<AuthorizationProps> = ({
children,
policy = [],
authenticate,
unauthorized,
onUnauthorized
}) => {
const permission = usePermission();
if (!permission.policySynced) {
return null
} else {
let authorized: boolean
if (authenticate) {
authorized = authenticate?.(permission.policies)
} else {
authorized = permission.authenticate?.(policy) || false
}
if (authorized) {
return <>{children}</>
} else {
if (onUnauthorized) {
onUnauthorized()
} else if (permission.onUnauthorized) {
permission.onUnauthorized()
}
return unauthorized ?? permission.unauthorized ?? null
}
}
};
路由组件
虽然认证和鉴权组件是可以单独使用的,但是在业务场景中,一般还是结合路由一起。比如未认证的时候,我们需要跳转到登录页,没有权限的时候我们可能要跳转到某个默认页面。这里,我们是基于react-router-dom进行了封装。
route-object
由于要增加认证,鉴权的配置,这里对原来的RouetObjec进行了扩展
import {DataRouteObject} from "react-router-dom";
import React from "react";
export type RouteItem = {
/**
* 路由名称
*/
label?: string;
id?: string;
/**
* 路由,可以支持根据参数生成路由
* @param params
*/
path?: (...params: any[]) => string;
/**
* 是否允许匿名访问
*/
anonymous?: boolean;
/**
* 权限策略
*/
policy?: string | string[];
/**
* 未授权时的内容
*/
unauthorized?: React.ReactElement | React.ReactNode; //未授权时的内容
/**
* 未授权时的回调
*/
onUnauthorized?: () => void; //未授权时的回调
};
export type TrionesRouteObject = Omit<DataRouteObject, "id" | "path" | "children"> &
RouteItem & {
children?: TrionesRouteObject[];
};
由于我们自己定义的路由对象是不能直接用于 react-router-dom的,这里我们还需要对配置进行转换。
import {TrionesRouteObject} from "./route-object";
import {createBrowserRouter, createHashRouter, RouteObject} from "react-router-dom";
import {Authorization} from "../permission";
import {Authentication} from "../authentication";
export const routesConvert = (routes: TrionesRouteObject[]): RouteObject[] => {
return routes.map((route: any) => {
if (typeof route.path == "function") {
route.path = route.path();
}
if (route.policy) {
if (route.element) {
route.element = <Authorization policy={route.policy} unauthorized={route.unauthorized}
onUnauthorized={route.onUnauthorized}>{route.element}</Authorization>;
}
if (route.Component) {
let Component = route.Component;
route.Component = () => (
<Authorization policy={route.policy} unauthorized={route.unauthorized}
onUnauthorized={route.onUnauthorized}>
<Component/>
</Authorization>
);
}
}
if (!route.anonymous) {
if (route.element) {
route.element = <Authentication>{route.element}</Authentication>;
}
if (route.Component) {
let Component = route.Component;
route.Component = () => (
<Authentication>
<Component/>
</Authentication>
);
}
}
if (route.children) {
route.children = routesConvert(route.children);
}
return route as RouteObject;
});
};
export const trionesCreateBrowserRouter = (routes: TrionesRouteObject[]) => {
return createBrowserRouter(routesConvert(routes));
};
export const trionesCreateHashRouter = (routes: TrionesRouteObject[]) => {
return createHashRouter(routesConvert(routes));
};
为了让用户有更友好的用户体验,我们将对象名转为了react-router-dom中原来的名称。
import {trionesCreateBrowserRouter, trionesCreateHashRouter} from "./hooks";
import {RouteItem, TrionesRouteObject} from "./route-object";
import {RouterProvider, Outlet,Link, useNavigate} from "react-router-dom"
export type {RouteItem, TrionesRouteObject as RouteObject};
export {
trionesCreateBrowserRouter as createBrowserRouter,
trionesCreateHashRouter as createHashRouter,
RouterProvider,
Outlet,
Link,
useNavigate
};
效果测试
我们用3个页面来进行测试
- 登录页,可以匿名访问
- dashboard ,只需要认证即可
- security,需要有 security权限才可以访问
SignInPage
import {useAuth, useNavigate} from "@trionesdev/commons-react";
export const SignInPage = () => {
const navigate = useNavigate()
const {setActor} = useAuth();
return <div>
<h1 style={{
textAlign: "center"
}}>登录页</h1>
<div style={{textAlign: "center"}}>
<button onClick={() => {
setActor?.({"id": "1", "username": "admin"})
navigate("/")
}}>进入
</button>
</div>
</div>
}
DashboardPage
import {Link, useAuth} from "@trionesdev/commons-react";
export const DashboardPage = () => {
const {actor} = useAuth()
return <div>
<h1>Dashboard</h1>
<div>当前用户:{actor.username}</div>
<Link to={"/security"}>Security</Link>
</div>
}
SecurityPage
export const SecurityPage = () => {
return <div>
<div style={{textAlign: "center"}}>
<h1>Security Page</h1>
</div>
</div>
}
路由如下
import {createHashRouter, RouteObject, RouterProvider} from "@trionesdev/commons-react";
import {SignInPage} from "./SignInPage";
import {DashboardPage} from "./DashboardPage";
import {SecurityPage} from "./SecurityPage";
export const routes: RouteObject[] = [
{
path: () => "/sign-in",
element: <SignInPage/>,
anonymous: true
},
{
path: () => "/",
element: <DashboardPage/>,
},
{
path: () => "/security",
element: <SecurityPage/>,
policy: "security",
unauthorized: <div>未授权</div>,
}
]
export const AppRouter = () => {
return <RouterProvider router={createHashRouter(routes)}/>;
};
入口文件
import React from 'react';
import './App.css';
import {AppRouter} from "./router";
import {AuthProvider, PermissionProvider} from "@trionesdev/commons-react";
function App() {
return (
<div>
<AuthProvider authRequest={() => Promise.resolve(false)} onUnAuthenticated={() => {
console.log("onUnAuthenticated");
window.location.href = "/#/sign-in";
}}>
<PermissionProvider permissionRequest={() => Promise.resolve({master: false, policies: []})}>
<AppRouter/>
</PermissionProvider>
</AuthProvider>
</div>
);
}
export default App;
直接请求根目录,就会因为没有登录而跳转到登录页
点击进入按钮,会设置当前用户信息,然后跳转到Dashboard页面
点击Security链接,跳转到Security页面,由于当前用户没有security权限,则显示未授权
修改代码,设置用户有security权限
import React from 'react';
import './App.css';
import {AppRouter} from "./router";
import {AuthProvider, PermissionProvider} from "@trionesdev/commons-react";
function App() {
return (
<div>
<AuthProvider authRequest={() => Promise.resolve(false)} onUnAuthenticated={() => {
console.log("onUnAuthenticated");
window.location.href = "/#/sign-in";
}}>
<PermissionProvider permissionRequest={() => Promise.resolve({master: false, policies: ["security"]})}>
<AppRouter/>
</PermissionProvider>
</AuthProvider>
</div>
);
}
export default App;
再尝试,页面可以展示