基於Spring Security實現許可權管理系統

肖老闆發表於2018-11-06

基於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依賴

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":"刪除使用者"
    }
  }
]

在對許可權的定義中,關鍵是resourceIdprivilegeskey,後續將使用這兩者結合來對使用者的許可權進行判斷。我這裡使用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設定到Authenticationauthorities中以供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

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註解,在該註解中我們可以使用多種校驗方法,比較常見的有hasPermissonhasRole兩個。而和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註解後還需要實現自己的PermissionEvaluatorSpring 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的錯誤提示:
Alt text

14. 前端頁面根據許可權實現個性化頁面

後端實現了介面級別的訪問限制之後並沒有結束。對於使用者可見的介面部分,不同角色的使用者登入系統時應該根據自己的角色而看到不同的介面。我們目前的經驗是,使用者登入成功後返回給前端該使用者的許可權列表,然後由前端對許可權進行判斷,如果沒有許可權則隱藏相應的按鈕或者功能模組。通過前後端這樣的結合,使用者將只能看到自己許可權允許範圍內的操作介面和資料,同時,即使某些使用者直接修改介面引數來獲取資料,在後端也會對其進行二次判斷,確保使用者自己看到自己的資料,只能進行許可權範圍內的操作!

相關文章