javascript
Spring Security框架
Spring Security框架
關于用戶身份認證與授權
Spring Security是用于解決認證與授權的框架。
添加依賴
<!-- Spring Boot Security:處理認證與授權 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>啟動項目,在啟動的日志中,可以看到類似以下內容:
Using generated security password: 2abb9119-b5bb-4de9-8584-9f893e4a5a92Spring Security有默認登錄的賬號和密碼(以上提示的值),密碼是隨機的,每次啟動項目都會不同。
Spring Security默認要求所有的請求都是必須先登錄才允許的訪問,可以使用默認的用戶名user和自動生成的隨機密碼來登錄。在測試登錄時,在瀏覽器訪問當前主機的任意網址都可以(包括不存在的資源),會自動跳轉到登錄頁(是由Spring Security提供的,默認的URL是:http://localhost:8080/login),當登錄成功后,會自動跳轉到此前訪問的URL(跳轉登錄頁之前的URL),另外,還可以通過 http://localhost:8080/logout 退出登錄。
Spring Security的依賴項中包括了Bcrypt算法的工具類,Bcrypt是一款非常優秀的密碼加密工具,適用于對需要存儲下來的密碼進行加密處理。
測試例 :
public class BcryptPasswordEncoderTests {private BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();@Testpublic void testEncode() {// 原文相同的情況,每次加密得到的密文都不同for (int i = 0; i < 10; i++) {String rawPassword = "123456";String encodedPassword = passwordEncoder.encode(rawPassword);System.out.println("rawPassword = " + rawPassword);System.out.println("encodedPassword = " + encodedPassword);}// rawPassword = 123456// encodedPassword = $2a$10$HWuJ9WgPazrwg9.isaae4u7XdP7ohH7LetDwdlTWuPC4ZAvG.Uc7W}@Testpublic void testMatches() {String rawPassword = "123456";String encodedPassword = "$2a$10$hI4wweFOGJ7FMduSmCjNBexbKFOjYMWl8hkug0n0k1LNR5vEyhhMW";boolean matchResult = passwordEncoder.matches(rawPassword, encodedPassword);System.out.println("match result : " + matchResult);}}Spring Security的認證機制中包含:當客戶端提交登錄后,會自動調用UserDetailsService接口(Spring Security定義的)的實現類對象中的UserDetails loadUserByUsername(String username)方法(根據用戶名加載用戶數據),將得到UserDetails類型的對象,此對象中應該至少包括此用戶名對應的密碼、權限等信息,接下來,Spring Security會自動完成密碼的對比,并確定此次客戶端提交的信息是否允許登錄!類似于:
// Spring Security的行為 UserDetails userDetails = userDetailsService.loadUserByUsername("chengheng"); // Spring Security將從userDetails中獲取密碼,用于驗證客戶端提交的密碼,判斷是否匹配所以,要實現Spring Security通過數據庫的數據來驗證用戶名與密碼(而不是采用默認的user用戶名和隨機的密碼),則在passport包下創建security.UserDetailsServiceImpl類,實現UserDetailsService接口,并重寫接口中的抽象方法:
@Service public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate AdminMapper adminMapper;@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {System.out.println("根據用戶名查詢嘗試登錄的管理員信息,用戶名=" + s);/*通過用戶名從緩存中獲取用戶信息*/AdminLoginVO admin = adminMapper.getLoginInfoByUsername(s);System.out.println("通過持久層進行查詢,結果=" + admin);if (admin == null) {System.out.println("根據用戶名沒有查詢到有效的管理員數據,將拋出異常");throw new BadCredentialsException("登錄失敗,用戶名不存在!");}System.out.println("查詢到匹配的管理員數據,需要將此數據轉換為UserDetails并返回");UserDetails userDetails = User.builder().username(admin.getUsername()).password(admin.getPassword())/* accountExpired 賬號是否過期 */.accountExpired(false)/* accountLocked 賬號是否鎖定 */.accountLocked(false)/* disabled 賬號是否禁用 */.disabled(admin.getIsEnable() != 1)/* credentialsExpired 證書是否過期 */.credentialsExpired(false)/* authorities 用戶權限 */.authorities(admin.getPermissions().toArray(new String[] {})).build();System.out.println("轉換得到UserDetails=" + userDetails);return userDetails;}}完成后,再配置密碼加密器即可:
@Configuration public class SecurityConfiguration {@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}}重啟項目,可以發現在啟動過程中不再生成隨機的密碼值,在瀏覽器上訪問此項目的任何URL,進入登錄頁,即可使用數據庫中的管理員數據進行登錄。
在Spring Security,默認使用Session機制存儲成功登錄的用戶信息(因為HTTP協議是無狀態協議,并不保存客戶端的任何信息,所以,同一個客戶端的多次訪問,對于服務器而言,等效于多個不同的客戶端各訪問一次,為了保存用戶信息,使得服務器端能夠識別客戶端的身份,必須采取某種機制),當下,更推薦使用Token或相關技術(例如JWT)來解決識別用戶身份的問題。
JWT
JWT = JSON Web Token,它是通過JSON格式組織必要的數據,將數據記錄在票據(Token)上,并且,結合一定的算法,使得這些數據會被加密,然后在網絡上傳輸,服務器端收到此數據后,會先對此數據進行解密,從而得到票據上記錄的數據(JSON數據),從而識別用戶的身份,或者處理相關的數據。
其實,在客戶端第1次訪問服務器端時,是“空著手”訪問的,不會攜帶任何票據數據,當服務器進行響應時,會將JWT響應到客戶端,客戶端從第2次訪問開始,每次都應該攜帶JWT發起請求,則服務器都會收到請求中的JWT并進行處理。
要使用JWT,需要添加相關的依賴項,可以實現生成JWT、解析JWT的框架較多,目前,主流的JWT框架可以是jjwt:
JWT的組成部分:Header(頭),Payload(載荷),Signature(簽名)
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt --> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version> </dependency>則在根項目中管理以上依賴,并在csmall-passport中添加以上依賴。
測試使用JWT:
public class JwtTests {// 密鑰String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";@Testpublic void testGenerateJwt() {// ClaimsMap<String, Object> claims = new HashMap<>();claims.put("id", 9527);claims.put("name", "家丁");// JWT的組成部分:Header(頭),Payload(載荷),Signature(簽名)String jwt = Jwts.builder()// Header:指定算法與當前數據類型// 格式為: { "alg": 算法, "typ": "jwt" }.setHeaderParam(Header.CONTENT_TYPE, "HS256").setHeaderParam(Header.TYPE, Header.JWT_TYPE)// Payload:通常包含Claims(自定義數據)和過期時間.setClaims(claims).setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))// Signature:由算法和密鑰(secret key)這2部分組成.signWith(SignatureAlgorithm.HS256, secretKey)// 打包生成.compact();// eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NzMwMn0.qBBHearv8iHPNjtDGtO2ci_-KAL4CALHnwzaG_ljsQgSystem.out.println(jwt);}@Testpublic void testParseJwt() {String jwt = "eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NzMwMn0.qBBHearv8iHPNjtDGtO2ci_-KAL4CALHnwzaG_ljsQg";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Object id = claims.get("id");Object name = claims.get("name");System.out.println("id=" + id);System.out.println("name=" + name);}}當JWT數據過期時,異常信息例如:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-06-16T15:47:57Z. Current time: 2022-06-16T16:08:32Z, a difference of 1235869 milliseconds. Allowed clock skew: 0 milliseconds.當JWT解析失敗(數據有誤)時,異常信息例如:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"cty"�"HS256","typ":"JWT","alg":"HS256"}當生成JWT和解析JWT的密鑰不一致時,異常信息例如:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.要在Spring Security中使用JWT,至少需要:
-
不能讓Spring Security按照原有模式來處理登錄(原有模式中,登錄成功后,自動裝用戶信息存儲到Session中,且跳轉頁面),需要
-
需要自動裝配AuthenticationManager對象
- 使得SecurityConfiguration配置類繼承自WebSecurityConfigurerAdapter類,重寫其中的xx方法,在此方法中直接調用父級方法即可,并在此方法上添加@Bean注解
-
創建AdminLoginDTO類,此類中應該包含用戶登錄時需要提交的用戶名、密碼
-
創建IAdminService接口
-
在IAdminService接口中添加登錄的抽象方法
String login(AdminLoginDTO adminLoginDTO); -
創建AdminServiceImpl類,實現以上接口
- 在實現過程中,調用AuthenticationManager實現認證,當認證成功后,生成JWT并返回
-
創建AdminController類,在類中處理登錄請求
-
在SecurityConfiguration中配置Spring Security,對特定的請求進行放行(默認所有請求都必須先登錄)
-
配置代碼
@Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}@Overrideprotected void configure(HttpSecurity http) throws Exception {// 禁用防跨域攻擊http.csrf().disable();// URL白名單String[] urls = {"/admins/login"};// 配置各請求路徑的認證與授權http.authorizeRequests() // 請求需要授權才可以訪問.antMatchers(urls) // 匹配一些路徑.permitAll() // 允許直接訪問(不需要經過認證和授權).anyRequest() // 匹配除了以上配置的其它請求.authenticated(); // 都需要認證} } @Data public class AdminLoginDTO implements Serializable {private String username;private String password; } public interface IAdminService {String login(AdminLoginDTO adminLoginDTO);} @Service public class AdminServiceImpl implements IAdminService {@Autowiredprivate AuthenticationManager authenticationManager;@Overridepublic String login(AdminLoginDTO adminLoginDTO) {// 準備被認證數據Authentication authentication= new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());// 調用AuthenticationManager驗證用戶名與密碼// 執行認證,如果此過程沒有拋出異常,則表示認證通過,如果認證信息有誤,將拋出異常authenticationManager.authenticate(authentication);// 如果程序可以執行到此處,則表示登錄成功// 生成此用戶數據的JWTString jwt = "This is a JWT."; // 臨時return jwt;} } @RestController @RequestMapping(value = "/admins", produces = "application/json; charset=utf-8") public class AdminController {@Autowiredprivate IAdminService adminService;// http://localhost:8080/admins/login?username=root&password=123456@RequestMapping("/login")public String login(AdminLoginDTO adminLoginDTO) {String jwt = adminService.login(adminLoginDTO);return jwt;} }測試
完成以上配置后,可以通過 http://localhost:8080/admins/login?username=root&password=123456 這類URL測試登錄,使用數據庫中的用戶名和密碼進行嘗試。
當通過以上URL進行訪問時,其內部過程大概是:
- Spring Security的相關配置會進行URL的檢查,來判斷是否允許訪問此路徑
- 所以,需要在SecurityConfiguration中將以上路徑設置為白名單
- 如果沒有將以上路徑配置到白名單,將直接跳轉到登錄頁,因為默認所有請求都必須先登錄
- 由AdminController接收到請求后,調用了IAdminService接口的實現類對象來處理登錄
- IAdminService接口的實現是AdminServiceImpl
- 在AdminServiceImpl中,調用了AuthenticationManager處理登錄的認證
- AuthenticationManager對象調用authenticate()方法進行登錄處理
- 內部實現中,會自動調用UserDetailsService實現對象的loadUserByUsername()方法以獲取用戶信息,并自動完成后續的認證處理(例如驗證密碼是否正確),所以,在步驟中,具體執行的是UserDetailsServiceImpl類中重寫的方法,此方法返回了用戶信息,Spring Security自動驗證,如果失敗(例如賬號已禁用、密碼錯誤等),會拋出異常
- 以上調用的authenticate()方法如果未拋出異常,可視為認證成功,即登錄成功
- 當登錄成功時,應該返回此用戶的JWT數據
- AuthenticationManager對象調用authenticate()方法進行登錄處理
Spring Security + JWT
此前,在處理登錄的業務中,當視為登錄成功時,返回的字符串并不是JWT數據,則應該將此數據改為必要的JWT數據。
@Service public class AdminServiceImpl implements IAdminService {// ===== 原有其它代碼 =====/*** JWT數據的密鑰*/private String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds";@Overridepublic String login(AdminLoginDTO adminLoginDTO) {// ===== 原有其它代碼 =====// 如果程序可以執行到此處,則表示登錄成功// 生成此用戶數據的JWT// ClaimsUser user = (User) authenticate.getPrincipal();System.out.println("從認證結果中獲取Principal=" + user.getClass().getName());Map<String, Object> claims = new HashMap<>();claims.put("username", user.getUsername());claims.put("permissions", user.getAuthorities());System.out.println("即將向JWT中寫入數據=" + claims);// JWT的組成部分:Header(頭),Payload(載荷),Signature(簽名)String jwt = Jwts.builder()// Header:指定算法與當前數據類型// 格式為: { "alg": 算法, "typ": "jwt" }.setHeaderParam(Header.CONTENT_TYPE, "HS256").setHeaderParam(Header.TYPE, Header.JWT_TYPE)// Payload:通常包含Claims(自定義數據)和過期時間.setClaims(claims).setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))// Signature:由算法和密鑰(secret key)這2部分組成.signWith(SignatureAlgorithm.HS256, secretKey)// 打包生成.compact();// 返回JWT數據return jwt;}}在控制器中,應該響應JSON格式的數據,所以,需要添加依賴包含JsonResult類的依賴。將控制器中處理請求的方法的返回值類型改為JsonResult<String>,并調整返回值:
// http://localhost:8080/admins/login?username=root&password=123456 @RequestMapping("/login") public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {String jwt = adminService.login(adminLoginDTO);return JsonResult.ok(jwt); }此時,重啟項目,在瀏覽器中,使用正確的用戶名和密碼訪問,響應的結果例如:
{"state":20000,"message":null,"data":"eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJwZXJtaXNzaW9ucyI6W3siYXV0aG9yaXR5IjoiL2Ftcy9hZG1pbi9kZWxldGUifSx7ImF1dGhvcml0eSI6Ii9hbXMvYWRtaW4vcmVhZCJ9LHsiYXV0aG9yaXR5IjoiL2Ftcy9hZG1pbi91cGRhdGUifSx7ImF1dGhvcml0eSI6Ii9wbXMvcHJvZHVjdC9kZWxldGUifSx7ImF1dGhvcml0eSI6Ii9wbXMvcHJvZHVjdC9yZWFkIn0seyJhdXRob3JpdHkiOiIvcG1zL3Byb2R1Y3QvdXBkYXRlIn1dLCJleHAiOjE2NTU0MzQwMzcsInVzZXJuYW1lIjoicm9vdCJ9.8ZIfpxxjJlwNo-E3JhXwH4sZR0J5-FU-HAOMu1Tg-44" }注意:以上只是訪問/admins/login時會執行所編寫的流程(發送用戶名和密碼,得到含JWT的結果),并不代表真正意義的實現了“登錄”!
登錄的流程應該是:
客戶端提交用戶名和密碼到服務器端 >>> 服務器端認證成功后響應JWT >>> 客戶端在后續的請求中都攜帶JWT >>> 服務器端驗證JWT來決定是否允許訪問。
為了便于體現“客戶端在后續的請求中都攜帶JWT”的操作,可以在項目中添加使用Knife4j。
當使用Knife4j時,需要在白名單中添加相關的放行資源路徑,否則,Knife4j的頁面將無法使用:
@Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter {// ===== 原有其它代碼 =====@Overrideprotected void configure(HttpSecurity http) throws Exception {// ===== 原有其它代碼 =====// URL白名單String[] urls = {"/admins/login","/doc.html", // 從本行開始,以下是新增"/**/*.js","/**/*.css","/swagger-resources","/v2/api-docs","/favicon.ico"};// ===== 原有其它代碼 =====} }在后續的訪問中,必須在請求中攜帶JWT數據, 服務器端才可以嘗試解析此JWT數據,從而判斷用戶是否已登錄或允許訪問。
為了便于測試,在控制器中添加一個測試訪問的請求配置:
// 以下是測試訪問的請求 @GetMapping("/hello") public String sayHello() {return "hello~~~"; }由于以上 /admins/hello 路徑并不在白名單中,如果直接訪問,會出現403錯誤。
在規范的使用方式中,JWT數據必須攜帶在請求頭(Request Header)的Authorization屬性中。
按照以上規范,則服務器端在每次接收到請求后,首先,就應該先判斷請求頭中是否存在Authorization、Authorization的值是否有效等操作,通常,是通過過濾器來實現以上檢查的。
在passport的根包下的security包下創建JwtAuthenticationFilter過濾器類,需要繼承自OncePerRequestFilter類:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {System.out.println("JwtAuthenticationFilter.doFilterInternal()");} }所有的過濾器都必須注冊后才可以使用,且同一個項目中允許存在多個過濾器,形成過濾器鏈,以上用于驗證JWT的過濾器應該運行在Spring Security處理登錄的過濾器之前,需要在自定義的SecurityConfiguration中的configure()方法中將以上自定義的過濾器注冊在Spring Security的相關過濾器之前:
@Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter {// 新增@Autowiredprivate JwtAuthenticationFilter jwtAuthenticationFilter;// ===== 原有其它代碼 =====@Overrideprotected void configure(HttpSecurity http) throws Exception {// ===== 原有其它代碼 =====// 注冊處理JWT的過濾器// 此過濾器必須在Spring Security處理登錄的過濾器之前http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);} }完成后,重啟項目,無論對哪個路徑發出請求,在控制臺都可以看出輸出了過濾器中的輸出語句內容,并且,在瀏覽器將顯示一片空白。
關于JwtAuthenticationFilter,它需要實現:
- 嘗試從請求頭中獲取JWT數據
- 如果無JWT數據,應該直接放行,Spring Security還會進行后續的處理,例如白名單的請求將允許訪問,其它請求將禁止訪問
- 如果存在JWT數據,應該嘗試解析
- 如果解析失敗,應該視為錯誤,可以要求客戶端重新登錄,客戶端就可以得到新的、正確的JWT,客戶端在下一次提交請求時,使用新的JWT即可正確訪問
- 將解析得到的數據封裝到Authentication對象中
- Spring Security的上下文中存儲的數據類型是Authentication類型
- 為避免存入1次后,Spring Security的上下文中始終存在Authentication,在此過濾器執行的第一時間,應該清除上下文中的數據
要使用Spring Security實現授權訪問,首先,必須保證用戶登錄后,在Spring Security上下文中存在權限相關信息(目前,此項已完成,在JwtAuthenticationFilter的最后,已經存入權限信息)。
然后,需要在配置類上使用@EnableGlobalMethodSecurity注解開啟“通過注解配置權限”的功能,所以,在SecrutiyConfiguration類上添加:
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) // 新增 public class SecurityConfiguration extends WebSecurityConfigurerAdapter {// ===== 類中原有代碼 ===== }最后,在任何你需要設置權限的處理請求的方法上,通過@PreAuthorize注解來配置要求某種權限,例如:
@GetMapping("/hello") @PreAuthorize("hasAuthority('/ams/admin/read')") // 新增 public String sayHello() {return "hello~~~"; }完成后,重啟項目,使用具有/ams/admin/read權限的用戶可以直接訪問,不具有此權限的用戶則不能訪問
總結
以上是生活随笔為你收集整理的Spring Security框架的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: mamp nginx php7,MAMP
- 下一篇: Spring4 学习教程