基于
Spring Security
实现权限管理系统
Spring Security
稍微复杂一点的后台系统都会涉及到用户权限管理。何谓用户权限?我的理解就是,权限就是对数据(
系统的实体类
)和数据可进行的操作(
增删查改
)的集中管理。要构建一个可用的权限管理系统,涉及到三个核心类:一个是用户
User
,一个是角色
Role
,最后是权限
Permission
。接下来本文将介绍如何基于
Spring Security 4.0
一步一步构建起一个接口级别的权限管理系统。
1. 相关概念
- 权限(Permission) = 资源(Resource) + 操作(Privilege)
- 角色(Role) = 权限的集合(a set of low-level permissions)
- 用户(User) = 角色的集合(high-level roles)
2. Spring Security的
maven
依赖
maven
Spring Boot
版本虽然已经到
2.0
了,但是之前使用的时候发现了一些坑,所以推荐还是暂时使用比较稳定的
1.5
版本。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.xxx.xxx</groupId>
<artifactId>api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
<name>security-demo</name>
<description>Demo project for spring security</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.7</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Camden.SR6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
3. 定义系统的权限集合
权限是资源以及可对资源进行的操作的一个集合。对于我们的系统来说,几乎所有实体类都可以看作一个资源,而常见的操作也就是
增删查改
四类,当然,根据我们实际的业务需要,可能还有其他的特殊操作,比如我们这里加了一个
导入用户
的操作。这里简单列举两个基本的权限集合:
[
{
"resourceId":"permission",
"resourceName":"权限",
"privileges": {
"read":"查看",
"write":"新增",
"update":"更新",
"delete":"删除"
}
},
{
"resourceId":"user",
"resourceName":"用户",
"privileges": {
"read":"查看用户列表",
"write":"新增用户",
"import":"导入用户",
"update":"修改用户信息",
"delete":"删除用户"
}
}
]
在对权限的定义中,关键是
resourceId
和
privileges
的
key
,后续将使用这两者结合来对用户的权限进行判断。我这里使用
resourceId-privilege
这样的形式来唯一表示对某个资源进行的某个操作。
4. 角色相关的操作
资源与操作权限集合类定义
JsonPermissions
:
@Data
public class JsonPermissions {
private List<SimplePermission> permissions;
@Data
public static class SimplePermission {
/**
* 资源id
*/
private String resourceId;
/**
* 资源名
*/
private String resourceName;
/**
* 权限列表
*/
private Map<String, String> privileges;
/**
* 是否被遗弃
*/
private boolean abandon = false;
}
}
角色类定义
Role
:
import lombok.Data;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;
@Document(collection = "role")
@Data
public class Role {
@Id
private String id;
/**
* 创建时间
*/
private Long createdTime = System.currentTimeMillis();
/**
* 是否被移除
*/
private Boolean isRemoved = false;
/**
* 角色名,用于权限校验
*/
private String name;
/**
* 角色中文名,用于显示
*/
private String nickname;
/**
* 角色描述信息
*/
private String description;
/**
* 是否为内置
*/
private boolean builtIn = false;
/**
* 角色状态,是否已禁用
*/
private Boolean banned = false;
/**
* 角色可进行的操作列表
*/
private List<JsonPermissions.SimplePermission> permissions;
/**
* 角色创建者
*/
private String proposer;
/**
* Spring Security 4.0以上版本角色都默认以'ROLE_'开头
* @param name
*/
public void setName(String name) {
if (name.indexOf("ROLE_") == -1) {
this.name = "ROLE_" + name;
} else {
this.name = name;
}
}
}
5. 给用户赋予角色
Spring Security
框架提供了一个基础用户接口
UserDetails
,该接口提供了基本的用户相关的操作,比如获取用户名/密码、用户账号是否过期和用户认证是否过期等,我们定义自己的
User
类时需要实现该接口。
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.*;
@Data
@NoArgsConstructor
public class User implements UserDetails {
public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();
@Id
private String id;
/**
* 创建时间
*/
private Long createdTime = System.currentTimeMillis();
/**
* 用户登录名
*/
private String username;
/**
* 用户真实姓名
*/
private String realName;
/**
* 用户登录密码,用户的密码不应该暴露给客户端
*/
@JsonIgnore
private String password;
/**
* 用户类型
*/
private String type;
/**
* 该用户关联的企业/区块id
*/
private Map<String, Object> associatedResources = new HashMap<>();
/**
* 用户关注的企业列表
*/
private List<String> favourite = new ArrayList<>();
/**
* 用户在系统中的角色列表,将根据角色对用户操作权限进行限制
*/
private List<String> roles = new ArrayList<>();
public void setPassword(String password) {
this.password = PASSWORD_ENCODER.encode(password);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
6. 创建系统的初始角色和超级管理员
如果我们对系统的所有接口都加上了访问限制,那么由谁来作为初始用户登录系统并创建其他用户呢?所以我们需要定义系统的初始角色和初始用户,并在系统启动时将初始角色和初始用户自动录入系统,然后再使用初始用户登录系统去创建其他业务相关的用户。定义系统的超级管理员角色:
roles.json
[
{
"name":"ROLE_ADMINISTRATOR",
"nickname":"管理员",
"description":"系统超级管理员,不允许用户更改",
"banned":false,
"state":"normal",
"permissions":[
{
"resourceId":"permission",
"resourceName":"权限",
"privileges": {
"read":"查看",
"write":"新增",
"update":"更新",
"delete":"删除"
}
},
{
"resourceId":"user",
"resourceName":"用户",
"privileges": {
"read":"查看用户列表",
"write":"新增用户",
"import":"导入用户",
"update":"修改用户信息",
"delete":"删除用户"
}
}
]
}
]
定义系统的初始管理员用户:
users.json
[
{
"username":"admin",
"realName":"超超超级管理员",
"password":"$2a$10$GhI1umKcTHysip4iSFXPXOQG1x9U.4eCWMEFwF/h3LBAt98K4o1B.",
"number":"admin",
"type":"system",
"activated":true,
"roles":["ROLE_ADMINISTRATOR"]
}
]
7. 加载系统初始化角色和用户数据
在系统部署时,需要将系统的初始化角色和用户自动加载到数据库中,这样才能正常登录使用。使用
@Component
和
@PostConstruct
注解在系统启动时自动导入初始化角色和用户。
import com.google.gson.reflect.TypeToken;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import javax.annotation.PostConstruct;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
/**
* 系统初始化配置类,主要用于加载内置数据到目标数据库上
*/
@Component
public class SystemInitializer {
@Value("${initialzation.file.users:users.json}") private String userFileName;
@Value("${initialzation.file.roles:roles.json}") private String roleFileName;
@Autowired
private UserRepository userRepository;
@Autowired private RoleRepository roleRepository;
@PostConstruct
public boolean initialize() throws Exception {
try {
InputStream userInputStream = getClass().getClassLoader().getResourceAsStream(userFileName);
if(userInputStream == null){
throw new Exception("initialzation user file not found: " + userFileName);
}
InputStream roleInputStream = getClass().getClassLoader().getResourceAsStream(roleFileName);
if(roleInputStream == null){
throw new Exception("initialzation role file not found: " + roleFileName);
}
//导入初始的系统超级管理员角色
Type roleTokenType = new TypeToken<ArrayList<Role>>(){}.getType();
ArrayList<Role> roles = CommonGsonBuilder.create().fromJson(new InputStreamReader(roleInputStream, StandardCharsets.UTF_8), roleTokenType);
for (Role role: roles) {
if (roleRepository.findByName(role.getName()) == null) {
roleRepository.save(role);
}
}
//导入初始的系统管理员用户
Type teacherTokenType = new TypeToken<ArrayList<User>>(){}.getType();
ArrayList<User> users = CommonGsonBuilder.create().fromJson(new InputStreamReader(userInputStream, StandardCharsets.UTF_8), teacherTokenType);
for (User user : users) {
if (userRepository.findByUsername(user.getUsername()) == null) {
userRepository.save(user);
}
}
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
}
8. 实现自己的UserDetailsService
在
UserDetailService
中自定义加载用户信息,并将用户角色
role
相关的所有
Permissions
设置到
Authentication
的
authorities
中以供
PermissionEvaluator
对用户权限进行判断。注意这里使用了
resourceId-privilege
的形式进行了拼接后存放。我这里用户信息是存放在
MongoDB
数据库中的,也可以换成其他的数据库。
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private IUserService userService;
@Autowired
private MongoTemplate mongoTemplate;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException(String.format("No user found with username: %s", username));
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
List<String> roles = user.getRoles();
for (String roleName : roles) {
Role role = mongoTemplate.findOne(Query.query(Criteria.where("name").is(roleName)), Role.class);
if (role == null) {
continue;
}
for (JsonPermissions.SimplePermission permission : role.getPermissions()) {
for (String privilege : permission.getPrivileges().keySet()) {
authorities.add(new SimpleGrantedAuthority(String.format("%s-%s", permission.getResourceId(), privilege)));
}
}
}
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), authorities);
}
}
9. 配置
UserDetailsService
UserDetailsService
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests().antMatchers(
"/js/**",
"/css/**",
"/img/**",
"/login/**").permitAll()
.anyRequest().authenticated()
.and().formLogin().permitAll()
.cors();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
}
10. 后端接口根据权限实现访问限制
在需要进行访问限制的接口方法上面加上
PreAuthorize
注解,在该注解中我们可以使用多种校验方法,比较常见的有
hasPermisson
和
hasRole
两个。而和
PreAuthorize
类似的还有
PostAuthorize
注解,从字面意义也比较好理解
PreAuthorize
是在访问接口前进行校验,而
PostAuthorize
是在访问接口后返回结果时进行校验。
@GetMapping(value = "/list")
@PreAuthorize("hasPermission('user', 'read') or hasRole('ROLE_ADMINISTRATOR')")
public List<?> getUserList(@RequestParam(value = "text", defaultValue = "") String text,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
return userService.list(text, page, size);
}
以此类推,可以在需要对用户访问进行限制的接口上面加上相应的访问限制。
11. 实现自己的PermissionEvaluator
在接口方法上面增加了
PreAuthorize
注解后还需要实现自己的
PermissionEvaluator
,
Spring Security
将在
hasPermission()
方法中对当前登录用户正在访问的资源及其对资源进行的操作进行合法性校验。
注意,这里
targetDomainObject
即是我们之前定义的
resourceId
,而
permission
即为
privilege
,在校验时要将其组合为和
UserDetailsService
中存储格式一致的格式,我们这里是使用
-
中划线进行连接的。
import java.io.Serializable;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
@Configuration
public class MyPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
boolean accessable = false;
if(authentication.getPrincipal().toString().compareToIgnoreCase("anonymousUser") != 0){
String privilege = targetDomainObject + "-" + permission;
for(GrantedAuthority authority : authentication.getAuthorities()){
if(privilege.equalsIgnoreCase(authority.getAuthority())){
accessable = true;
break;
}
}
return accessable;
}
return accessable;
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
// TODO Auto-generated method stub
return false;
}
}
12. 注解支持
实现了PermissionEvaluator之后必须添加
globalMethodSecurity
的注解,否则在接口上面加的权限判断不会生效。在
SpringBootServletInitializer
的继承类上面加上该注解启用
method security
。
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.support.SpringBootServletInitializer;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
@SpringBootApplication
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MyApiApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder applicationBuilder) {
return applicationBuilder.sources(MyApiApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(MyApiApplication.class, args);
}
}
13. 访问测试:403
由于我目前登录的用户还没有为其设置角色和访问权限,所以我没有访问
list
接口的权限,强行访问的时候就出现了如下的
403
的错误提示:
14. 前端页面根据权限实现个性化页面
后端实现了接口级别的访问限制之后并没有结束。对于用户可见的界面部分,不同角色的用户登录系统时应该根据自己的角色而看到不同的界面。我们目前的经验是,用户登录成功后返回给前端该用户的权限列表,然后由前端对权限进行判断,如果没有权限则隐藏相应的按钮或者功能模块。通过前后端这样的结合,用户将只能看到自己权限允许范围内的操作界面和数据,同时,即使某些用户直接修改接口参数来获取数据,在后端也会对其进行二次判断,确保用户自己看到自己的数据,只能进行权限范围内的操作!