最近在学习spring security,自己也了些小的demo。也看了几个优秀的后台管理的开源项目。今天聊一下若依系统的权限管理的详细流程。
若依使用的也是当前最流行的RBAC
模型。如果不了解RBAC的小伙伴可以去网上查一下,其实很好理解。若依这里大致可以认为是实现了RBAC0
。简单来说,就是用户不直接拥有权限,而是添加角色作为中转,将权限赋予角色。然后再将角色赋予用户。权限可以是菜单权限或者是按钮权限等。
Springboot,SpringSecurity,JWT,Redis,Mybatis
vue,vuex,router
与权限相关的表主要有三张,sys_user
,sys_role
,sys_menu
。次要的还有两张关联表sys_user_role
,sys_role_menu
。下面依次看一下主要的表。
上面就是user表的基本结构,保存了一些用户的基本资料,以及用户状态标志位。没什么特别要说的地方。
上面是role表的基本结构,定义了角色的名称以及角色的标识字符串。还包括了其他模块的一些数据,比如数据权限的标识,这里不做讨论。
这张表要特别说一下。
首先是动态菜单的实现。表里包括了前端生成动态路由router
的数据。
其次就是perms
字段。这个字段将权限管理的粒度细化到了按钮,也就是你可能可以进入某个页面。但是无法使用这个页面里的所有功能。菜单部分的权限是在渲染页面时就确定了,如果你没有某个菜单或目录的所有权限,那你的页面则不会出现这些目录。
我们观察一下点击登录之后,前端一共发送了三个请求。
依次看一下这些请求都做了什么。
前后端分离的系统交互一般都是无状态登录,这里使用的是jwt
实现。登录后续的所有请求都会借助token
进行权限验证。
登录成功后,需要获取一些公用状态,比如用户名称,用户头像信息等。这些状态都被保存在vuex
中管理。其实这里不是很严谨,这里忽略了路由守卫的部分,但是感知不强,会在下面详细说一下。
到这里就进行到首页渲染的最后一步,获取路由信息。
其实图中的过程不是很严谨,但是这样稍微更好理解一些。
这一步的工作主要由前端来完成,登录完成后,会跳转至首页。在首页渲染之前,路由守卫会做一些操作,这一部分我在另一篇文章里有详细描述。戳这里在这些操作里就包括上面的接口请求,以及这里的路由信息的请求。在获取到路由信息后,将信息转化为router
对象,再动态挂载路由。然后在左边栏的页面部分,遍历router
对象生成边栏。
这里会贴一些我认为比较重要的代码进行说明,更多具体的限于篇幅也不搞太多。
这里就直接看配置类了
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/captchaImage").anonymous()
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js"
).permitAll()
.antMatchers("/profile/**").anonymous()
.antMatchers("/common/download**").anonymous()
.antMatchers("/common/download/resource**").anonymous()
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
.antMatchers("/druid/**").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
作者已经给配置类做了一些注解,这里比较重要的是添加了jwt
过滤器,而且是所有的请求都会被这个过滤器拦截,包括"/login", "/captchaImage"
。除此之外,配置类里并没有声明登录接口。那么肯定在某个地方加入SpringSecurity
的过滤链。
其实从Controller层顺藤摸瓜,很快就能看到这个方法。这个方法验证了用户是否合法,并且将用户信息保存进了Redis
public String login(String username, String password, String code, String uuid)
{
// 通过UUID,还原登录前的秘钥
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
// 通过秘钥查询Redis中存储的验证信息
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();
}
// 用户验证
Authentication authentication = null;
try
{
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new CustomException(e.getMessage());
}
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 生成token
return tokenService.createToken(loginUser);
}
就是在下面这个位置调用了authenticationManager
,将用户名密码加入了整个验证链。而且作者也在此做了注释该方法会去调用UserDetailsServiceImpl.loadUserByUsername
,也就是我们自定义的用户验证规则。
// 用户验证
Authentication authentication = null;
try
{
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
在这里会从数据库验证用户是否合法。到这基本上就算完成了完整的验证流程。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user))
{
log.info("登录用户:{} 不存在.", username);
throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
}
else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
{
log.info("登录用户:{} 已被删除.", username);
throw new BaseException("对不起,您的账号:" + username + " 已被删除");
}
else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
{
log.info("登录用户:{} 已被停用.", username);
throw new BaseException("对不起,您的账号:" + username + " 已停用");
}
return createLoginUser(user);
}
在配置类中只定义了一个jwt
过滤器。将这个过滤器添加到了UsernamePasswordAuthenticationFilter
过滤器之前。这部分我感觉没有特别难理解的部分了,主要就是一些业务逻辑。
@Override
protected 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);
}
以上都是个人对项目的理解,如有错误还请指正。如果有其他问题,请在评论区提出讨论。如果这篇文章帮到了你,请点个赞鼓励一下我这个菜鸟。