本文我们记录总结一些SpringSecurity在微服务中具体的应用场景并不断完善。我们可能需要考虑这些问题:
- 1.RBAC基本权限模型设计
- 2.用户实体设计
- 3.存储与传播机制设计
- 4.角色权限的控制
- 5.SpringSecurity的自定义服务
【1】RBAC基本权限模型设计
这个想对要容易理解一点,通常有如下模型:
sys_permission 权限表
sys_role 角色表
sys_role_permission 角色权限关联表
sys_user 用户表
sys_user_role 用户角色关联表
通过表与表的关联,来为用户赋予角色和权限。在请求访问拦截时,角色是一个粗粒度的控制,权限则是细粒度。
如下我们给出权限表的模型设计:
CREATE TABLE `sys_permission` ( `id` char(19) NOT NULL DEFAULT '' COMMENT '编号', `pid` char(19) NOT NULL DEFAULT '' COMMENT '所属上级', `name` varchar(20) NOT NULL DEFAULT '' COMMENT '名称', `type` tinyint NOT NULL DEFAULT '0' COMMENT '类型(0:模块,1:菜单,2:按钮,3:接口)', `permission_value` varchar(50) DEFAULT NULL COMMENT '权限值', `path` varchar(100) DEFAULT NULL COMMENT '访问路径', `component` varchar(100) DEFAULT NULL COMMENT '组件路径', `icon` varchar(50) DEFAULT NULL COMMENT '图标', `status` tinyint DEFAULT NULL COMMENT '状态(0:禁止,1:正常)', `is_deleted` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除 1(true)已删除, 0(false)未删除', `create_date` datetime DEFAULT NULL COMMENT '创建时间', `update_date` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_pid` (`pid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='权限表';
从权限分类来说,通常可以划分为:模块、菜单、按钮与接口。其中前三者通常是提供给前端进行页面布局的,最后接口则是交由后端进行控制拦截。
【2】用户实体设计
这里的用户实体设计并非指前面的sys_user,而是指SpringSecurity的UserDetails 。如下所示,我们SecurityUser 实现了UserDetails接口,并包装了sys_user与权限列表。
public class SecurityUser implements UserDetails { //当前登录用户 private transient User currentUserInfo; //当前权限 private List<String> permissionValueList; public SecurityUser() { } public SecurityUser(User user) { if (user != null) { this.currentUserInfo = user; } } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> authorities = new ArrayList<>(); for(String permissionValue : permissionValueList) { if(StringUtils.isEmpty(permissionValue)) continue; SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue); authorities.add(authority); } return authorities; } @Override public String getPassword() { return currentUserInfo.getPassword(); } @Override public String getUsername() { return currentUserInfo.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
【3】存储与传播机制设计
其实这里要解决的是用户登录成功后,我们如何将用户角色权限信息存储起来并提供给前端,以及用户身份信息在整个体系中如何流转。
这里我们设计如下:用户登录成功后,我们采用JWT技术将用户存储起来。以{username:权限列表}
格式存储到Redis中。
如下是我们的TokenLoginFilter
示例,其继承了UsernamePasswordAuthenticationFilter
。
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter { private TokenManager tokenManager; private RedisTemplate redisTemplate; private AuthenticationManager authenticationManager; public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) { this.authenticationManager = authenticationManager; this.tokenManager = tokenManager; this.redisTemplate = redisTemplate; this.setPostOnly(false); this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST")); } //1 获取表单提交用户名和密码 @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { //获取表单提交数据 try { User user = new ObjectMapper().readValue(request.getInputStream(), User.class); return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword(), new ArrayList<>())); } catch (IOException e) { e.printStackTrace(); throw new RuntimeException(); } } //2 认证成功调用的方法 @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { //认证成功,得到认证成功之后用户信息 SecurityUser user = (SecurityUser)authResult.getPrincipal(); //根据用户名生成token String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername()); //把用户名称和用户权限列表放到redis redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(),user.getPermissionValueList()); //返回token ResponseUtil.out(response, R.ok().data("token",token)); } //3 认证失败调用的方法 protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { ResponseUtil.out(response, R.error()); } }
用户登录成功后,会从响应中拿到token。前端在后续请求时,将token放到header中进行传递。此时我们就可以根据请求头中的token获取到用户信息、权限信息放到上下文中。
public class TokenAuthFilter extends BasicAuthenticationFilter { private TokenManager tokenManager; private RedisTemplate redisTemplate; public TokenAuthFilter(AuthenticationManager authenticationManager,TokenManager tokenManager,RedisTemplate redisTemplate) { super(authenticationManager); this.tokenManager = tokenManager; this.redisTemplate = redisTemplate; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { //获取当前认证成功用户权限信息 UsernamePasswordAuthenticationToken authRequest = getAuthentication(request); //判断如果有权限信息,放到权限上下文中 if(authRequest != null) { SecurityContextHolder.getContext().setAuthentication(authRequest); } chain.doFilter(request,response); } private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { //从header获取token String token = request.getHeader("token"); if(token != null) { //从token获取用户名 String username = tokenManager.getUserInfoFromToken(token); //从redis获取对应权限列表 List<String> permissionValueList = (List<String>)redisTemplate.opsForValue().get(username); Collection<GrantedAuthority> authority = new ArrayList<>(); for(String permissionValue : permissionValueList) { SimpleGrantedAuthority auth = new SimpleGrantedAuthority(permissionValue); authority.add(auth); } return new UsernamePasswordAuthenticationToken(username,token,authority); } return null; } }
【4】角色权限的控制
这部分通常分为前端布局与后端控制。假设我们在登录之后给到了前端当前用户所对应的模块、 菜单与按钮权限,那么前端就可以实现动态的布局。
至于后端对用户角色、权限的控制可以通过如下几种方式:
- SpringSecurity请求授权规则配置与注解使用说明一文中提到的注解
- 自定义拦截器
代码使用注解进行控制实例如下:
@PostMapping @PreAuthorize("hasAnyAuthority('access:register:save')") public Result save(@Validated @RequestBody AccessRegister accessRegister, Principal principal){ accessRegister.setCreateBy(principal.getName()); boolean flag = accessRegisterService.addRegister(accessRegister); return flag ? Result.succ("登记成功") : Result.fail("登记失败"); }
【5】SpringSecurity的自定义服务
也就是我们对SpringSecurity的配置。
① 自定义UserDetailsServiceImpl
@Service("userDetailsService") public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserService userService; @Autowired private PermissionService permissionService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //根据用户名查询数据 User user = userService.selectByUsername(username); //判断 if(user == null) { throw new UsernameNotFoundException("用户不存在"); } //根据用户查询用户权限列表 List<String> permissionValueList = permissionService.selectPermissionValueByUserId(user.getId()); SecurityUser securityUser = new SecurityUser(); securityUser.setCurrentUserInfo(user); securityUser.setPermissionValueList(permissionValueList); return securityUser; } }
② 退出处理器
退出的时候需要移除token(假设记录了token)并从Redis移除用户信息。
public class TokenLogoutHandler implements LogoutHandler { private TokenManager tokenManager; private RedisTemplate redisTemplate; public TokenLogoutHandler(TokenManager tokenManager,RedisTemplate redisTemplate) { this.tokenManager = tokenManager; this.redisTemplate = redisTemplate; } @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { //1 从header里面获取token //2 token不为空,移除token,从redis删除token String token = request.getHeader("token"); if(token != null) { //移除 tokenManager.removeToken(token); //从token获取用户名 String username = tokenManager.getUserInfoFromToken(token); redisTemplate.delete(username); } ResponseUtil.out(response, R.ok()); } }
③ 没有权限处理器
当用户没有权限时,返回错误信息。
public class UnauthEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { ResponseUtil.out(httpServletResponse, R.error()); } }
④ 自定义密码加密器
如下所示,我们可以使用MD5来完成这一动作。
@Component public class DefaultPasswordEncoder implements PasswordEncoder { public DefaultPasswordEncoder() { this(-1); } public DefaultPasswordEncoder(int strength) { } //进行MD5加密 @Override public String encode(CharSequence charSequence) { return MD5.encrypt(charSequence.toString()); } //进行密码比对 @Override public boolean matches(CharSequence charSequence, String encodedPassword) { return encodedPassword.equals(MD5.encrypt(charSequence.toString())); } }
⑤ SpringSecurity配置类
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter { private TokenManager tokenManager; private RedisTemplate redisTemplate; private DefaultPasswordEncoder defaultPasswordEncoder; private UserDetailsService userDetailsService; @Autowired public TokenWebSecurityConfig(UserDetailsService userDetailsService, DefaultPasswordEncoder defaultPasswordEncoder, TokenManager tokenManager, RedisTemplate redisTemplate) { this.userDetailsService = userDetailsService; this.defaultPasswordEncoder = defaultPasswordEncoder; this.tokenManager = tokenManager; this.redisTemplate = redisTemplate; } /** * 配置设置 * @param http * @throws Exception */ //设置退出的地址和token,redis操作地址 @Override protected void configure(HttpSecurity http) throws Exception { http.exceptionHandling() .authenticationEntryPoint(new UnauthEntryPoint())//没有权限访问 .and().csrf().disable() .authorizeRequests() .anyRequest().authenticated() .and().logout().logoutUrl("/admin/logout")//退出路径 .addLogoutHandler(new TokenLogoutHandler(tokenManager,redisTemplate)) .and() .addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate)) .addFilter(new TokenAuthFilter(authenticationManager(), tokenManager, redisTemplate)) .httpBasic(); } //调用userDetailsService和密码处理 @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder); } //不进行认证的路径,可以直接访问 @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/api/**"); } }