本文我们记录总结一些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/**");
}
}