目錄
1. 前提概要
本項目通過採用目前較流行的四種框架進行整合,實現基於數據庫的動態權限分配及用戶認證項目,可拓展性好,開箱即用,任何涉及權限分配及角色認證的業務均可在該項目基礎上直接進行業務開發!此文僅對項目的核心模塊進行介紹及如何使用,其它模塊類似於異常處理模塊將不做介紹。
項目已上傳到github:https://github.com/SmallPineApp1e/SpringBoot-Security
POM導入相關依賴
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.4.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.smallpineapple</groupId>
<artifactId>springboot-security</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-security</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.21</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>8.0.19</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
2. 數據庫表結構
menu是請求路徑規則,例如/admin/**,代表擁有admin角色的用戶可以訪問這一種路徑規則的所有接口
/*
Navicat Premium Data Transfer
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80018
Source Host : localhost:3306
Source Schema : security
Target Server Type : MySQL
Target Server Version : 80018
File Encoding : 65001
Date: 26/02/2020 23:29:25
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for menu
-- ----------------------------
DROP TABLE IF EXISTS `menu`;
CREATE TABLE `menu` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`pattern` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '請求路徑匹配規則',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of menu
-- ----------------------------
INSERT INTO `menu` VALUES (1, '/db/**');
INSERT INTO `menu` VALUES (2, '/admin/**');
INSERT INTO `menu` VALUES (3, '/user/**');
-- ----------------------------
-- Table structure for menu_role
-- ----------------------------
DROP TABLE IF EXISTS `menu_role`;
CREATE TABLE `menu_role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`mid` int(11) NOT NULL COMMENT 'menu表外鍵',
`rid` int(11) NOT NULL COMMENT 'role表外鍵',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of menu_role
-- ----------------------------
INSERT INTO `menu_role` VALUES (1, 1, 1);
INSERT INTO `menu_role` VALUES (2, 2, 2);
INSERT INTO `menu_role` VALUES (3, 3, 3);
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`nameZh` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES (1, 'dba', '數據庫管理員');
INSERT INTO `role` VALUES (2, 'admin', '系統管理員');
INSERT INTO `role` VALUES (3, 'user', '用戶');
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`enabled` tinyint(1) NULL DEFAULT NULL,
`locked` tinyint(1) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'root', '$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq', 1, 0);
INSERT INTO `user` VALUES (2, 'admin', '$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq', 1, 0);
INSERT INTO `user` VALUES (3, 'sang', '$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq', 1, 0);
-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`uid` int(11) NULL DEFAULT NULL,
`rid` int(11) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user_role
-- ----------------------------
INSERT INTO `user_role` VALUES (1, 1, 1);
INSERT INTO `user_role` VALUES (2, 1, 2);
INSERT INTO `user_role` VALUES (3, 2, 2);
INSERT INTO `user_role` VALUES (4, 3, 3);
SET FOREIGN_KEY_CHECKS = 1;
3. 項目結構
4. 編寫實體類
User類:
需要實現UserDetails接口,用於SpringSecurity的用戶狀態認證(登錄用戶名密碼、用戶是否鎖定、用戶賬號是否可用.....)
/**
* @author Zeng
* @date 2020/2/24 22:27
*/
public class User implements UserDetails {
private Integer id;
private String username;
private String password;
private Boolean enabled;
private Boolean locked;
//用戶具備的角色
private List<Role> roles;
//登錄後返回的token
private String token;
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public List<Role> getRoles() {
return roles;
}
public void setRoles(List<Role> roles) {
this.roles = roles;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
@Override
public String getUsername() {
return username;
}
//賬戶是否未過期
@Override
public boolean isAccountNonExpired() {
return true;
}
//賬戶是否未鎖定
@Override
public boolean isAccountNonLocked() {
return !locked;
}
//賬戶密碼是否未過期
@Override
public boolean isCredentialsNonExpired() {
return true;
}
public void setUsername(String username) {
this.username = username;
}
//獲取用戶的角色
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> list = new ArrayList<>(roles.size());
roles.forEach(role -> {
list.add(new SimpleGrantedAuthority(role.getName()));
});
return list;
}
@Override
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public boolean isEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public Boolean getLocked() {
return locked;
}
public void setLocked(Boolean locked) {
this.locked = locked;
}
}
Role類:
package org.smallpineapple.springbootsecurity.bean;
/**
* @author Zeng
* @date 2020/2/24 22:32
*/
public class Role {
private Integer id;
private String name;
private String nameZh;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameZh() {
return nameZh;
}
public void setNameZh(String nameZh) {
this.nameZh = nameZh;
}
}
Menu類:
/**
* @author Zeng
* @date 2020/2/24 22:32
*/
public class Menu {
private Integer id;
private String pattern;
//當前路徑需要具備的角色
private List<Role> roles;
public List<Role> getRoles() {
return roles;
}
public void setRoles(List<Role> roles) {
this.roles = roles;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getPattern() {
return pattern;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
}
4. 核心配置類SecurityConfig
該類負責註冊有關權限控制和用戶登錄校驗的類
/**
* @author Zeng
* @date 2020/2/24 22:52
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserService userService;
@Autowired
MyFilter myFilter;
@Autowired
CustomAccessDecisionManager customAccessDecisionManager;
//使用的密碼加密方式
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//註冊登錄認證方法
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}
//配置登錄及註銷及權限配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
//註冊MyFilter和customAccessDecisionManager進行權限管理
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setAccessDecisionManager(customAccessDecisionManager);
o.setSecurityMetadataSource(myFilter);
return o;
}
})
//路徑爲“/doLogin”的POST請求自動放行
.antMatchers(HttpMethod.POST, "/doLogin")
.permitAll()
//其它請求都需要認證
.anyRequest().authenticated()
.and()
//添加登錄的過濾器,當請求路徑爲"/doLogin"時該過濾器截取請求
.addFilterBefore(new JwtLoginFilter("/doLogin",
authenticationManager()), UsernamePasswordAuthenticationFilter.class)
//添加token校驗的過濾器,每次發起請求都被該過濾器截取判斷是否登錄
.addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf().disable();
}
}
5. 登錄認證
定義JwtLoginFilter類,用戶登錄時會被該過濾器截取下來,defaultFilterProcessesUrl代表登錄的請求路徑,如果定義爲“/doLogin”時,用戶請求登錄"/doLogin"將會來到該過濾器的attemptAuthentication()方法進行用戶名和密碼的校驗,如果校驗成功則會生成token返回給客戶端
/**
* @author Zeng
* @date 2020/2/25 11:16
*/
public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {
public JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
setAuthenticationManager(authenticationManager);
}
/**
* 從登錄參數中提取出用戶名密碼, 然後調用 AuthenticationManager.authenticate() 方法去進行自動校驗
* @param req
* @param resp
* @return
* @throws AuthenticationException
* @throws IOException
* @throws ServletException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse resp) throws AuthenticationException, IOException, ServletException {
String username = req.getParameter("username");
String password = req.getParameter("password");
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
return getAuthenticationManager().authenticate(token);
}
/**
* 校驗成功的回調函數,生成jwt的token
* @param req
* @param resp
* @param chain
* @param authResult
* @throws IOException
* @throws ServletException
*/
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
StringBuffer roles = new StringBuffer();
//遍歷用戶角色,將其寫入jwt中,注意角色之間以逗號分隔,是一種規範
for (GrantedAuthority authority : authorities) {
roles.append(authority.getAuthority())
.append(",");
}
String jwt = Jwts.builder()
.claim("authorities", roles)//配置用戶角色
.setSubject(authResult.getName())//設置jwt的主題爲用戶的用戶名
.setExpiration(new Date(System.currentTimeMillis() + 10 * 60 * 1000))//設置過期時間爲10分鐘
.signWith(SignatureAlgorithm.HS512,"turing-team") //使用密鑰對頭部和載荷進行簽名
.compact();//生成jwt
//返回給前端
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
User user = (User) authResult.getPrincipal();
user.setToken(jwt);
JsonResultUtil jsonResultUtil = JsonResultUtil.success("登錄成功", user);
System.out.println(jsonResultUtil.toString());
out.write(new ObjectMapper().writeValueAsString(jsonResultUtil));
out.flush();
out.close();
}
/**
* 校驗失敗的回調函數
* @param req
* @param resp
* @param failed
* @throws IOException
* @throws ServletException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
JsonResultUtil failure = JsonResultUtil.failure("用戶名或密碼錯誤,請重新登錄!", null);
out.write(new ObjectMapper().writeValueAsString(failure));
out.flush();
out.close();
}
}
6. 驗證token過濾器
/**
* @author Zeng
* @date 2020/2/25 11:43
* 用戶攜帶的token是否有效
*/
public class JwtFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse resp = (HttpServletResponse) servletResponse;
//獲取token
String cliToken = req.getHeader("token");
PrintWriter pw;
if("".equals(cliToken) || cliToken == null){
pw = resp.getWriter();
resp.setContentType("application/json;charset=utf-8");
JsonResultUtil jsonResult = JsonResultUtil.failure("必須傳遞用戶的認證信息", null);
pw.write(new ObjectMapper().writeValueAsString(jsonResult));
pw.flush();
pw.close();
return ;
}
//解析token
Jws<Claims> jws;
try {
jws = Jwts.parser()
.setSigningKey("turing-team") //設置生成jwt時使用的密鑰
.parseClaimsJws(cliToken);
}catch (JwtException ex){
pw = resp.getWriter();
resp.setContentType("application/json;charset=utf-8");
JsonResultUtil jsonResult = JsonResultUtil.failure("登錄已過期,請重新登陸", null);
pw.write(new ObjectMapper().writeValueAsString(jsonResult));
pw.flush();
pw.close();
return ;
}
Claims claims = jws.getBody();
//獲取用戶的用戶名,在生成token時指定了主題爲用戶名
String username = claims.getSubject();
//獲取用戶的所有角色,以逗號分割的字符串
String authoritiesStr = (String) claims.get("authorities");
//轉成用戶的所有角色對象,如果是以逗號分隔則可以自動轉換爲GrantedAuthority對象
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(authoritiesStr);
//對用戶進行校驗
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(token);
//放行
filterChain.doFilter(servletRequest, servletResponse);
}
}
7. 獲取請求路徑所需角色過濾器
/**
* @author Zeng
* @date 2020/2/24 23:05
* 定義過濾器,分析出用戶的請求地址匹配邏輯並分析出需要哪些角色
*/
@Component
public class MyPermissionFilter implements FilterInvocationSecurityMetadataSource {
//路徑匹配類,用於檢查用戶的請求路徑是否與數據庫中某個路徑規則匹配
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Autowired
MenuService menuService;
//每次用戶發出請求都會先進入該方法,分析出該請求地址需要哪些角色
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
//強轉對象
FilterInvocation filterInvocation = (FilterInvocation) o;
//獲取用戶請求地址
String requestUrl = filterInvocation.getRequestUrl();
//獲取所有路徑規則
List<Menu> menus = menuService.findAllMenusWithRoles();
//遍歷路徑規則
for (Menu menu : menus) {
//判斷與哪一條路由規則匹配
if(antPathMatcher.match(menu.getPattern(), requestUrl)){
//獲取訪問該路徑所需要的所有角色
List<Role> roles = menu.getRoles();
//轉化爲返回值類型
String[] rolesStr = new String[roles.size()];
for (int i = 0; i < rolesStr.length; i++) {
rolesStr[i] = roles.get(i).getName();
}
return SecurityConfig.createList(rolesStr);
}
}
//全部都匹配不上,則返回一個默認的標識符,表示該路徑是登錄後就可以訪問的路徑
return SecurityConfig.createList("ROLE_login");
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
//是否支持該方式,返回true
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
8. 用戶權限驗證
/**
* @author Zeng
* @date 2020/2/24 23:33
* 判斷請求當前用戶具有哪些角色,如果用戶具備訪問路徑須具備的角色則允許訪問,否則判爲非法請求
*/
@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {
/**
* 核心方法,判斷用戶是否可以有權限訪問該路徑
* @param authentication 可以獲取登錄的用戶信息
* @param o 實際是FilterInvocation對象,可以獲取請求路徑
* @param collection 訪問該路徑所需要的角色,是MyFilter中的返回值
* @throws AccessDeniedException 非法請求,權限不夠
* @throws InsufficientAuthenticationException
*/
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
//遍歷訪問該路徑所需要的所有角色名字
for (ConfigAttribute configAttribute : collection) {
//如果返回是登錄後可訪問,則判斷用戶是否登錄
if("ROLE_login".equals(configAttribute.getAttribute())){
//AnonymousAuthenticationToken爲匿名訪問,則不允許訪問
if(authentication instanceof AnonymousAuthenticationToken){
throw new AccessDeniedException("尚未登錄,請前往登錄!");
}
return ;
}
//獲取當前用戶所具有的所有角色
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
//遍歷該用戶的所有角色並判斷是否具有必須具備的角色
for (GrantedAuthority authority : authorities) {
if(authority.getAuthority().equals(configAttribute.getAttribute())){
return ;
}
}
}
throw new AccessDeniedException("權限不足,請聯繫管理員!");
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
9. UserService
/**
* @author Zeng
* @date 2020/2/24 22:42
*/
@Service
public class UserService implements UserDetailsService {
@Autowired
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userMapper.loadUserByUsername(s);
if(user == null){
throw new UsernameNotFoundException("用戶不存在!");
}
user.setRoles(userMapper.findRolesByUserId(user.getId()));
return user;
}
}
10. HelloController
/**
* @author Zeng
* @date 2020/2/24 22:55
*/
@RestController
public class HelloController {
@GetMapping("/hello")
public JsonResultUtil hello(){
return JsonResultUtil.success("成功訪問公共接口", null);
}
@GetMapping("/db/hello")
public JsonResultUtil db(){
return JsonResultUtil.success("成功訪問dba角色的接口", null);
}
@GetMapping("/admin/hello")
public JsonResultUtil admin(){
return JsonResultUtil.success("成功訪問admin角色的接口", null);
}
@GetMapping("/user/hello")
public JsonResultUtil user(){
return JsonResultUtil.success("成功訪問user角色的接口", null);
}
}
9. 流程解析
當用戶發起非登錄請求時,首先會被JwtFilter截取請求進行token有效性校驗,判斷用戶是否處於已登錄狀態;若已登錄接下來被MyPermissionFilter截取請求,判斷該請求需要用戶具備哪些角色纔可以訪問,然後把需要的角色封裝起來傳遞到請求訪問管理類CustomAccessDecsionManager判斷用戶是否具有任意一個相應的角色,如果具有則此次訪問是正常地訪問,否則說明該訪問是非法的,不允許訪問,拋出異常。
10. 接口測試
用戶 | root | admin | sang |
---|---|---|---|
角色 |
admin, db | admin | user |
可訪問接口規則 | /admin/**,/db/** | /admin/** | /user/** |
10.1 登錄測試
10.2 訪問有權限的接口
10.3 訪問無權限的接口
這裏的異常沒有處理好,可以採用統一異常處理返回友好的JSON信息給前端,可參考我的另一篇博客:SpringBoot統一異常處理
10.4 訪問權限表以外的接口(登錄即可訪問)
至此,整個環境已經搭建起來了,可以往上繼續添加其它業務,登錄模塊和權限控制模塊已經搭建好了,時間有限,能力有限,如果有什麼錯誤歡迎大家指出,樂意與你們交流!