【项目实践】后台管理系统前后端实践一:权限控制原理

权限模型

先回顾总结下之前做权限设计的经验。权限设计的核心在于怎么合理的给每个人分配权限,其中的核心我理解有三个:用户,资源,策略。用户的主体是人,资源可以是页面/按钮/api等,策略是指定用户应该按照何种规访问哪些资源。权限模型总的来说可以分为5类:

  • ACL:Access Control List,访问控制列表。简单理解为将资源的访问权限记录下,资源能否访问先查表,window的文件系统就是这种模式的应用。
  • DAC:Discretionary Access Control,自主访问控制(ACL的拓展)。简单理解为在 ACL 基础上,拥有权限的用户自主的给其他人赋予资源的访问权限。
  • MAC: Mandatory Access Control,强制访问控制。简单理解为用户和资源都设置了权限限制,用户访问资源需要验证用户身份和资源的访问级别。
  • RBAC:Role-Based Access Control,基于角色的权限访问控制。简单理解为给用户赋予特定的角色,角色上赋予资源的访问权限。RBAC目前是主流的权限控制模型,细分为RBAC0,RBAC1,RBAC2,RBAC3。
  • ABAC:Attribute-Based Access Control,基于属性的访问控制。简单理解为通过策略(访问规则的描述)来限定资源的访问,策略可以用在用户上,也可以用在资源上。
// 阿里云 RAM 策略配置表
{"Version": "1","Statement":[{"Effect": "Allow","Action": ["oss:List*", "oss:Get*"], // 请求的描述"Resource": ["acs:oss:*:*:samplebucket", "acs:oss:*:*:samplebucket/*"], // 资源的描述"Condition": // 约束条件的描述 {"IpAddress": {"acs:SourceIp": "42.160.1.0"}}}]
} 

RBAC 权限模型

RBAC 作为主流的权限控制模型有3个基础组成部分,分别是:用户、角色和权限:

  • 用户:可以是单个用户,也可以是用户组
  • 角色:可以定义为单个角色,也可以将同类型的角色做成角色集,上下级的角色定义为岗位
  • 权限:可以分为两大类:功能权限和数据权限。功能权限是指菜单(页面),按钮(api)等这类权限。数据权限是指对数据访问范围的区分,如根据国家区分数据的访问范围。

RBAC 模型分类

RBAC0:最基础 RBAC 模型,。在这个模型中,我们把权限赋予角色,再把角色赋予用户。用户和角色,角色和权限都是多对多的关系。用户拥有的权限等于他所有的角色持有权限之和。

RBAC1:RBAC1建立在RBAC0基础之上,在角色中引入了继承的概念。简单理解就是,给角色可以分成几个等级,每个等级权限不同,从而实现更细粒度的权限管理。角色集和岗位就是RBAC1的应用。

RBAC2:RBAC2同样建立在RBAC0基础之上,对用户、角色和权限三者之间增加了一些限制。这些限制可以分成两类,即静态职责分离SSD(Static Separation of Duty)和动态职责分离DSD(Dynamic Separation of Duty)。

RBAC3:RBAC3 = RBAC1 + RBAC2,所以RBAC3既有角色分层,也包括可以增加各种限制。

前端功能权限实现

对于前端来说,权限控制体现在页面和按钮是否显示。结合 vue-element-admin 看看页面的显示控制是怎么完成的。 在 vue-element-admin 中,页面的显示控制是获取到角色后,找到 route.meta.roles 符合的角色生成 asyncRoutes 通过 router.addRoutes 动态生成 routes。侧边栏 SideBar 组件遍历路由生成该角色的菜单栏导航。 具体实现过程描述如下:

1.创建vue实例的时候将vue-router挂载,但这个时候vue-router挂载一些登录或者不用权限的公用的页面。
2.当用户登录后,获取用户role,将role和路由表每个页面的需要的权限作比较,生成最终用户可访问的路由表。
3.调用router.addRoutes(store.getters.addRouters)添加用户可访问的路由。
4.使用vuex管理路由表,根据vuex中可访问的路由渲染侧边栏组件。

页面功能权限

页面权限功能的总体逻辑在beforeEach,代码如下:

router.beforeEach(async(to, from, next) => {// start progress barNProgress.start()// set page titledocument.title = getPageTitle(to.meta.title)// determine whether the user has logged inconst hasToken = getToken()if (hasToken) {if (to.path === '/login') {// if is logged in, redirect to the home pagenext({ path: '/' })NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939} else {// determine whether the user has obtained his permission roles through getInfoconst hasRoles = store.getters.roles && store.getters.roles.length > 0if (hasRoles) {next()} else {try {// get user info// note: roles must be a object array! such as: ['admin'] or ,['developer','editor']const { roles } = await store.dispatch('user/getInfo')// generate accessible routes map based on rolesconst accessRoutes = await store.dispatch('permission/generateRoutes', roles)// dynamically add accessible routesrouter.addRoutes(accessRoutes)// hack method to ensure that addRoutes is complete// set the replace: true, so the navigation will not leave a history recordnext({ ...to, replace: true })} catch (error) {// remove token and go to login page to re-loginawait store.dispatch('user/resetToken')Message.error(error || 'Has Error')next(`/login?redirect=${to.path}`)NProgress.done()}}}} else {/* has no token*/if (whiteList.indexOf(to.path) !== -1) {// in the free login whitelist, go directlynext()} else {// other pages that do not have permission to access are redirected to the login page.next(`/login?redirect=${to.path}`)NProgress.done()}} 

登陆的时序简易图如下:

细节说明:每个路由可以让哪些角色访问是写死在前端代码中的,不支持动态配置。如果需要支持动态配置,可以在前端通过一个 tree 控件或者其它展现形式给管理员配置角色的路由表,之后将这份路由表存储到后端。当用户登录后得到 roles,前端根据roles 去向后端请求可访问的路由表,从而动态生成可访问页面,之后就是 router.addRoutes 动态挂载到 router 上。

按钮功能权限

通过获取到用户的role之后,在前端用v-if手动判断来区分不同权限对应的按钮的。需要按钮权限主要是为了限制一些修改,新增或者删除操作,对于这种需求后端来进行会更加安全。

<template><!-- Admin can see this --><el-tag v-permission="['admin']">admin</el-tag><!-- Editor can see this --><el-tag v-permission="['editor']">editor</el-tag><!-- Editor can see this --><el-tag v-permission="['admin','editor']">Both admin or editor can see this</el-tag>
</template>

<script>
// 当然你也可以为了方便使用,将它注册到全局
import permission from '@/directive/permission/index.js' // 权限判断指令
export default{directives: { permission }
}
</script> 

v-permission 的指令实现部分:

function checkPermission(el, binding) {const { value } = bindingconst roles = store.getters && store.getters.rolesif (value && value instanceof Array) {if (value.length > 0) {const permissionRoles = valueconst hasPermission = roles.some(role => {return permissionRoles.includes(role)})if (!hasPermission) {el.parentNode && el.parentNode.removeChild(el)}}} else {throw new Error(`need roles! Like v-permission="['admin','editor']"`)}
}

export default {inserted(el, binding) {checkPermission(el, binding)},update(el, binding) {checkPermission(el, binding)}
} 

后端端功能权限实现

登陆认证

对于后端来说,登陆验证主要解决两件事情:首次登陆确认用户名和密码,返回一个登陆凭证 token ;携带 token 时检验 token 是否有效和是否过期。

先看下从登陆页面发出的请求的逻辑。前端登陆接口为 /login ,/register时请求头是不带token的,这点从源码可以看出。

export function login(username, password, code, uuid) {const data = {username,password,code,uuid}return request({url: '/login',headers: {isToken: false},method: 'post',data: data})
}

// 注册方法
export function register(data) {return request({url: '/register',headers: {isToken: false},method: 'post',data: data})
}

// axios 请求拦截器
service.interceptors.request.use(config => {// 是否需要设置 tokenconst isToken = (config.headers || {}).isToken === falseif (getToken() && !isToken) {config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改}
} 

ruoyi-vue 分离版权限控制使用的是 spring security(关于 spring security 的使用可以看这篇文章:link),具体流程如下:

1.验证码校验:根据 uuid 从 redis 取出验证码,和前端传递的比对是否正确

// 验证码开关
if (captchaEnabled){validateCaptcha(username, code, uuid);
}
/**
 * 校验验证码
 * 
 * @param username 用户名
 * @param code 验证码
 * @param uuid 唯一标识
 * @return 结果
 */
public void validateCaptcha(String username, String code, String uuid){String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");String captcha = redisCache.getCacheObject(verifyKey);redisCache.deleteObject(verifyKey);if (captcha == null){AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));throw new CaptchaExpireException();}if (!code.equalsIgnoreCase(captcha)){AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));throw new CaptchaException();}
} 

2.用户名和密码校验:将用户名和密码传递给 spring security,spring security 调用 UserDetailsServiceloadUserByUsername 方法。该方法需要重写,自定义了用户名校验方法和限制同一账号多次重试,同一账号最后返回的类型为 UserDetails。通过后会返回 Authentication 对象,该对象包括三个属性 Principal 用户信息,没有认证时一般是用户名,认证后一般是用户对象;Credentials 用户凭证,一般是密码; Authorities 用户权限。校验失败后会调用 AuthenticationEntryPoint 接口的 commence 方法。

// 用户验证
Authentication authentication = null;
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);

public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{SysUser user = userService.selectUserByUserName(username);if (StringUtils.isNull(user)){log.info("登录用户:{} 不存在.", username);throw new ServiceException("登录用户:" + username + " 不存在");}else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()){log.info("登录用户:{} 已被删除.", username);throw new ServiceException("对不起,您的账号:" + username + " 已被删除");}else if (UserStatus.DISABLE.getCode().equals(user.getStatus())){log.info("登录用户:{} 已被停用.", username);throw new ServiceException("对不起,您的账号:" + username + " 已停用");}// 登录账户密码错误次数校验passwordService.validate(user);return createLoginUser(user);
}
/**
 * 认证失败处理类 返回未授权
 * 
 * @author ruoyi
 */
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable{private static final long serialVersionUID = -8970718410437077606L;@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)throws IOException{int code = HttpStatus.UNAUTHORIZED;String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));}
} 

3.token 生成:从 Authentication 得到用户信息,生成 uuid 作为键,用户信息作为值保存到 redis 中。生成的 uuid 也保存到 token 的 body 中。

LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 生成token
return tokenService.createToken(loginUser);

/**
 * 创建令牌
 *
 * @param loginUser 用户信息
 * @return 令牌
 */
public String createToken(LoginUser loginUser){String token = IdUtils.fastUUID();loginUser.setToken(token);// 刷新 redis 令牌有效期refreshToken(loginUser);Map<String, Object> claims = new HashMap<>();claims.put(Constants.LOGIN_USER_KEY, token);return createToken(claims);
}
/**
 * 刷新令牌有效期
 *
 * @param loginUser 登录信息
 */
public void refreshToken(LoginUser loginUser){loginUser.setLoginTime(System.currentTimeMillis());loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);// 根据uuid将loginUser缓存String userKey = getTokenKey(loginUser.getToken());redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
/**
 * 从数据声明生成令牌
 *
 * @param claims 数据声明
 * @return 令牌
 */
private String createToken(Map<String, Object> claims){String token = Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();return token;
} 

不携带 token 登陆认证时序图如下:

当请求携带 token 时,认证流程又有所不同,具体流程如下:

1.token 解析:解析 token 拿到 uuid,根据 uuid 从 redis 中获得用户信息,如果 redis 中的缓存过期 securityContext 则没办法设置用户信息导致认证失败
2.权限设置:从 redis 取出的用户信息中包含用户的权限数据,会在本次请求中设置到 securityContext

/**
 * token过滤器 验证token有效性
 * 
 * @author ruoyi
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter{@Autowiredprivate TokenService tokenService;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException{LoginUser loginUser = tokenService.getLoginUser(request);if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())){tokenService.verifyToken(loginUser);UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authenticationToken);}chain.doFilter(request, response);}
} 

登陆认证时序图如下:

接口鉴权

接口鉴权部分目前还没有自己敲过,先按照文档权限注解和参考讲义来进行说明。

Spring Security提供了Spring EL表达式,允许我们在定义接口访问的方法上面添加注解,来控制访问权限。因此可以看到在需要被控制的接口上存在注解@PreAuthorize("@ss.hasPermi('system:role:list')"),该注解表示调用该接口是需要权限的,并且权限为“system:role:list”。

@PreAuthorize("@ss.hasPermi('system:role:list')")
@GetMapping("/list")
public TableDataInfo list(SysRole role)
{startPage();List<SysRole> list = roleService.selectRoleList(role);return getDataTable(list);
} 

@PreAuthorize 注解是 SpringSecurity 框架的注解,注解的value值需要填写一个表达式,如果表达式计算的结果是true,那么就允许访问对应的接口,如果表达式计算的结果是false,则表示没有权限。当我们需要用到该注解时,需要先写另一个注解来让其能生效,ry程序在 SecurityConfig 类已经写了,如下:

/**
 * spring security配置
 * 
 * @author ruoyi
 */
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter{// 省略
} 

@EnableGlobalMethodSecurity 注解后面的 prePostEnabled 方法(Determines if Spring Security’s pre post annotations should be enabled. Default is false.)表示 @PreAuthorize@PostAuthorize 注解要不要启用。@PreAuthorize 表示在执行方法前验证权限,@PostAuthorize 表示在执行方法后验证权限。

@PreAuthorize 注解的值为 SpEL 表达式,SpEL 表达式能写方法调用,通过“@”来引用bean。@ss.hasPermi('system:post:list')引用名为 ss 的 Bean,然后调用该 Bean 的 hasPermi 方法,并传入了参数 ‘system:post:list’。Bean ss 定义如下:

/**
 * RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
 * 
 * @author ruoyi
 */
@Service("ss")
public class PermissionService
{/** 所有权限标识 */private static final String ALL_PERMISSION = "*:*:*";/** 管理员角色权限标识 */private static final String SUPER_ADMIN = "admin";private static final String ROLE_DELIMETER = ",";private static final String PERMISSION_DELIMETER = ",";/** * 验证用户是否具备某权限 *  * @param permission 权限字符串 * @return 用户是否具备某权限 */public boolean hasPermi(String permission){if (StringUtils.isEmpty(permission)){return false;}LoginUser loginUser = SecurityUtils.getLoginUser();if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())){return false;}PermissionContextHolder.setContext(permission);return hasPermissions(loginUser.getPermissions(), permission);}/** * 判断是否包含权限 *  * @param permissions 权限列表 * @param permission 权限字符串 * @return 用户是否具备某权限 */private boolean hasPermissions(Set<String> permissions, String permission){return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));}
} 

最后

整理了一套《前端大厂面试宝典》,包含了HTML、CSS、JavaScript、HTTP、TCP协议、浏览器、VUE、React、数据结构和算法,一共201道面试题,并对每个问题作出了回答和解析。

有需要的小伙伴,可以点击文末卡片领取这份文档,无偿分享

部分文档展示:



文章篇幅有限,后面的内容就不一一展示了

有需要的小伙伴,可以点下方卡片免费领取

猜你喜欢

转载自blog.csdn.net/web220507/article/details/129406770