1. 程式人生 > >【手摸手,帶你搭建前後端分離商城系統】03 整合Spring Security token 實現方案,完成主業務登入

【手摸手,帶你搭建前後端分離商城系統】03 整合Spring Security token 實現方案,完成主業務登入

## 【手摸手,帶你搭建前後端分離商城系統】03 整合Spring Security token 實現方案,完成主業務登入 上節裡面,我們已經將基本的前端 `VUE + Element UI` 整合到了一起。並且通過 `axios` 傳送請求到後端API。 解決跨域問題後、成功從後端獲取到資料。 本小結,將和大家一起搭建 `Spring-Security + token` 的方式先完成登入。許可權將在後面講解。 ### 引入 在之前,我們的 API 都是一種`裸奔`的方式。誰都可以訪問,肯定是不安全的。所以我們要引入安全校驗框架。 #### 傳統 session 方案 傳統session 的方式是,通過一個 `攔截器` 攔截所有的請求,若 `cookie` 當中儲存的 `session id` 在服務端過期後、則要求前端重新登入,進而獲取一個新的`session` #### session 與 cookie 區別 因為`HTTP` 是一種無狀態的協議。所以服務端不知道這個 請求是誰發過來的,有好多人訪問伺服器,但是對於伺服器來說,這些人我都不認識。就需要一種東西來給每個人加一個 `ID` 。 **session(會話)** 是一種客戶端發起請求後, 服務端用來識別使用者的東西,可以儲存一些使用者的基本資訊。比如ID什麼的 **cookie** 是一種客戶端瀏覽器用來記錄和儲存資訊的東西。簡單理解,如圖所示。 ![image-20201015110133157](https://file.chaobei.xyz/image-20201015110133157.png_imagess) 當然,預設的cookie 裡面總會包含一串 `JSESSIONID` ![image-20201015110702977](https://file.chaobei.xyz/image-20201015110702977.png_imagess) #### session認證所顯露的問題 **Session**: 每個使用者經過我們的應用認證之後,我們的應用都要在服務端做一次記錄,以方便使用者下次請求的鑑別,通常而言session都是儲存在記憶體中,而隨著認證使用者的增多,服務端的開銷會明顯增大。 **擴充套件性**: 使用者認證之後,服務端做認證記錄,如果認證的記錄被儲存在記憶體中的話,這意味著使用者下次請求還必須要請求在這臺伺服器上,這樣才能拿到授權的資源,這樣在分散式的應用上,相應的限制了負載均衡器的能力。這也意味著限制了應用的擴充套件能力。 **CSRF**: 因為是基於cookie來進行使用者識別的, cookie如果被截獲,使用者就會很容易受到跨站請求偽造的攻擊。 #### JWT https://jwt.io/ 肯定是原有的session認證的方式存在弊端、我們就需要採取一種新的方式來進行驗證。JWT JWT token 由三部分構成: - 頭部(header) - 載荷(playload) - 簽證(signature) > 具體的內容可以參考: https://www.jianshu.com/p/576dbf44b2ae ##### 頭部 header 頭部一般包含加密演算法和型別。例如 ```json { "alg": "HS256",// 加密演算法 "typ": "JWT" // 宣告型別 } ``` ##### 負荷 playload 負載可以理解為存放資訊的位置,例如: ```json { "iss":"mall-pro", // 簽發者 "sub":"admin", // 面向的使用者 "iat": 1602737566890,//簽發時間 "exp": 1602739566890//過期時間,必須大於簽發時間 } ``` ##### 簽證(signature) 簽證一般是頭部和負荷組成內容的,一旦頭部和負荷內容被篡改,驗籤的時候也將無法通過。 ```java //secret為加密演算法的金鑰 String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret) ``` 我們來參考一個生成的 JWT 例項 > 注意,我這裡使用回車、一般三部分都是通過標點進行分割的。 ```json eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ``` ### 實現原理 1. 使用者呼叫登入介面後、驗證使用者名稱和密碼。驗證成功後、頒發給其`token` 2. 前臺獲得 `token` 後,將其存放到本地、每次的請求都將這個`token ` 攜帶到請求頭裡面。 3. 後臺收到請求後、驗證請求頭裡面的 `Authorization` 是否正確、從而判斷是否可以呼叫這個介面。 4. 通過解析 `token` 將賬號資訊存入 `userDetail` 讓其順利呼叫介面資訊、並可以在介面中獲得當前登入人的賬號資訊。 ### Spring Security 安全框架,我們這裡考慮使用 `Spring-Security` ,使用全家桶系列,一般大家都會想到`apache shiro` 等許可權框架、都是可以的。我們這裡介紹如何加入 `Spring-Security` 引入到 `mall-security` 並且新增一個配置檔案。 ```xml org.springframework.boot
spring-boot-starter-security
``` #### 新增一個登陸介面 我們首先從登陸介面開始,一個最基本的 `controller` 接受引數。當然,使用者名稱和密碼肯定是不能為空的,校驗完後交給 `service` ```java @ApiOperation("使用者登入介面") @RequestMapping("login") public CommonResult login(@RequestBody @Valid @ApiParam("使用者名稱密碼") UmsAdminLoginParam param) { UmsAdminTokenBO tokenBO = umsAdminService.umsAdminLogin(param); return CommonResult.success(tokenBO); } ``` 具體的內容無非是:查詢資料庫、是否存在、密碼是否正確。正確就構造一個 `token` 返回給前端。這裡主要說一些重要的點。 #### 斷言與全域性異常處理 >
斷言可以理解為:若當前行不符合判斷條件、則丟擲異常。或者直接使用斷言來丟擲一個異常。比如賬號不存在,直接丟擲一個異常即可。 > > 全域性異常處理:全域性異常處理,在全域性統一攔截異常資訊,並通過`{code=500,message="error message"}` 的方式返回給前端做出提示即可。 Springboot 對於全域性異常的處理、簡直是簡單的不得了~ ```java @RestControllerAdvice @Slf4j public class GlobalExControllerHandler { /** *

全域性異常攔截器,攔截自定義ApiException *

author: MRC * * @param e 自定義異常 * @return xyz.chaobei.common.api.CommonResult * @since 2020/10/20 **/ @ExceptionHandler(value = ApiException.class) public CommonResult exceptionHandler(ApiException e) { log.info("系統異常攔截器:異常資訊:" + e.getMessage()); if (Objects.nonNull(e.getErrorCode())) { return CommonResult.failed(e.getErrorCode()); } return CommonResult.failed(e.getMessage()); } } ``` 直接通過 `return` 的方式,就好像我們在 `controller` 裡面給前端返回`json` 一樣簡單。 斷言則是,判斷某一條件是否成立、如果不成立則丟擲異常的一種更加簡單的方式。就不用每次都寫`throw new xxxException` 簡而言之就是:一種非常優美的方式拋異常(偷懶的) ```java public class Asserts { /** *

斷言丟擲一個異常 *

author: MRC * * @param message 提示語 * @return void * @since 2020/10/15 **/ public static void fail(String message) { throw new ApiException(message); } public static void fail(IErrorCode iErrorCode) { throw new ApiException(iErrorCode); } } ``` #### Spring Security UserDetails Spring UserDetails 作為一個介面、規定了一些需要的引數方法。我們必須要用自己的邏輯實現這個方法。並將`username` `password` 等重要資訊通過其定義的方法進行返回。也是作為一種橋接、將我們的使用者名稱、密碼等資訊交付給 `SpringSecurity` ```java public class UmsAdminUserDetails implements UserDetails { private final UmsAdminModel adminModel; public UmsAdminUserDetails(UmsAdminModel adminModel) { this.adminModel = adminModel; } // 省略,具體請檢視原始碼 } ``` ### JWT 簽發服務 `JWT` 又稱作`JsonWebToken` ,我們需要一個依賴來生成token/登入後需要將這個 `token` 返回給前端,讓前端儲存,而後所有的請求都需要帶上這個 `token` 然後我們服務端就知道是哪個使用者在請求了。 ```xml io.jsonwebtoken

jjwt ``` #### 生成token 我在上面的內容裡面已經介紹了。我們的token 必須要包含: - `sub` 簽發給誰 - `iat` 過期時間戳 - `iss` 誰簽發的 ```java /** * 功能描述: 通過負載生成token * * @Param: claims 負載 * @Return: java.lang.String * @Author: MRC * @Date: 2020/10/21 0:17 */ private String buildToken(Map claims) { return Jwts.builder() .setClaims(claims) .setExpiration(generateExpirationDate()) .signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret()) .compact(); } ``` 通過`builder()` 構造器、設定其負載內容、並且指定 過期時間`setExpiration` ,以及加入祕鑰進行加密 `signWith` #### token 檢驗 token 檢驗包含:當前token 是否有效(能順利從token取出我們的`sub`)、以及檢驗其是否過期 `無效`等。 ```java /** *

從toKen中獲取負載資訊 *

author: MRC * * @param token 獲取的token * @return io.jsonwebtoken.Claims * @since 2020/10/22 **/ private Claims getClaimsFromToken(String token) { Claims claims = null; try { claims = Jwts.parser() .setSigningKey(jwtConfig.getSecret()) .parseClaimsJws(token) .getBody(); } catch (Exception e) { log.info("JWT格式驗證失敗:{}", token); } return claims; } ``` 該方法描述瞭如何從一個`token` 裡面取出我們所需要的 `Claims` 資訊。並且可以從負載裡面取出 `sub` 以及 `exp` 等資訊。我簡要介紹一個。其他的詳細內容請檢視原始碼。 ```java /** *

首先獲取token當中的負載、而後從負載中取出sub *

author: MRC * * @param token 被校驗的token * @return java.lang.String * @since 2020/10/22 **/ public String getUserNameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } ``` **如果你的token被篡改了,那麼驗證的時候肯定會報錯、所以要捕獲一下異常。返回空即可。** ### login service 寫到這裡,我們`login` 控制器的`service` 已經可以全部寫下去了。登入成功,通過`tokenService` 返回一個token ,然後封裝返回給前端即可。 ```java @Override public UmsAdminTokenBO umsAdminLogin(UmsAdminLoginParam param) { // 通過使用者名稱獲取userDetail UserDetails userDetails = this.findUserDetailByUserName(param.getUsername()); // 基本校驗使用者名稱和密碼 if (!passwordEncoder.matches(param.getPassword(), userDetails.getPassword())) { Asserts.fail("使用者名稱密碼錯誤"); } // 這裡暫時不開啟許可權,後面再修改 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null); // 將構建的使用者資訊加入spring security context 上下文 SecurityContextHolder.getContext().setAuthentication(authentication); String token = defaultTokenServer.generateToken(userDetails); return UmsAdminTokenBO.builder().token(token).tokenHeader(jwtConfig.getTokenHeader()).build(); } ``` ### Security Config 接下來。就是配置一個全域性的`Security Config` ```java public class SecurityConfig extends WebSecurityConfigurerAdapter {} ``` 主要還是需要重寫`configure()` 方法。獲取一個 `registry` 例項。將我們的攔截資訊加入到裡面。 - 配置開放的路徑 - 配置需要驗證的路徑。 - 新增一個JWT預設過濾器,在`SpringSecurity` 處理之前,將token 進行校驗後加入到`context` 上下文裡面。 ```java @Override protected void configure(HttpSecurity http) throws Exception { ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = http.authorizeRequests(); // 新增開放的路徑 for (String url : urlsConfig.getUrls()) { registry.antMatchers(url).permitAll(); } // 允許跨域預請求 registry.antMatchers(HttpMethod.OPTIONS).permitAll(); // 所有的請求都需要身份認證 registry.and() .authorizeRequests() .anyRequest().authenticated() // 關閉csrf 不使用session .and() .csrf() .disable() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 自定義許可權拒絕 .and() .exceptionHandling() .accessDeniedHandler(this.customerAccessDenied()) .authenticationEntryPoint(this.customerAuthentication()) // 新增許可權攔截器和JWT攔截器,注意,是before .and() .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); } ``` ### 自定義過濾器 ```java @Slf4j @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private JwtConfig jwtConfig; @Autowired private DefaultTokenServer defaultTokenServer; @Autowired private UserDetailsService userDetailsService; /** *

token 過濾器邏輯 * 1、token 必須存在 * 2、toKen 必須正確,未過期。 * 3、若上下文不存在。則往上下文放一個userDetail *

author: MRC * * @param request 請求 * @param response 響應 * @param filterChain 過濾器 * @return void * @since 2020/10/22 **/ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = request.getHeader(jwtConfig.getTokenHeader()); log.info("doFilterInternal request url={}", request.getRequestURL()); log.info("doFilterInternal request token={}", token); // 請求攜帶token/則檢驗這個token是否正確和是否過期 if (!StringUtils.isEmpty(token)) { // 攜帶的使用者名稱資訊 String username = defaultTokenServer.getUserNameFromToken(token); log.info("request token username={}", username); if (StringUtils.isEmpty(username)) { filterChain.doFilter(request, response); } UserDetails userDetails = userDetailsService.loadUserByUsername(username); //校驗token是否有效 if (defaultTokenServer.isTokenExpired(token)) { filterChain.doFilter(request, response); } //檢查當前上下文是否存在使用者資訊,若沒有則新增 if (SecurityContextHolder.getContext().getAuthentication() == null) { log.info("doFilterInternal getContext = null"); // 將使用者資訊新增到上下文。說明這個request 是通過的。 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); log.info("doFilterInternal user:{}", username); SecurityContextHolder.getContext().setAuthentication(authentication); } } // 通過攔截器 filterChain.doFilter(request, response); } } ``` 其實我們這裡去掉`session` 以後,我們的客戶端對於前端的請求標識、只能通過攜帶token的方式。 然後我們每一個請求首先會進入`JwtAuthenticationTokenFilter` 也就是我們上面寫的這個。 檢查當前請求有沒有攜帶`token` 要是帶了 `token` 那就檢查它,檢查成功就從資料庫查出來這個人。把這個人注入到我們的`SpringSecurity Context` 裡面。 `SpringSecurity` 的其他過濾器看到上下文有東西在,就放行~說明是登入後的。 要是沒帶、或者驗證錯誤~。那上下文也就沒有這個使用者的資訊了。所以這個請求只能返回`403` ### 密碼問題 這裡使用的是:`PasswordEncoder` 介面實現類下的 `BCryptPasswordEncoder` ,當然,你肯定要在使用之前要用`@Bean` ```java @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } ``` 未來使用的時候、直接注入一個就行了。 - `matches` 校驗 - `encode` 加密 至於是怎麼加密的。當然還得研究一下~ ### 實際測試 在未登入之前,我們訪問一個介面~ ```json { "code": 401, "data": "Full authentication is required to access this resource", "message": "暫未登入或token已經過期" } ``` 首先使用使用者名稱和密碼進行登入,我們加入一條資料。`admin,123456` ```sql INSERT INTO `mall-pro`.`ums_admin`(`id`, `username`, `password`, `icon`, `lock`, `email`, `nick_name`, `note`, `create_time`, `login_time`, `status`) VALUES (1, 'admin', '$2a$10$08arRlZRspTqMBK1N8NqW.9CQq7KWffa47MGelgJMuPK/uXtKX3O6', '#e', 1, '[email protected]', '管理員', '測試', '2020-10-22 16:14:33', '2020-10-22 16:14:36', 1); ``` 請求登入介面`/auth/login` ,驗證使用者名稱和密碼後、返回資訊如下: ```json { "code": 200, "message": "操作成功", "data": { "tokenHeader": "Authorization", "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlzcyI6Im1hbGwtcHJvIiwiZXhwIjoxNjAzNTAzNjU3LCJpYXQiOjE2MDM0MTcyNTc4MzJ9.5bX2gajbRebS9MyII3OlBKD4xc5uTgelvFprT8SHvBq_MnFa--CSn3ntkGteITt5lLRbAyxyzC8u8KZ1ZCdYjg" } } ``` 將登入後,將指定頭和token帶入請求頭進行請求,成功請求到資料~ ### 小結 已經好久沒更新這一篇文章了。希望我的讀者你們不要怪我,實在是太忙了。白天要上班,偶爾摸魚寫一寫,程式碼除錯完、而後我再整理這篇文章。現在已經是凌晨00:26 。加油吧~ 我努力更新完這個系列。 #### 原始碼地址 https://gitee.com/mrc1999/mall-pro #### 歡迎關注 ![](https://file.chaobei.xyz/blogs/banner_1591192617