引言
上一次,使用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
。五行代码(不算注释),一个注解。就解决了一直以来困扰我们的权限问题。