Spring Security 实现用户授权
引言
上一次,使用Spring Security
与Angular
实现了用户认证。Spring Security and Angular 实现用户认证
本次,我们通过Spring Security
的授权机制,实现用户授权。
实现十分简单,大家认真听,都能听得懂。
实现
权限设计
前台实现了菜单的权限控制,但后台接口还没进行保护,只要用户登录成功,什么接口都可以调用。
我们希望实现:用户有什么菜单的权限,只能访问后台对应该菜单的接口。
比如,用户有计算机组管理的菜单,就可以访问计算机组相关的增删改查接口,但是其他的接口都不允许访问。
Spring Security
的设计
依据Spring Security
的设计,用户对应角色,角色对应后台接口。这是没什么问题的。
示例
某接口添加@Secured
注解,内部添加权限表达式。
@GetMapping @Secured("ROLE_ADMIN") public List<Host> getAll() { return hostService.getAll(); }
然后再为用户创建Spring Security
中的角色。
这里我们为用户添加ROLE_ADMIN
的角色授权,与getAll
方法上的@Secured("ROLE_ADMIN")
注解中的参数一致,表示该用户有权限访问该方法,这就是授权。
private UserDetails createUser(User user) { logger.debug("初始化授权列表"); List<SimpleGrantedAuthority> authorities = new ArrayList<>(); logger.debug("角色授权"); authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); logger.debug("构建用户"); return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities); }
不足
作为一款优秀的安全框架而言,Spring Security
这样设计是没有任何问题的,我们只需要简简单单的几行代码就能实现接口的授权管理。
但是却不符合我们的要求。
我们要求,在我们的系统中,用户对应多角色。
但是我们的角色是要求可以进行动态配置的,今天有一个系统管理员的角色,明天可能又加一个教师的角色。
在用户授权这方面,是可以实现动态配置的,因为用户的权限列表是一个List
,我可以从数据库查当前用户的角色,然后add
进去。
private UserDetails createUser(User user) { logger.debug("初始化授权列表"); List<SimpleGrantedAuthority> authorities = new ArrayList<>(); logger.debug("角色授权"); authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); logger.debug("构建用户"); return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities); }
但是在接口级别,就无法实现动态配置了。大家想想,注解里,要求的参数必须是常量,就是我们想动态配置,也实现不了啊?
@GetMapping @Secured("ROLE_ADMIN") public List<Host> getAll() { return hostService.getAll(); }
所以,我们总结,因为注解配置的限制,所以在Spring Security
中角色是静态的。
重新设计
我们的角色是动态的,而Spring Security
中的角色是静态的,所以不能将我们的角色直接映射到Spring Security
中的角色,要映射也得拿一个我们系统中静态的对象与之对应。
角色是动态的,这个不行了。但是我们的菜单是静态的啊。
功能模块是我们开发的,菜单就这么固定的几个,用户管理、角色管理、系统设置啥的,在我们开发期间就已经固定下来了,我们是不是可以使用菜单结合Spring Security
进行授权呢?
认真看这张图,看懂了这张图,你应该就明白了我的设计思想。
角色是动态的,我不用它授权,我使用静态的菜单进行授权。
静态的菜单对应Spring Security
中静态的角色,角色再对应后台接口,如此设计,就实现了我们的设想:用户拥有哪个菜单的权限,就只拥有被该菜单调用的相应接口权限。
编码
设计好了,一起来写代码吧。
授权注解选择
Spring Security
中有多种授权注解,个人经过对比之后选择@Secured
注解,因为我觉得这个注解配置项更容易被人理解。
public @interface Secured { /** * Returns the list of security configuration attributes (e.g. ROLE_USER, ROLE_ADMIN). * * @return String[] The secure method attributes */ public String[]value(); }
直接写一个角色的字符串数组传进去即可。
@Secured("ROLE_ADMIN") // 需要拥有`ROLE_ADMIN`角色才可访问 @Secured({"ROLE_ADMIN", "ROLE_TEACHER"}) // 用户拥有`ROLE_ADMIN`、`ROLE_TEACHER`二者之一即可访问
注意:这里的字符串一定是以ROLE_
开头,Spring Security
才把它当成角色的配置,否则无效。
启用@Secured
注解
默认的Spring Security
是不进行授权注解拦截的,添加注解@EnableGlobalMethodSecurity
以启用@Secured
注解的全局方法拦截。
@EnableGlobalMethodSecurity(securedEnabled = true) // 启用全局方法安全,采用@Secured方式
菜单角色映射
在菜单中新建一个字段securityRoleName
来声明我们的系统菜单对应着哪个Spring Security
角色。
// 该菜单在Spring Security环境下的角色名称 @Column(nullable = false) private String securityRoleName;
建一个类,用于存放所有Spring Security
角色的配置信息,供全局调用。
这里不能用枚举,@Secured
注解中要求必须是String
数组,如果是枚举,需要通过YunzhiSecurityRoleEnum.ROLE_MAIN.name()
格式获取字符串信息,但很遗憾,注解中要求必须是常量。
还记得上次自定义HTTP
状态码的时候,吃了枚举类无法扩展的亏,以后再也不用枚举了。就算用枚举,也会设计一个接口,枚举实现该接口,不用枚举声明方法的参数类型,而使用接口声明,方便扩展。
package club.yunzhi.huasoft.security; /** * @author zhangxishuo on 2019-03-02 * Yunzhi Security 角色 * 该角色对应菜单 */ public class YunzhiSecurityRole { public static final String ROLE_MAIN = "ROLE_MAIN"; public static final String ROLE_HOST = "ROLE_HOST"; public static final String ROLE_GROUP = "ROLE_GROUP"; public static final String ROLE_USER = "ROLE_USER"; public static final String ROLE_ROLE = "ROLE_ROLE"; public static final String ROLE_SETTING = "ROLE_SETTING"; }
示例
@GetMapping @Secured({YunzhiSecurityRole.ROLE_HOST, YunzhiSecurityRole.ROLE_GROUP}) public List<Host> getAll() { return hostService.getAll(); }
用户授权
代码体现授权思路:遍历当前用户的菜单,根据菜单中对应的Security
角色名进行授权。
private UserDetails createUser(User user) { logger.debug("获取用户的所有授权菜单"); Set<WebAppMenu> menus = webAppMenuService.getAllAuthMenuByUser(user); logger.debug("初始化授权列表"); List<SimpleGrantedAuthority> authorities = new ArrayList<>(); logger.debug("遍历授权菜单,进行角色授权"); for (WebAppMenu menu : menus) { authorities.add(new SimpleGrantedAuthority(menu.getSecurityRoleName())); } logger.debug("构建用户"); return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities); }
注:这里遇到了Hibernate
惰性加载引起的错误,启用事务防止Hibernate
关闭Session
,深层原理目前还在研究。
单元测试
单元测试很简单,供写相同功能的人参考。
@Test public void authTest() throws Exception { logger.debug("获取基础菜单"); WebAppMenu hostMenu = webAppMenuRepository.findByRoute("/host"); WebAppMenu groupMenu = webAppMenuRepository.findByRoute("/group"); WebAppMenu settingMenu = webAppMenuRepository.findByRoute("/setting"); logger.debug("构造角色"); List<Role> roleList = new ArrayList<>(); Role roleHost = new Role(); roleHost.setWebAppMenuList(Collections.singletonList(hostMenu)); roleList.add(roleHost); Role roleGroup = new Role(); roleGroup.setWebAppMenuList(Collections.singletonList(groupMenu)); roleList.add(roleGroup); Role roleSetting = new Role(); roleSetting.setWebAppMenuList(Collections.singletonList(settingMenu)); roleList.add(roleSetting); logger.debug("保存角色"); roleRepository.saveAll(roleList); logger.debug("构造用户"); User user = userService.getOneUnSavedUser(); logger.debug("获取用户名和密码"); String username = user.getUsername(); String password = user.getPassword(); logger.debug("保存用户"); userRepository.save(user); logger.debug("用户登录"); String token = this.loginWithUsernameAndPassword(username, password); logger.debug("无授权用户访问host,断言403"); this.mockMvc.perform(MockMvcRequestBuilders.get(HOST_URL) .header(TOKEN_KEY, token)) .andExpect(status().isForbidden()); logger.debug("用户授权Host菜单"); user.getRoleList().clear(); user.getRoleList().add(roleHost); userRepository.save(user); logger.debug("重新登录, 重新授权"); token = this.loginWithUsernameAndPassword(username, password); logger.debug("授权Host用户访问,断言200"); this.mockMvc.perform(MockMvcRequestBuilders.get(HOST_URL) .header(TOKEN_KEY, token)) .andExpect(status().isOk()); logger.debug("用户授权Group菜单"); user.getRoleList().clear(); user.getRoleList().add(roleGroup); userRepository.save(user); logger.debug("重新登录, 重新授权"); token = this.loginWithUsernameAndPassword(username, password); logger.debug("授权Group用户访问,断言200"); this.mockMvc.perform(MockMvcRequestBuilders.get(HOST_URL) .header(TOKEN_KEY, token)) .andExpect(status().isOk()); logger.debug("用户授权Setting菜单"); user.getRoleList().clear(); user.getRoleList().add(roleSetting); userRepository.save(user); logger.debug("重新登录, 重新授权"); token = this.loginWithUsernameAndPassword(username, password); logger.debug("授权Setting用户访问,断言403"); this.mockMvc.perform(MockMvcRequestBuilders.get(HOST_URL) .header(TOKEN_KEY, token)) .andExpect(status().isForbidden()); } private String loginWithUsernameAndPassword(String username, String password) throws Exception { logger.debug("用户登录"); byte[] encodedBytes = Base64.encodeBase64((username + ":" + password).getBytes()); MvcResult mvcResult = this.mockMvc.perform(MockMvcRequestBuilders.get(LOGIN_URL) .header("Authorization", "Basic " + new String(encodedBytes))) .andExpect(status().isOk()) .andReturn(); logger.debug("从返回体中获取token"); String json = mvcResult.getResponse().getContentAsString(); JSONObject jsonObject = JSON.parseObject(json); return jsonObject.getString("token"); }
总结
感谢开源社区,感谢Spring Security
。五行代码(不算注释),一个注解。就解决了一直以来困扰我们的权限问题。