# vhr_practice **Repository Path**: windsearecher/vhr_practice ## Basic Information - **Project Name**: vhr_practice - **Description**: 仿造开源的微人事管理系统 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 0 - **Created**: 2020-01-06 - **Last Updated**: 2021-02-25 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 2020-1-6 ## 登陆功能完成 > > > Spring Security > > 完成登陆我们需要设计权限,不同的用户角色不同,不同的角色访问权限不同。一个用户对应多个角色,而一个角色对应可以访问多个菜单项。 ![p274](.\images\p274.png) 关于这个表,我说如下几点: 1.hr表是用户表,存放了用户的基本信息。 2.role是角色表,name字段表示角色的英文名称,按照SpringSecurity的规范,将以`ROLE_`开始,nameZh字段表示角色的中文名称。 3.menu表是一个资源表,该表涉及到的字段有点多,由于我的前端采用了Vue来做,因此当用户登录成功之后,系统将根据用户的角色动态加载需要的模块,所有模块的信息将保存在menu表中,menu表中的path、component、iconCls、keepAlive、requireAuth等字段都是Vue-Router中需要的字段,也就是说menu中的数据到时候会以json的形式返回给前端,再由vue动态更新router,menu中还有一个字段url(后端提供的接口),表示一个url pattern,即路径匹配规则,假设有一个路径匹配规则为`/admin/**`,那么当用户在客户端发起一个`/admin/user`的请求,将被`/admin/**`拦截到,系统再去查看这个规则对应的角色是哪些,然后再去查看该用户是否具备相应的角色,进而判断该请求是否合法。 用户认证使用Spring Security,首先我们得有一个UserDetails的实例,在人事管理系统中,登陆操作是Hr登陆,根据前面的Hr表创建Hr实体类。 `` ``` public class Hr implements UserDetails { private Integer id; private String name; private String phone; private String telephone; private String address; private Boolean enabled; private String username; private String password; private String userface; private String remark; private List roles; public List getRoles() { return roles; } public void setRoles(List roles) { this.roles = roles; } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name == null ? null : name.trim(); } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone == null ? null : phone.trim(); } public String getTelephone() { return telephone; } public void setTelephone(String telephone) { this.telephone = telephone == null ? null : telephone.trim(); } public String getAddress() { return address; } public void setAddress(String address) { this.address = address == null ? null : address.trim(); } public void setEnabled(Boolean enabled) { this.enabled = enabled; } public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return enabled; } public void setUsername(String username) { this.username = username == null ? null : username.trim(); } @Override @JsonIgnore public Collection getAuthorities() { List authorities = new ArrayList<>(roles.size()); for (Role role : roles) { authorities.add(new SimpleGrantedAuthority(role.getName())); } return authorities; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password == null ? null : password.trim(); } public String getUserface() { return userface; } public void setUserface(String userface) { this.userface = userface == null ? null : userface.trim(); } public String getRemark() { return remark; } public void setRemark(String remark) { this.remark = remark == null ? null : remark.trim(); } } ``` * 自定义继承UserDetails,前端用户在登陆成功后,需要获取当前用户信息,对于敏感信息,使用@JsonIgnore注解,表示不返回该信息。 * roles属性存储当前用户的所有角色信息,在getAuthorities方法中,并将这些角色转换为List。 接下来提供一个UserDetailsService实例来查询用户 `` ``` @Service public class HrService implements UserDetailsService { @Autowired HrMapper hrMapper; @Autowired HrRoleMapper hrRoleMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Hr hr = hrMapper.loadUserByUsername(username); if (hr == null) { throw new UsernameNotFoundException("用户名不存在!"); } hr.setRoles(hrMapper.getHrRolesById(hr.getId())); return hr; } public List
getAllHrs(String keywords) { return hrMapper.getAllHrs(HrUtils.getCurrentHr().getId(),keywords); } public Integer updateHr(Hr hr) { return hrMapper.updateByPrimaryKeySelective(hr); } @Transactional public boolean updateHrRole(Integer hrid, Integer[] rids) { hrRoleMapper.deleteByHrid(hrid); return hrRoleMapper.addRole(hrid, rids) == rids.length; } public Integer deleteHrById(Integer id) { return hrMapper.deleteByPrimaryKey(id); } public List
getAllHrsExceptCurrentHr() { return hrMapper.getAllHrsExceptCurrentHr(HrUtils.getCurrentHr().getId()); } } ``` 自定义HrService实现UserDetailsService接口,并实现该接口的loadUserByUsernmae方法,用来根据用户名查询用户所有信息,包括用户的角色信息。 ## 自定义FilterInvocationSecurityMetadataSource FilterInvocationSecurityMetadataSource有一个默认的实现类DefaultFilterInvocationSecurityMetadataSource,该类的主要功能就是通过当前的请求地址,获取该地址需要的用户角色,我们照猫画虎,自己也定义一个FilterInvocationSecurityMetadataSource,如下: ``` @Component public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { @Autowired MenuService menuService; AntPathMatcher antPathMatcher = new AntPathMatcher(); @Override public Collection getAttributes(Object object) throws IllegalArgumentException { String requestUrl = ((FilterInvocation) object).getRequestUrl(); List menus = menuService.getAllMenusWithRole(); for (Menu menu : menus) { if (antPathMatcher.match(menu.getUrl(), requestUrl)) { List roles = menu.getRoles(); String[] str = new String[roles.size()]; for (int i = 0; i < roles.size(); i++) { str[i] = roles.get(i).getName(); } return SecurityConfig.createList(str); } } return SecurityConfig.createList("ROLE_LOGIN"); } @Override public Collection getAllConfigAttributes() { return null; } @Override public boolean supports(Class clazz) { return true; } } ``` 关于自定义这个类,我说如下几点: 1.一开始注入了MenuService,MenuService的作用是用来查询数据库中url pattern和role的对应关系,查询结果是一个List集合,集合中是Menu类,Menu类有两个核心属性,一个是url pattern,即匹配规则(比如`/admin/**`),还有一个是List,即这种规则的路径需要哪些角色才能访问。 2.我们可以从getAttributes(Object o)方法的参数o中提取出当前的请求url,然后将这个请求url和数据库中查询出来的所有url pattern一一对照,看符合哪一个url pattern,然后就获取到该url pattern所对应的角色,当然这个角色可能有多个,所以遍历角色,最后利用SecurityConfig.createList方法来创建一个角色集合。 3.第二步的操作中,涉及到一个优先级问题,比如我的地址是`/employee/basic/hello`,这个地址既能被`/employee/**`匹配,也能被`/employee/basic/**`匹配,这就要求我们从数据库查询的时候对数据进行排序,将`/employee/basic/**`类型的url pattern放在集合的前面去比较。 4.如果getAttributes(Object o)方法返回null的话,意味着当前这个请求不需要任何角色就能访问,甚至不需要登录。但是在我的整个业务中,并不存在这样的请求,我这里的要求是,所有未匹配到的路径,都是认证(登录)后可访问,因此我在这里返回一个`ROLE_LOGIN`的角色,这种角色在我的角色数据库中并不存在,因此我将在下一步的角色比对过程中特殊处理这种角色。 5.getAttributes(Object o)方法返回的集合最终会来到AccessDecisionManager类中,接下来我们再来看AccessDecisionManager类。 6.通过MenuService中的getAllMenu方法获取所有的菜单资源进行对比,考虑到每次请求都会调用getAttributes方法,因此将getAllMenu方法的返回值缓存下来,下一次请求直接从缓存中获取。 ## 自定义AccessDecisionManager 自定义UrlAccessDecisionManager类实现AccessDecisionManager接口,如下: ``` @Component public class UrlAccessDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object o, Collection collection) throws AccessDeniedException, AuthenticationException { Iterator iterator = collection.iterator(); while (iterator.hasNext()) { ConfigAttribute ca = iterator.next(); //当前请求需要的权限 String needRole = ca.getAttribute(); if ("ROLE_LOGIN".equals(needRole)) { if (authentication instanceof AnonymousAuthenticationToken) { throw new BadCredentialsException("未登录"); } else return; } //当前用户所具有的权限 Collection authorities = authentication.getAuthorities(); for (GrantedAuthority authority : authorities) { if (authority.getAuthority().equals(needRole)) { return; } } } throw new AccessDeniedException("权限不足!"); } @Override public boolean supports(ConfigAttribute configAttribute) { return true; } @Override public boolean supports(Class aClass) { return true; } } ``` 关于这个类,我说如下几点: 1.decide方法接收三个参数,其中第一个参数中保存了当前登录用户的角色信息,第三个参数则是UrlFilterInvocationSecurityMetadataSource中的getAttributes方法传来的,表示当前请求需要的角色(可能有多个)。 2.如果当前请求需要的权限为`ROLE_LOGIN`则表示登录即可访问,和角色没有关系,此时我需要判断authentication是不是AnonymousAuthenticationToken的一个实例,如果是,则表示当前用户没有登录,没有登录就抛一个BadCredentialsException异常,登录了就直接返回,则这个请求将被成功执行。 3.遍历collection,同时查看当前用户的角色列表中是否具备需要的权限,如果具备就直接返回,否则就抛异常。 4.这里涉及到一个all和any的问题:假设当前用户具备角色A、角色B,当前请求需要角色B、角色C,那么是要当前用户要包含所有请求角色才算授权成功还是只要包含一个就算授权成功?我这里采用了第二种方案,即只要包含一个即可。小伙伴可根据自己的实际情况调整decide方法中的逻辑。 ## 配置WebSecurityConfig 最后在webSecurityConfig中完成简单的配置即可,如下: ``` @Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired HrService hrService; @Autowired UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource; @Autowired UrlAccessDecisionManager urlAccessDecisionManager; @Autowired AuthenticationAccessDeniedHandler authenticationAccessDeniedHandler; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(hrService); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/index.html", "/static/**"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .withObjectPostProcessor(new ObjectPostProcessor() { @Override public O postProcess(O o) { o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource); o.setAccessDecisionManager(urlAccessDecisionManager); return o; } }).and().formLogin().loginPage("/login_p").loginProcessingUrl("/login").usernameParameter("username").passwordParameter("password").permitAll().failureHandler(new AuthenticationFailureHandler() { @Override public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter out = httpServletResponse.getWriter(); StringBuffer sb = new StringBuffer(); sb.append("{\"status\":\"error\",\"msg\":\""); if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) { sb.append("用户名或密码输入错误,登录失败!"); } else if (e instanceof DisabledException) { sb.append("账户被禁用,登录失败,请联系管理员!"); } else { sb.append("登录失败!"); } sb.append("\"}"); out.write(sb.toString()); out.flush(); out.close(); } }).successHandler(new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter out = httpServletResponse.getWriter(); ObjectMapper objectMapper = new ObjectMapper(); String s = "{\"status\":\"success\",\"msg\":" + objectMapper.writeValueAsString(HrUtils.getCurrentHr()) + "}"; out.write(s); out.flush(); out.close(); } }).and().logout().permitAll().and().csrf().disable().exceptionHandling().accessDeniedHandler(authenticationAccessDeniedHandler); } } ``` 关于这个配置,我说如下几点: 1.在configure(HttpSecurity http)方法中,通过withObjectPostProcessor将刚刚创建的UrlFilterInvocationSecurityMetadataSource和UrlAccessDecisionManager注入进来。到时候,请求都会经过刚才的过滤器(除了configure(WebSecurity web)方法忽略的请求)。 2.successHandler中配置登录成功时返回的JSON,登录成功时返回当前用户的信息。 3.failureHandler表示登录失败,登录失败的原因可能有多种,我们根据不同的异常输出不同的错误提示即可。 OK,这些操作都完成之后,我们可以通过POSTMAN或者RESTClient来发起一个登录请求,看到如下结果则表示登录成功: > 我在稍微整整流程,也就是我们登陆成功后,会从数据库中获取这个用户的角色,会做一个多表查询(hr和role,hr_role三个表)。这是登陆操作要做的事情。当我们需要访问其它url,也就是菜单项,我们需要判断当前用户是否拥有权限访问。一般我们可以在Spring Security的配置文件中配置不同的角色能访问的url(也就是后台接口)。这里是把角色能访问的url保存在数据库中。查询到该用户访问的url所需的角色权限,返回一个List集合。然后交给AccessDecisionManager来处理当前用户是否具有请求需要的角色。也就是分两步,一、获取请求url对应的角色,二、然后判断当前用户是否具备请求需要的角色。 # 2020-1-10 ## 前端Home首页菜单项展示 前端的Home首页采用ElementUI的Container布局。但如何根据不同的用户展示不同的菜单项呢? 用户在登录成功之后,进入home主页之前,向服务端发送请求,要求获取当前的菜单信息和组件信息,服务端根据当前用户所具备的角色,以及角色所对应的资源,返回一个json字符串,格式如下: ``` [ { "id": 2, "path": "/home", "component": "Home", "name": "员工资料", "iconCls": "fa fa-user-circle-o", "children": [ { "id": null, "path": "/emp/basic", "component": "EmpBasic", "name": "基本资料", "iconCls": null, "children": [], "meta": { "keepAlive": false, "requireAuth": true } }, { "id": null, "path": "/emp/adv", "component": "EmpAdv", "name": "高级资料", "iconCls": null, "children": [], "meta": { "keepAlive": false, "requireAuth": true } } ], "meta": { "keepAlive": false, "requireAuth": true } } ] ``` 前端拿到这个字符串后,做两件事:1.将json动态添加到当前路由中 2.将数据保存到store中,然后home根据store中数据来渲染菜单。 ## 数据请求时机 这个很重要。 可能会有小伙伴说这有何难,登录成功之后请求不就可以了吗?是的,登录成功之后,请求菜单资源是可以的,请求到之后,我们将之保存在store中,以便下一次使用,但是这样又会有另外一个问题,假如用户登录成功之后,点击某一个子页面,进入到子页面中,然后按了一下F5进行刷新,这个时候就GG了,因为F5刷新之后store中的数据就没了,而我们又只在登录成功的时候请求了一次菜单资源,要解决这个问题,有两种思路:1.将菜单资源不要保存到store中,而是保存到localStorage中,这样即使F5刷新之后数据还在;2.直接在每一个页面的mounted方法中,都去加载一次菜单资源。 由于菜单资源是非常敏感的,因此最好不要不要将其保存到本地,故舍弃方案1,但是方案2的工作量有点大,因此我采取办法将之简化,采取的办法就是使用路由中的导航守卫。 ## 路由导航守卫 开启全局守卫,每次发起请求前都会在前端拦截。 ``` //进行路由过滤和权限拦截,在跳转前执行 router.beforeEach((to, from, next) => { if (to.path == '/') { next(); }else { //登陆后会保存一个user信息在sessionStorage中 if (window.sessionStorage.getItem("user")) { initMenu(router, store); next(); }else{ next('/?redirect='+to.path); } } }) ``` 初始化菜单的操作如下: export const initMenu = (router, store)=> { if (store.state.routes.length > 0) { return; } getRequest("/config/sysmenu").then(resp=> { if (resp && resp.status == 200) { var fmtRoutes = formatRoutes(resp.data); router.addRoutes(fmtRoutes); store.commit('initMenu', fmtRoutes); } }) } export const formatRoutes = (routes)=> { let fmRoutes = []; routes.forEach(router=> { let { path, component, name, meta, iconCls, children } = router; if (children && children instanceof Array) { children = formatRoutes(children); } let fmRouter = { path: path, component(resolve){ if (component.startsWith("Home")) { require(['../components/' + component + '.vue'], resolve) } else if (component.startsWith("Emp")) { require(['../components/emp/' + component + '.vue'], resolve) } else if (component.startsWith("Per")) { require(['../components/personnel/' + component + '.vue'], resolve) } else if (component.startsWith("Sal")) { require(['../components/salary/' + component + '.vue'], resolve) } else if (component.startsWith("Sta")) { require(['../components/statistics/' + component + '.vue'], resolve) } else if (component.startsWith("Sys")) { require(['../components/system/' + component + '.vue'], resolve) } }, name: name, iconCls: iconCls, meta: meta, children: children }; fmRoutes.push(fmRouter); }) return fmRoutes; } 在初始化菜单中,首先判断store中的数据是否存在,如果存在,说明这次跳转是正常的跳转,而不是用户按F5或者直接在地址栏输入某个地址进入的。否则就去加载菜单。拿到菜单之后,首先通过formatRoutes方法将服务器返回的json转为router需要的格式,这里主要是转component,因为服务端返回的component是一个字符串,而router中需要的却是一个组件,因此我们在formatRoutes方法中动态的加载需要的组件即可。数据格式准备成功之后,一方面将数据存到store中,另一方面利用路由中的addRoutes方法将之动态添加到路由中。 ## 动态加载权限侧边栏 我再次查询了一些资料。通过查阅网上的资料,论坛等我总结出了2条方式,分别是前端主导和后台主导。 (1)前端主导 何谓前端主导?就是在整个权限方面,主体是定义在前端。前端需要提前定义一份完整的路由权限表,后台的作用仅仅是返回当前用户的权限列表,把获取到的权限表比对完整的权限表,那么得到一份新的路由权限表拿去渲染。 这里需要注意的是,为什么不直接把后台返回的权限列表拿去渲染,而是需要通过比对,才得出权限表? 因为后台返回的仅仅只是字符串! 我们在使用vue-router定义路由的时候,是需要导入该路由对应的component的,如下所示, component是必须引入的,而后台返回给我们的数据是不会带component对应的组件的。 举个例子: 在前端定义的完整权限表: ``` import Order from './components/orderCompontents/order.vue' import OrderList from './components/orderCompontents/orderList.vue' import ProductManage from './components/orderCompontents/productManage.vue' import ProductionList from './components/orderCompontents/productionList.vue' import ReviewManage from './components/orderCompontents/reviewManage.vue' import ReturnGoods from './components/orderCompontents/returnGoods.vue' const allroutes = [ { path: '/order', title: 'order-manage', component: Order, meta: { name: '订单管理' }, children: [ { path: '/order-list', title: 'order-list', component: OrderList, meta: { name: '订单列表' } }, { path: '/product', title: 'product-manage', component: ProductManage, meta: { name: '生产管理' }, children: [ { path: '/product-list', title: 'product-list', component: ProductionList, meta: { name: '生产列表' } }, { path: '/review-manage', title: 'review-manage', component: ReviewManage, meta: { name: '审核管理' } } ] }, { path: '/return-goods', title: 'return-goods', component: ReturnGoods, meta: { name: '退货管理' } } ] } ] ``` 后台传输过来的数据: ``` { "code": 0, "message": "获取权限成功", "data": [ { "name": "订单管理", "children": [ { "name": "订单列表" }, { "name": "生产管理", "children": [ { "name": "生产列表" } ] }, { "name": "退货管理" } ] } ] } ``` 我们对比这两个数据的name属性,就能很轻易的过滤出一份路由权限表。再通过router.addRoutes()动态添加进路由即可。 (2)后台主导 前面一种方式比较简单,前端定义好,后台传过来进行比对即可,但是缺点也是很明显。如果后台传递的权限名稍稍做一些改动,那么前端就匹配不到相应的路由了。也就是改一个权限名,前端后台需要一起改。。有点不太符合前后端彻底分离的思想。我们想要的是,只改后台,那么前端会根据接收的数据自动变化! 哈哈哈,怎么解决这个问题呢? 那就是用后台主导思想。 思路如下: 路由表不在前端进行比对,后台对用户的权限进行比对,返回给前端一个比对好的路由表,且返回的路由表需要有如下字段: ``` { "data": { "router": [ { "path": "", "redirect": "/home", }, { "path": "/home", "component": "Home", "name": "Home", "meta": { "title": "首页", "icon": "example" }, "children": [ { "path": "/xitong", "name": "xitong", "component": "xitong/xitong", "meta": { "title": "系统", "icon": "table" } } ] }, { "path": "*", "redirect": "/404", "hidden": true } ] } } ``` 注意其中的component字段,他是字符串,我们需要把这个字符串转化为我们前端定义的组件! ``` function filterRouter(routers) { // 遍历后台传来的路由字符串,转换为组件对象 const accessedRouters = routers.filter(route => { if (route.component) { if (route.component === 'Home') { // Home组件特殊处理 route.component = Home } else { route.component = _import(route.component) } } if (route.children && route.children.length) { route.children = filterRouter(route.children) } return true }) return accessedRouters } ``` 这个函数的主要作用就是把后台传过来的字符串型的component转化为真正的组件 其中_import()函数的定义如下: ``` function _import (file) { return () => import('@/components/views/' + file + '.vue') } ``` 通过异步加载组件,去请求该组件 其中的路径需要大家根据自己文件的路径去修改。 这种方法最重要的一点就是,后台传递的component实际存放的是路径!前端根据这个路径去找到这个组件并异步加载组件。 最终执行结束后,filterRouter返回的就是一份路由权限列表,里面的component也有了引用。 这种方法的好处在于,前端的所有权限路由数据都来自于后台,只要路径不改,后台任意修改数据,前端均会自动变化。 这里采用就是后台主导的动态加载路由的方法。 ## 菜单渲染 最后,在Home页中,从store中获取菜单json,渲染成菜单即可,相关代码可以在`Home.vue`中查看,不赘述。 OK,如此之后,不同用户登录成功之后就可以看到不同的菜单了。 ## 思考 大家在调用别人封装好的API时,有没有想过这个问题。为什么需要先获取token,有点像第三方登陆,需要颁发令牌,其实就是一种权限机制。只有有了这枚令牌,我们才有调用这个API 的权限。携带token去访问时后台会对其判断是否过期,若没有过期,判断这枚令牌是否拥有调用该API的权限。这里在用axios请求登陆接口出现一个问题,一直登陆错误。在查询一些资料发现,原来是因为axios发post请求默认Content-Type:application/json;charset=utf-8,而我后端需要'Content-Type': 'application/x-www-form-urlencoded' 我们现在来说说post请求常见的数据格式(content-type) 1. Content-Type: application/json : 请求体中的数据会以json字符串的形式发送到后端 2. Content-Type: application/x-www-form-urlencoded:请求体中的数据会以普通表单形式(键值对)发送到后端 ``` export const postKeyValueRequest = (url, params) => { return axios({ method: 'post', url: `${base}${url}`, data: params, //这里在发起请求前对url进行拼接 transformRequest: [function (data) { let ret = ''; for (let i in data) { ret += encodeURIComponent(i) + '=' + encodeURIComponent(data[i]) + '&' } console.log("ret:"+ret); return ret; }], headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); } ```