1. 程式人生 > 程式設計 >SpringBoot整合Spring Security的詳細教程

SpringBoot整合Spring Security的詳細教程

好好學習,天天向上

本文已收錄至我的Github倉庫DayDayUP:github.com/RobodLee/DayDayUP,歡迎Star,更多文章請前往:目錄導航

前言

Spring Security是一個功能強大且高度可定製的身份驗證和訪問控制框架。提供了完善的認證機制和方法級的授權功能。是一款非常優秀的許可權管理框架。它的核心是一組過濾器鏈,不同的功能經由不同的過濾器。這篇文章就是想通過一個小案例將Spring Security整合到SpringBoot中去。要實現的功能就是在認證伺服器上登入,然後獲取Token,再訪問資源伺服器中的資源。

SpringBoot整合Spring Security的詳細教程

基本概念

單點登入

什麼叫做單點登入呢。就是在一個多應用系統中,只要在其中一個系統上登入之後,不需要在其它系統上登入也可以訪問其內容。舉個例子,京東那麼複雜的系統肯定不會是單體結構,必然是微服務架構,比如訂單功能是一個系統,交易是一個系統......那麼我在下訂單的時候登入了,付錢難道還需要再登入一次嗎,如果是這樣,使用者體驗也太差了吧。實現的流程就是我在下單的時候系統發現我沒登入就讓我登入,登入完了之後系統返回給我一個Token,就類似於身份證的東西;然後我想去付錢的時候就把Token再傳到交易系統中,然後交易系統驗證一下Token就知道是誰了,就不需要再讓我登入一次。

SpringBoot整合Spring Security的詳細教程

JWT

上面提到的Token就是JWT(JSON Web Token),是一種用於通訊雙方之間傳遞安全資訊的簡潔的、URL安全的表述性宣告規範。一個JWT實際上就是一個字串,它由三部分組成,頭部、載荷與簽名。為了能夠直觀的看到JWT的結構,我畫了一張思維導圖:

SpringBoot整合Spring Security的詳細教程

最終生成的JWT令牌就是下面這樣,有三部分,用 . 分隔。

base64UrlEncode(JWT 頭)+"."+base64UrlEncode(載荷)+"."+HMACSHA256(base64UrlEncode(JWT 頭) + "." + base64UrlEncode(有效載荷),金鑰)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

SpringBoot整合Spring Security的詳細教程

RSA

從上面的例子中可以看出,JWT在加密解密的時候都用到了同一個金鑰 “ robod666 ”,這將會帶來一個弊端,如果被黑客知道了金鑰的內容,那麼他就可以去偽造Token了。所以為了安全,我們可以使用非對稱加密演算法RSA。

SpringBoot整合Spring Security的詳細教程

RSA的基本原理有兩點:

  • 私鑰加密,持有私鑰或公鑰才可以解密
  • 公鑰加密,持有私鑰才可解密

認證伺服器使用者登入功能

前期準備

介紹完了基本概念之後就可以開始整合了,受限於篇幅,只貼最核心的程式碼,其它內容請小夥伴們去原始碼中找,地址在文末。 首先需要準備好資料庫:

-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
 `ID` int(11) NOT NULL AUTO_INCREMENT COMMENT '編號',`ROLE_NAME` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '角色名稱',`ROLE_DESC` varchar(60) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '角色描述',PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1,'ROLE_USER','基本角色');
INSERT INTO `sys_role` VALUES (2,'ROLE_ADMIN','超級管理員');
INSERT INTO `sys_role` VALUES (3,'ROLE_PRODUCT','管理產品');
INSERT INTO `sys_role` VALUES (4,'ROLE_ORDER','管理訂單');

-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
 `id` int(11) NOT NULL AUTO_INCREMENT,`username` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '使用者名稱稱',`password` varchar(120) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密碼',`status` int(1) NULL DEFAULT 1 COMMENT '1開啟0關閉',PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1,'xiaoming','$2a$10$CYX9OMv0yO8wR8rE19N2fOaXDJondci5uR68k2eQJm50q8ESsDMlC',1);
INSERT INTO `sys_user` VALUES (2,'xiaoma',1);

-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
 `UID` int(11) NOT NULL COMMENT '使用者編號',`RID` int(11) NOT NULL COMMENT '角色編號',PRIMARY KEY (`UID`,`RID`) USING BTREE,INDEX `FK_Reference_10`(`RID`) USING BTREE,CONSTRAINT `FK_Reference_10` FOREIGN KEY (`RID`) REFERENCES `sys_role` (`ID`) ON DELETE RESTRICT ON UPDATE RESTRICT,CONSTRAINT `FK_Reference_9` FOREIGN KEY (`UID`) REFERENCES `sys_user` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO `sys_user_role` VALUES (1,1);
INSERT INTO `sys_user_role` VALUES (2,1);
INSERT INTO `sys_user_role` VALUES (1,3);
INSERT INTO `sys_user_role` VALUES (2,4);

SET FOREIGN_KEY_CHECKS = 1;

一共三張表,分別是使用者表,角色表,使用者-角色表。使用者是登入用的,密碼其實就是加密過的字串,內容是“ 123 ”;角色是做許可權控制時用的。

然後建立一個空的父工程SpringSecurityDemo,然後在父工程裡面建立一個Module作為認證服務,名叫authentication_server。新增必要的依賴。(內容較佔篇幅,有需要的去原始碼中獲取,原始碼地址見文末)。

專案的配置檔案內容截取了核心的部分貼在下面:

…………
# 配置了公鑰和私鑰的位置
rsa:
 key:
 pubKeyPath: C:\Users\robod\Desktop\auth_key\id_key_rsa.pub
 priKeyPath: C:\Users\robod\Desktop\auth_key\id_key_rsa

最後的公私鑰的標籤是自定義的,並不是Spring提供的標籤,後面我們會在RSA的配置類中去載入這一部分內容。

為了方便起見,我們還可以準備幾個工具類(內容較佔篇幅,有需要的去原始碼中獲取,原始碼地址見文末):

  • JsonUtils:提供了json相關的一些操作;
  • JwtUtils:生成token以及校驗token相關方法;
  • RsaUtils:生成公鑰私鑰檔案,以及從檔案中讀取公鑰私鑰。

我們可以將載荷單獨封裝成一個物件:

@Data
public class Payload<T> {
 private String id;
 private T userInfo;
 private Date expiration;
}

現在再去寫一個測試類,呼叫RsaUtils中的相應方法去生成公鑰和私鑰。那公鑰私鑰生成好了在使用的時候是怎麼獲取的呢?為了解決這個問題,我們需要建立一個RSA的配置類,

@Data
@ConfigurationProperties("rsa.key") //指定配置檔案的key
public class RsaKeyProperties {

 private String pubKeyPath;

 private String priKeyPath;

 private PublicKey publicKey;
 private PrivateKey privateKey;

 @PostConstruct
 public void createKey() throws Exception {
 this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
 this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
 }
}

首先我們使用了@ConfigurationProperties註解去指定公鑰私鑰路徑的key,然後在構造方法中就可以去獲取到公鑰私鑰的內容了。這樣在需要公鑰私鑰的時候就可以直接呼叫這個類了。但是不放入Spring容器中怎麼呼叫這個類,所以在啟動類中新增一個註解:

@EnableConfigurationProperties(RsaKeyProperties.class)

這表示把RSA的配置類放入Spring容器中。

使用者登入

在實現使用者登入的功能之前,先說一下登入的相關內容。關於登入流程我在網上看了篇文章感覺挺好的,貼出來給小夥伴們看看:

vue+springboot前後端分離實現單點登入跨域問題解決方法

首先會進入UsernamePasswordAuthenticationFilter並且設定許可權為null和是否授權為false,然後進入ProviderManager查詢支援UsernamepasswordAuthenticationToken的provider並且呼叫provider.authenticate(authentication);再然後就是UserDetailsService介面的實現類(也就是自己真正具體的業務了),這時候都檢查過了後,就會回撥UsernamePasswordAuthenticationFilter並且設定許可權(具體業務所查出的許可權)和設定授權為true(因為這時候確實所有關卡都檢查過了)。

在上面這段話中,提到了一個UsernamePasswordAuthenticationFilter,我們一開始進入的就是這個過濾器的attemptAuthentication()方法,但是這個方法是從form表單中獲取使用者名稱密碼,和我們的需求不符,所以我們需要重寫這個方法。然後經過一系列的週轉,進入到了UserDetailsService.loadUserByUsername()方法中,所以我們為了實現自己的業務邏輯,需要去實現這個方法。這個方法返回的是一個UserDetails介面物件,如果想返回自定義的物件,可以去實現這個介面。終端使用者驗證成功之後,呼叫的是UsernamePasswordAuthenticationFilter的父類AbstractAuthenticationProcessingFilter.successfulAuthentication()方法,我們也需要去重寫這個方法去實現我們自己的需求。

所以現在就來實現一下上面說的這些東西吧👇

@Data
public class SysUser implements UserDetails {

 private Integer id;
 private String username;
 private String password;
 private Integer status;
 private List<SysRole> roles = new ArrayList<>();	//SysRole封裝了角色資訊,和登入無關,我放在後面講

	//這裡還有幾個UserDetails中的方法,我就不貼程式碼了

}

我們自定義了一個SysUser類去實現UserDetails介面,然後添加了幾個自定義的欄位☝

public interface UserService extends UserDetailsService {
}
//-----------------------------------------------------------
@Service("userService")
public class UserServiceImpl implements UserService {
	…………
 @Override
 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
 SysUser sysUser = userMapper.findByUsername(username);
 return sysUser;
 }
}

在☝這段程式碼中,我們先定義了一個介面UserService去繼承UserDetailsService,然後用UserServiceImpl實現了UserService,就相當於UserServiceImpl實現了UserDetailsService,這樣我們就可以去實現loadUserByUsername()方法,內容很簡單,就是用使用者名稱去資料庫中查出對應的SysUser,然後具體的驗證流程就可以交給其它的過濾器去實現了,我們就不用管了。

前面提到了需要去重寫attemptAuthentication()和successfulAuthentication()方法,那就自定義一個過濾器去繼承UsernamePasswordAuthenticationFilter然後重寫這兩個方法吧👇

public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {

 private AuthenticationManager authenticationManager;
 private RsaKeyProperties rsaKeyProperties;

 public JwtLoginFilter(AuthenticationManager authenticationManager,RsaKeyProperties rsaKeyProperties) {
 this.authenticationManager = authenticationManager;
 this.rsaKeyProperties = rsaKeyProperties;
 }

 //這個方法是用來去嘗試驗證使用者的
 @Override
 public Authentication attemptAuthentication(HttpServletRequest request,HttpServletResponse response) throws AuthenticationException {
 try {
  SysUser user = JSONObject.parseObject(request.getInputStream(),SysUser.class);
  return authenticationManager.authenticate(
   new UsernamePasswordAuthenticationToken(
    user.getUsername(),user.getPassword())
  );
 } catch (Exception e) {
  try {
  response.setContentType("application/json;charset=utf-8");
  response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
  PrintWriter out = response.getWriter();
  Map<String,Object> map = new HashMap<>();
  map.put("code",HttpServletResponse.SC_UNAUTHORIZED);
  map.put("message","賬號或密碼錯誤!");
  out.write(new ObjectMapper().writeValueAsString(map));
  out.flush();
  out.close();
  } catch (Exception e1) {
  e1.printStackTrace();
  }
  throw new RuntimeException(e);
 }
 }

 //成功之後執行的方法
 @Override
 public void successfulAuthentication(HttpServletRequest request,HttpServletResponse response,FilterChain chain,Authentication authResult) throws IOException,ServletException {
 SysUser sysUser = new SysUser();
 sysUser.setUsername(authResult.getName());
 sysUser.setRoles((List<SysRole>) authResult.getAuthorities());
 String token = JwtUtils.generateTokenExpireInMinutes(sysUser,rsaKeyProperties.getPrivateKey(),24*60);
 response.addHeader("Authorization","RobodToken " + token);	//將Token資訊返回給使用者
 try {
  //登入成功時,返回json格式進行提示
  response.setContentType("application/json;charset=utf-8");
  response.setStatus(HttpServletResponse.SC_OK);
  PrintWriter out = response.getWriter();
  Map<String,Object> map = new HashMap<String,Object>(4);
  map.put("code",HttpServletResponse.SC_OK);
  map.put("message","登陸成功!");
  out.write(new ObjectMapper().writeValueAsString(map));
  out.flush();
  out.close();
 } catch (Exception e1) {
  e1.printStackTrace();
 }
 }
}

程式碼的邏輯還是很清晰的,我就不去講解了。

現在重點來了,Spring Security怎麼知道我們要去呼叫自己的UserService和自定義的過濾器呢?所以我們需要配置一下,這也是使用Spring Security的一個核心——>配置類👇

@Configuration
@EnableWebSecurity //這個註解的意思是這個類是Spring Security的配置類
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	…………
 @Bean
 public BCryptPasswordEncoder passwordEncoder() {
 return new BCryptPasswordEncoder();
 }

 //認證使用者的來源
 @Override
 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
 auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
 }

 //配置SpringSecurity相關資訊
 @Override
 public void configure(HttpSecurity http) throws Exception {
 http.csrf().disable() //關閉csrf
  .addFilter(new JwtLoginFilter(super.authenticationManager(),rsaKeyProperties))
  .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); //禁用session
 }

}

在配置類中,配置了認證使用者的來源和添加了自定義的過濾器。這樣就可以實現登入的功能了。

SpringBoot整合Spring Security的詳細教程

可以看到,現在已經成功登入了,但是這個/login是從哪兒來的呢,這個是Spring Security自己提供的,使用者名稱的鍵必須是”username“,密碼的鍵必須是 ”password“,提交方式必須是POST。

總結一下,實現登入的功能需要做哪些操作:

  • 認證使用者實現UserDetails介面
  • 使用者來源的Service實現UserDetailsService介面,實現loadUserByUsername()方法,從資料庫中獲取資料
  • 實現自己的過濾器繼承UsernamePasswordAuthenticationFilter,重寫attemptAuthentication()和successfulAuthentication()方法實現自己的邏輯
  • Spring Security的配置類繼承自WebSecurityConfigurerAdapter,重寫裡面的兩個config()方法
  • 如果使用RSA非對稱加密,就準備好RSA的配置類,然後在啟動類中加入註解將其加入IOC容器中

資源伺服器許可權校驗

在這一小節,我們要實現去訪問資源伺服器中的資源,並進行鑑權的操作。在父工程SpringSecirityDemo中再建立一個模組recourse_server。因為我們現在並不需要從資料庫中獲取使用者資訊。所以就不需要自己去定義Service和Mapper了。也不需要登入的過濾器了。下面這張目錄結構圖是資源服務工程所需要的所有東西。

SpringBoot整合Spring Security的詳細教程

SysRole上一節中用到了但是沒有詳細說明。這個類是用來封裝角色資訊的,做鑑權的時候用的,實現了GrantedAuthority介面:

@Data
public class SysRole implements GrantedAuthority {

 private Integer id;
 private String roleName;
 private String roleDesc;

 /**
 * 如果授予的許可權可以當作一個String的話,就可以返回一個String
 * @return
 */
 @JsonIgnore
 @Override
 public String getAuthority() {
 return roleName;
 }

}

裡面實現了getAuthority方法,直接返回roleName即可。roleName是角色名。

客戶端將Token傳到資源伺服器中,伺服器需要對Token進行校驗並取出其中的載荷資訊。所以我們可以自定義一個過濾器繼承自BasicAuthenticationFilter,然後重寫doFilterInternal()方法,實現自己的邏輯。

public class JwtVerifyFilter extends BasicAuthenticationFilter {
	…………
 @Override
 protected void doFilterInternal(HttpServletRequest request,FilterChain chain)
  throws IOException,ServletException {
 String header = request.getHeader("Authorization");
 //沒有登入
 if (header == null || !header.startsWith("RobodToken ")) {
  chain.doFilter(request,response);
  response.setContentType("application/json;charset=utf-8");
  response.setStatus(HttpServletResponse.SC_FORBIDDEN);
  PrintWriter out = response.getWriter();
  Map<String,HttpServletResponse.SC_FORBIDDEN);
  map.put("message","請登入!");
  out.write(new ObjectMapper().writeValueAsString(map));
  out.flush();
  out.close();
  return;
 }
 //登入之後從token中獲取使用者資訊
 String token = header.replace("RobodToken ","");
 SysUser sysUser = JwtUtils.getInfoFromToken(token,rsaKeyProperties.getPublicKey(),SysUser.class).getUserInfo();
 if (sysUser != null) {
  Authentication authResult = new UsernamePasswordAuthenticationToken
   (sysUser.getUsername(),null,sysUser.getAuthorities());
  SecurityContextHolder.getContext().setAuthentication(authResult);
  chain.doFilter(request,response);
 }
 }
}

在這段程式碼中,先是從請求頭中獲取"Authorization"的值,如果值未null或者不是以我們規定的 “RobodToken ” 開頭就說明不是我們設定的Token,就是沒登入,提示使用者登入。有Token的話就呼叫JwtUtils.getInfoFromToken()去驗證並獲取載荷的內容。驗證通過的話就在Authentication的構造方法中把角色資訊傳進去,然後交給其它過濾器去執行即可。

私鑰應該只儲存在認證伺服器中,所以資源伺服器中只要存公鑰就可以了。

…………
rsa:
 key:
 pubKeyPath: C:\Users\robod\Desktop\auth_key\id_key_rsa.pub
@Data
@ConfigurationProperties("rsa.key") //指定配置檔案的key
public class RsaKeyProperties {

 private String pubKeyPath;

 private PublicKey publicKey;

 @PostConstruct
 public void createKey() throws Exception {
 this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
 }
}

接下來就是Spring Security核心的配置檔案了👇

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true) //開啟許可權控制的註解支援,securedEnabled表示SpringSecurity內部的許可權控制註解開關
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	…………
 //配置SpringSecurity相關資訊
 @Override
 public void configure(HttpSecurity http) throws Exception {
 http.csrf().disable() //關閉csrf
  .authorizeRequests()
  .antMatchers("/**").hasAnyRole("USER") //角色資訊
  .anyRequest() //其它資源
  .authenticated() //表示其它資源認證通過後
  .and()
  .addFilter(new JwtVerifyFilter(super.authenticationManager(),rsaKeyProperties))
  .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); //禁用session
 }

}

這裡面有個註解 @EnableGlobalMethodSecurity(securedEnabled = true),這個註解的意思是開啟許可權控制的註解支援。然後添加了自定義的Token解析過濾器。最後在需要進行許可權控制的方法上添加註解即可👇

@RestController
@RequestMapping("/product")
public class ProductController {

 @Secured("ROLE_PRODUCT")
 @RequestMapping("/findAll")
 public String findAll() {
 return "產品列表查詢成功";
 }

}

好了,這樣findAll方法就需要有"ROLE_PRODUCT"許可權才能訪問。我們來測試一下:

SpringBoot整合Spring Security的詳細教程

登入成功之後,響應頭中有伺服器返回的Token資訊,把它複製下來,然後新增到我們請求的請求頭中。

SpringBoot整合Spring Security的詳細教程

可以看到,現在已經成功訪問到資源了。再來換個沒有許可權的使用者登入測試一下:

SpringBoot整合Spring Security的詳細教程

請求被拒絕了,說明許可權控制功能是沒有問題的。總結一下步驟:

  • 封裝許可權資訊的類實現GrantedAuthority介面,並實現裡面的getAuthority()方法
  • 實現自己的Token校驗過濾器繼承自BasicAuthenticationFilter,並重寫doFilterInternal()方法,實現自己的業務邏輯
  • 編寫Spring Security的配置類繼承WebSecurityConfigurerAdapter,重寫configure()方法新增自定義的過濾器,並新增@EnableGlobalMethodSecurity(securedEnabled = true)註解開啟註解許可權控制的功能
  • 如果使用RSA非對稱加密,就準備好RSA的配置類,然後在啟動類中加入註解將其加入IOC容器中,注意這裡不要只要配置公鑰即可

總結

SpringBoot 整合 Spring Security到這裡就結束了。文章只是簡單的說了一下整合的流程,很多其它的東西都沒有說,比如各個過濾器都有什麼作用等。還有,這裡採用的認證伺服器和資源伺服器分離的方式,要是整合在一起也是可以的。類似的問題還有很多,小夥伴們就自行研究吧。問了讓文章不會太臃腫,很多程式碼都沒有貼出來,有需要的小夥伴點選下面的連結就可以下載了。

點選下載原始碼

到此這篇關於SpringBoot整合Spring Security的文章就介紹到這了,更多相關SpringBoot整合Spring Security內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!