动态处理角色和资源的关系
vhr-web:
CustomFilterInvocationSecurityMetadataSource类(FilterInvocationSecurityMetadataSource接口):通过当前的请求地址,获取该地址需要的用户角色,其中getAttributes(Object o)方法获取该地址需要的用户角色(当前登录用户满足其中任意角色即可获取访问权限),该方法返回的集合最终会来到AccessDecisionManager类中,接下来我们再来看AccessDecisionManager类。
CustomUrlDecisionManager(实现AccessDecisionManager接口):该方法接收Authentication对象参数以及以及getAttributes传来的“当前请求需要的角色”参数。
- 如果当前需要的角色为“ROLE_LOGIN”,表示只要登录就能访问,因此只需判断是否登录(通过Authentication是否是AnonymousAuthenticationToken子类即可,因为AnonymousAuthenticationToken表示尚未登录,就会抛出AccessDeniedException运行时异常,前端获取该异常中包含的信息“未登录”,并进行展示)。
- 遍历getAttributes传来的集合(集合内包含表示角色需求的ConfigAttribute对象,包含 角色名 String对象),通过authentication.getAuthorities()(该方法获取的内容由 Hr 类中进行实现的getAuthorities方法进行注入)可以获取当前用户具有的权限集合,遍历权限集合判断是否有某权限和所需角色名字相同。
Hr类(实现UserDetails接口),实现了getAuthorities方法(会在CustomUrlDecisionManager中被调用),将 Hr 对象(当前登录对象)中的角色列表中的所有角色名整合成一个GrantedAuthority类型的集合,进行返回。
HrService类(实现UserDetailsService接口),主要实现该接口的loadUserByUsername方法(创建表示登录用户信息的对象:Hr 对象,并通过查询数据库 为该对象的角色属性赋值),在改方法中会查询数据库hr 表中的username 为当前登录 username 的行(获取该对象),(1)如果获取对象为空,表示“"用户名不存在!"”,会抛出UsernameNotFoundException异常,(2)如果能找到该对象,则再从hr_role 表中利用该用户 id(hr 对象)获取该用户具有的角色信息。
SecurityConfig类(继承WebSecurityConfigurerAdapter)中完成简单的配置:
- 重写configure方法,将实现的 HrService 类与AuthenticationManagerBuilder对象绑定,表示在执行登录时,首先通过HrService类去查找用户并创建用户信息(Hr 类,后续在权限验证中会使用到 Hr 类)。
- configure方法中,通过http.authorizeRequests().withObjectPostProcessor将之前提到的FilterInvocationSecurityMetadataSource和AccessDecisionManager实现类注入进来,保证所有请求都会经过这两个过滤器进行处理(获取当前地址所需权限+验证用户是否具有权限)。
- loginFilter()方法中,loginFilter.setAuthenticationSuccessHandler配置登录成功后返回的信息(“登录成功+用户信息: hr 对象”);loginFilter.setAuthenticationFailureHandler根据登录失败抛出的异常返回相应的错误信息。
密码加密和加盐
为了避免数据库被攻击,需要对数据库中存储的密码进行加密(存储密码的md5摘要),但却存在着攻击者利用 md5摘要反向查询彩虹表获取未加密密码的问题,因此为了增大攻击者反向查询(建立彩虹表)的难度(即使相同明文,生成的加密字符串也是不相同的),应该在数据库中保存“原始密码+盐(一段不保存在数据库中的字符串)”的 md5摘要。
使用SpringSecurity提供的BCryptPasswordEncoder类对用户设定的密码进行加盐加密,且无需在系统内配置盐字符串内容。只需要创建BCryptPasswordEncoder对象,调用其encode 方法(以原密码为参数),即可得到加密后的字符串,将其保存在数据库中即可。
在进行 hr 注册以及 hr 修改密码时,需要使用BCryptPasswordEncoder提供的 encode方法对原密码进行加密,将该方法返回的加盐加密后字符串存储到数据库中。
在 SercurityConfig配置类中,在passwordEncoder方法(返回PasswordEncoder类型对象,BCryptPasswordEncoder implements PasswordEncoder)创建BCryptPasswordEncoder对象,并将该对象进行注入(用于登录时密码验证)。
服务端异常统一处理
在前后端不分离的项目中,如果出了异常直接跳转到错误页面即可。而本项目作为前后端分离的项目,除了异常之后,对异常的处理逻辑应该是向前端返回 JSON。对服务器端发生的异常可以进行统一处理。
只需要编写GlobalExceptionHandler类,在其中创建带有 ExceptionHandler 注释的方法用于处理全局异常,利用 instanceof判断当前接收到的异常是否为某类异常,对该类异常填写处理逻辑即可(生成带有异常描述信息的RespBean对象,并返回)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(SQLException.class)
public RespBean sqlException(SQLException e) {
if (e instanceof SQLIntegrityConstraintViolationException) {
return RespBean.error("该数据有关联数据,操作失败!");
}
return RespBean.error("数据库异常,操作失败!");
}
}
@RestControllerAdvice 注解是一个组合注解,由ControllerAdvice(继承了@Component)和ResponseBody组成,注解了RestControllerAdvice的类中的方法可以被 ExceptionHandler 注解注释,该 ExceptionHandler 注解用于指定异常处理方法,与 RestControllerAdive配合使用时,用于全局处理异常。
文章https://www.cnblogs.com/UncleWang001/p/10949318.html(非常好,不懂RestControllerAdvice就看这里)对 ControllerAdvice、ExceptionHandler、RestControllerAdive 解释的很好呀。本身ControllerAdvice+ ResponseBody+ExceptionHandler就可以进行全局异常统一处理,当全部的全局异常统一处理都返回 json,就可以使用RestControllerAdvice代替ControllerAdvice,这样在方法上也不用添加ResponseBody啦。
登录成功后前端动态加载组件
之前我们曾提到过后端动态处理角色和资源的关系(判断登录用户是否具有获取某种资源的权限),进行这种处理可以在用户在地址栏直接某资源路径时,不具有该资源权限的用户无法对该资源进行访问。但实际上,前端也应该对用户不具有的资源进行过滤,即在导航栏中只展示该用户能够访问的资源列表。
我们很容易能够想到可以在用户登录成功之后(进入 Home 页面之前),先向后端发起 get 请求,获取该用户有权限访问的资源列表(获取当前的菜单信息和组件信息),然后在 对后端返回的 JSON 数据进行解析(对JSON 数据中包含的每一条资源对象,加载相应的组件)。
但这里存在一个问题,如果用户是第一次登录,那么简单的get 菜单资源并加载当然是没有问题的,那么如果用户进入子页面后又返回 HOME 呢,如果在向后端 get 一次就太浪费了
- 因此很自然的会想到将菜单资源保存在 vue 提供的 store(无法被外界读取) (不能将敏感数据保存在 localStore)中。
- 但这里又存在一个问题,如果用户在处于 Home 页面或者任一子页面时,按 F5对页面进行了刷新,那么 就会造成store 中的数据清空了,因此只get一次菜单请求还是不够的,也许我们会想到在每个vue 页面的mounted方法中都发起 get菜单资源请求,但这样又太浪费了。因此,我们需要在进入 HOME 页面时,预先判断 store 中是否有数据,如果没有的话就需要向后端发起 get 菜单资源的请求了。
具体实现其实就是,如果要去的页面不是登录页面,就先从store 中读取当前登录状态(是否登录了),如果没登录就去登录页面(并将本身要去的页面的 path 作为参数传递给登录页面,保证登录成功之后能够跳转到原本访问页面),如果已经登录了,就判断 store 中的routes 数组(自定义的保存菜单路由资源的数组)是否为空,如果为空才需要使用getRequest("/config/sysmenu")获取菜单资源 JSON,并调用formatRoutes将 JSON中的 component 转换为相应组件,调用formatRoutes得到的fmtRoutes就可add 进router中啦(router.addRoutes(fmtRoutes))。
并将fmtRoutes添加进store中,这样只要用户不进行 F5刷新,都不需要再“get 菜单 JSON+JSON 转换为路由对象”了。
邮件收发功能
邮件收发功能引入 RabbitMQ,
聊天功能(模仿微信页面)
HTTP/1.1具有协议升级特性,协议升级特性指的是客户端可以在请求的请求头中包含 Connection 首部字段, 并将该字段值设置为 Upgrade,并在请求头中包含值为websocket 等(协议名)的 Upgrade 首部字段,表示想在后续使用 websocket 协议与服务器建立通信。如果服务器同意协议升级,就会返回101响应吗表示“服务器同意进行协议转换”,这之后两者之间就不再使用 HTTP 协议啦。
如果要实现聊天功能,要在客户端和服务器之间使用 websocket 全双工协议(文本消息和其他二进制消息都可以同时在两个方向上发送,而且客户端和服务器都可以主动向对方发送消息);
websocket 协议连接在80或者443端口,和 HTTP使用相同端口,也就是说,几乎所有的防火墙都不会阻塞 websocket连接。
远程系统性能和状态的实时监控也会用到 socket。
Git版本回退
每一次的提交(commit)都会有一个版本号,我们可以为每次提交同时打上一个 tag,从 github 上克隆下来的项目,可以在本地使用 git tag 命令查看所有的 tag,通过 git show tag1,可以查看 tag1对应的 commit 对应的版本号 x1,然后如果想要回到 x1版本的话,就执行 git reset --hard x1 就行啦。
项目远程部署(Docker、Netty)
1、后端
- 远程服务器配置 Docker(修改docker 配置 文件:/lib/systemd/system/docker.service,开启docker允许远程访问)
- IDEA 下载 Dokcer 插件,在 IDEA 中打包后端项目,并使用远程服务器 Docker部署
2、使用