【手摸手,帶你搭建前後端分離商城系統】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 是一種客戶端瀏覽器用來記錄和儲存資訊的東西。簡單理解,如圖所示。
當然,預設的cookie 裡面總會包含一串 JSESSIONID
session認證所顯露的問題
Session: 每個使用者經過我們的應用認證之後,我們的應用都要在服務端做一次記錄,以方便使用者下次請求的鑑別,通常而言session都是儲存在記憶體中,而隨著認證使用者的增多,服務端的開銷會明顯增大。
擴充套件性: 使用者認證之後,服務端做認證記錄,如果認證的記錄被儲存在記憶體中的話,這意味著使用者下次請求還必須要請求在這臺伺服器上,這樣才能拿到授權的資源,這樣在分散式的應用上,相應的限制了負載均衡器的能力。這也意味著限制了應用的擴充套件能力。
CSRF: 因為是基於cookie來進行使用者識別的, cookie如果被截獲,使用者就會很容易受到跨站請求偽造的攻擊。
JWT
肯定是原有的session認證的方式存在弊端、我們就需要採取一種新的方式來進行驗證。JWT
JWT token 由三部分構成:
- 頭部(header)
- 載荷(playload)
- 簽證(signature)
具體的內容可以參考: https://www.jianshu.com/p/576dbf44b2ae
頭部 header
頭部一般包含加密演算法和型別。例如
{
"alg": "HS256",// 加密演算法
"typ": "JWT" // 宣告型別
}
負荷 playload
負載可以理解為存放資訊的位置,例如:
{
"iss":"mall-pro", // 簽發者
"sub":"admin", // 面向的使用者
"iat": 1602737566890,//簽發時間
"exp": 1602739566890//過期時間,必須大於簽發時間
}
簽證(signature)
簽證一般是頭部和負荷組成內容的,一旦頭部和負荷內容被篡改,驗籤的時候也將無法通過。
//secret為加密演算法的金鑰
String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
我們來參考一個生成的 JWT 例項
注意,我這裡使用回車、一般三部分都是通過標點進行分割的。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
實現原理
- 使用者呼叫登入介面後、驗證使用者名稱和密碼。驗證成功後、頒發給其
token
- 前臺獲得
token
後,將其存放到本地、每次的請求都將這個token
攜帶到請求頭裡面。 - 後臺收到請求後、驗證請求頭裡面的
Authorization
是否正確、從而判斷是否可以呼叫這個介面。 - 通過解析
token
將賬號資訊存入userDetail
讓其順利呼叫介面資訊、並可以在介面中獲得當前登入人的賬號資訊。
Spring Security
安全框架,我們這裡考慮使用 Spring-Security
,使用全家桶系列,一般大家都會想到apache shiro
等許可權框架、都是可以的。我們這裡介紹如何加入 Spring-Security
引入到 mall-security
並且新增一個配置檔案。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
新增一個登陸介面
我們首先從登陸介面開始,一個最基本的 controller
接受引數。當然,使用者名稱和密碼肯定是不能為空的,校驗完後交給 service
@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 對於全域性異常的處理、簡直是簡單的不得了~
@RestControllerAdvice
@Slf4j
public class GlobalExControllerHandler {
/**
* <p>全域性異常攔截器,攔截自定義ApiException
* <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
*
* @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
簡而言之就是:一種非常優美的方式拋異常(偷懶的)
public class Asserts {
/**
* <p>斷言丟擲一個異常
* <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
*
* @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
public class UmsAdminUserDetails implements UserDetails {
private final UmsAdminModel adminModel;
public UmsAdminUserDetails(UmsAdminModel adminModel) {
this.adminModel = adminModel;
}
// 省略,具體請檢視原始碼
}
JWT 簽發服務
JWT
又稱作JsonWebToken
,我們需要一個依賴來生成token/登入後需要將這個 token
返回給前端,讓前端儲存,而後所有的請求都需要帶上這個 token
然後我們服務端就知道是哪個使用者在請求了。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
生成token
我在上面的內容裡面已經介紹了。我們的token 必須要包含:
sub
簽發給誰iat
過期時間戳iss
誰簽發的
/**
* 功能描述: 通過負載生成token
*
* @Param: claims 負載
* @Return: java.lang.String
* @Author: MRC
* @Date: 2020/10/21 0:17
*/
private String buildToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret())
.compact();
}
通過builder()
構造器、設定其負載內容、並且指定 過期時間setExpiration
,以及加入祕鑰進行加密 signWith
token 檢驗
token 檢驗包含:當前token 是否有效(能順利從token取出我們的sub
)、以及檢驗其是否過期 無效
等。
/**
* <p>從toKen中獲取負載資訊
* <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
*
* @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
等資訊。我簡要介紹一個。其他的詳細內容請檢視原始碼。
/**
* <p>首先獲取token當中的負載、而後從負載中取出sub
* <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
*
* @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 ,然後封裝返回給前端即可。
@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
public class SecurityConfig extends WebSecurityConfigurerAdapter {}
主要還是需要重寫configure()
方法。獲取一個 registry
例項。將我們的攔截資訊加入到裡面。
- 配置開放的路徑
- 配置需要驗證的路徑。
- 新增一個JWT預設過濾器,在
SpringSecurity
處理之前,將token 進行校驗後加入到context
上下文裡面。
@Override
protected void configure(HttpSecurity http) throws Exception {
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.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);
}
自定義過濾器
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtConfig jwtConfig;
@Autowired
private DefaultTokenServer defaultTokenServer;
@Autowired
private UserDetailsService userDetailsService;
/**
* <p> token 過濾器邏輯
* 1、token 必須存在
* 2、toKen 必須正確,未過期。
* 3、若上下文不存在。則往上下文放一個userDetail
* <p>author: <a href='mailto:maruichao52@gmail.com'>MRC</a>
*
* @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
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
未來使用的時候、直接注入一個就行了。
matches
校驗encode
加密
至於是怎麼加密的。當然還得研究一下~
實際測試
在未登入之前,我們訪問一個介面~
{
"code": 401,
"data": "Full authentication is required to access this resource",
"message": "暫未登入或token已經過期"
}
首先使用使用者名稱和密碼進行登入,我們加入一條資料。admin,123456
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, 'maruichao@gmail.com', '管理員', '測試', '2020-10-22 16:14:33', '2020-10-22 16:14:36', 1);
請求登入介面/auth/login
,驗證使用者名稱和密碼後、返回資訊如下:
{
"code": 200,
"message": "操作成功",
"data": {
"tokenHeader": "Authorization",
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlzcyI6Im1hbGwtcHJvIiwiZXhwIjoxNjAzNTAzNjU3LCJpYXQiOjE2MDM0MTcyNTc4MzJ9.5bX2gajbRebS9MyII3OlBKD4xc5uTgelvFprT8SHvBq_MnFa--CSn3ntkGteITt5lLRbAyxyzC8u8KZ1ZCdYjg"
}
}
將登入後,將指定頭和token帶入請求頭進行請求,成功請求到資料~
小結
已經好久沒更新這一篇文章了。希望我的讀者你們不要怪我,實在是太忙了。白天要上班,偶爾摸魚寫一寫,程式碼除錯完、而後我再整理這篇文章。現在已經是凌晨00:26 。加油吧~ 我努力更新完這個系列。
原始碼地址
https://gitee.com/mrc1999/mall-pro