1. 前言
之前我講解了如何編寫一個自己的 Jwt 生成器以及如何在用戶認證通過後返回 Json Web Token 。今天我們來看看如何在請求中使用 Jwt 訪問鑑權。DEMO 獲取方法在文末。
2. 常用的 Http 認證方式
我們要在 Http 請求中使用 Jwt 我們就必須瞭解 常見的 Http 認證方式。
2.1 HTTP Basic Authentication
HTTP Basic Authentication 又叫基礎認證,它簡單地使用 Base64 算法對用戶名、密碼進行加密,並將加密後的信息放在請求頭 Header 中,本質上還是明文傳輸用戶名、密碼,並不安全,所以最好在
Https 環境下使用。其認證流程如下:客戶端發起 GET 請求 服務端響應返回 401 Unauthorized, www-Authenticate 指定認證算法,realm 指定安全域。然後客戶端一般會彈窗提示輸入用戶名稱和密碼,輸入用戶名密碼後放入 Header 再次請求,服務端認證成功後以 200 狀態碼響應客戶端。
2.2 HTTP Digest Authentication
為彌補 BASIC 認證存在的弱點就有了 HTTP Digest Authentication 。它又叫摘要認證。它使用隨機數加上 MD5 算法來對用戶名、密碼進行摘要編碼,流程類似 Http Basic Authentication ,但是更加複雜一些:
步驟1:跟基礎認證一樣,只不過返回帶 WWW-Authenticate 首部字段的響應。該字段內包含質問響應方式認證所需要的臨時諮詢碼(隨機數,nonce)。 首部字段 WWW-Authenticate 內必須包含 realm 和 nonce 這兩個字段的信息。客戶端就是依靠向服務器回送這兩個值進行認證的。nonce 是一種每次隨返回的 401 響應生成的任意隨機字符串。該字符串通常推薦由 Base64 編碼的十六進制數的組成形式,但實際內容依賴服務器的具體實現
步驟2:接收到 401 狀態碼的客戶端,返回的響應中包含 DIGEST 認證必須的首部字段 Authorization 信息。首部字段 Authorization 內必須包含 username、realm、nonce、uri 和 response 的字段信息,其中,realm 和 nonce 就是之前從服務器接收到的響應中的字段。
步驟3:接收到包含首部字段 Authorization 請求的服務器,會確認認證信息的正確性。認證通過後則會返回包含 Request-URI 資源的響應。
並且這時會在首部字段
Authorization-Info 寫入一些認證成功的相關信息。2.3 SSL 客戶端認證
SSL 客戶端認證就是通常我們說的 HTTPS 。安全級別較高,但需要承擔 CA 證書費用。SSL 認證過程中涉及到一些重要的概念,數字證書機構的公鑰、證書的私鑰和公鑰、非對稱算法(配合證書的私鑰和公鑰使用)、對稱密鑰、對稱算法(配合對稱密鑰使用)。相對複雜一些這裡不過多講述。
2.4 Form 表單認證
Form 表單的認證方式並不是HTTP規範。所以實現方式也呈現多樣化,其實我們平常的掃碼登錄,手機驗證碼登錄都屬於表單登錄的範疇。表單認證一般都會配合 Cookie,Session 的使用,現在很多 Web 站點都使用此認證方式。用戶在登錄頁中填寫用戶名和密碼,服務端認證通過後會將 sessionId 返回給瀏覽器端,瀏覽器會保存 sessionId 到瀏覽器的
Cookie 中。因為 HTTP 是無狀態的,所以瀏覽器使用 Cookie 來保存 sessionId。下次客戶端會在發送的請求中會攜帶 sessionId 值,服務端發現 sessionId 存在並以此為索引獲取用戶存在服務端的認證信息進行認證操作。認證過則會提供資源訪問。我們在https://www.felord.cn/spring-security-login-jwt.html 一文其實也是通過 Form 提交來獲取 Jwt 其實 Jwt 跟 sessionId 同樣的作用,只不過 Jwt 天然攜帶了用戶的一些信息,而 sessionId 需要去進一步獲取用戶信息。
2.5 Json Web Token 的認證方式 Bearer Authentication
我們通過表單認證獲取 Json Web Token ,那麼如何使用它呢? 通常我們會把 Jwt 作為令牌使用 Bearer Authentication 方式使用。Bearer Authentication 是一種基於令牌的 HTTP 身份驗證方案,用戶向服務器請求訪問受限資源時,會攜帶一個 Token 作為憑證,檢驗通過則可以訪問特定的資源。最初是在
RFC 6750 中作為 OAuth 2.0 的一部分,但有時也可以單獨使用。我們在使用 Bear Token 的方法是在請求頭的 Authorization 字段中放入 Bearer 的格式的加密串(Json Web Token)。請注意 Bearer 前綴與 Token 之間有一個空字符位,與基本身份驗證類似,Bearer Authentication 只能在HTTPS(SSL)上使用。3. Spring Security 中實現接口 Jwt 認證
接下來我們是我們該系列的重頭戲 ———— 接口的 Jwt 認證。
3.1 定義 Json Web Token 過濾器
無論上面提到的哪種認證方式,我們都可以使用 Spring Security 中的 Filter 來處理。 Spring Security 默認的基礎配置沒有提供對 Bearer Authentication 處理的過濾器, 但是提供了處理 Basic Authentication 的過濾器:
org.springframework.security.web.authentication.www.BasicAuthenticationFilter
BasicAuthenticationFilter 繼承了 OncePerRequestFilter 。所以我們也模仿 BasicAuthenticationFilter 來實現自己的 JwtAuthenticationFilter 。 完整代碼如下:
package cn.felord.spring.security.filter;
import cn.felord.spring.security.exception.SimpleAuthenticationEntryPoint;
import cn.felord.spring.security.jwt.JwtTokenGenerator;
import cn.felord.spring.security.jwt.JwtTokenPair;
import cn.felord.spring.security.jwt.JwtTokenStorage;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
/**
* jwt 認證攔截器 用於攔截 請求 提取jwt 認證
*
* @author dax
* @since 2019/11/7 23:02
*/
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHENTICATION_PREFIX = "Bearer ";
/**
* 認證如果失敗由該端點進行響應
*/
private AuthenticationEntryPoint authenticationEntryPoint = new SimpleAuthenticationEntryPoint();
private JwtTokenGenerator jwtTokenGenerator;
private JwtTokenStorage jwtTokenStorage;
public JwtAuthenticationFilter(JwtTokenGenerator jwtTokenGenerator, JwtTokenStorage jwtTokenStorage) {
this.jwtTokenGenerator = jwtTokenGenerator;
this.jwtTokenStorage = jwtTokenStorage;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 如果已經通過認證
if (SecurityContextHolder.getContext().getAuthentication() != null) {
chain.doFilter(request, response);
return;
}
// 獲取 header 解析出 jwt 並進行認證 無token 直接進入下一個過濾器 因為 SecurityContext 的緣故 如果無權限並不會放行
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(header) && header.startsWith(AUTHENTICATION_PREFIX)) {
String jwtToken = header.replace(AUTHENTICATION_PREFIX, "");
if (StringUtils.hasText(jwtToken)) {
try {
authenticationTokenHandle(jwtToken, request);
} catch (AuthenticationException e) {
authenticationEntryPoint.commence(request, response, e);
}
} else {
// 帶安全頭 沒有帶token
authenticationEntryPoint.commence(request, response, new AuthenticationCredentialsNotFoundException("token is not found"));
}
}
chain.doFilter(request, response);
}
/**
* 具體的認證方法 匿名訪問不要攜帶token
* 有些邏輯自己補充 這裡只做基本功能的實現
*
* @param jwtToken jwt token
* @param request request
*/
private void authenticationTokenHandle(String jwtToken, HttpServletRequest request) throws AuthenticationException {
// 根據我的實現 有效token才會被解析出來
JSONObject jsonObject = jwtTokenGenerator.decodeAndVerify(jwtToken);
if (Objects.nonNull(jsonObject)) {
String username = jsonObject.getStr("aud");
// 從緩存獲取 token
JwtTokenPair jwtTokenPair = jwtTokenStorage.get(username);
if (Objects.isNull(jwtTokenPair)) {
if (log.isDebugEnabled()) {
log.debug("token : {} is not in cache", jwtToken);
}
// 緩存中不存在就算 失敗了
throw new CredentialsExpiredException("token is not in cache");
}
String accessToken = jwtTokenPair.getAccessToken();
if (jwtToken.equals(accessToken)) {
// 解析 權限集合 這裡
JSONArray jsonArray = jsonObject.getJSONArray("roles");
String roles = jsonArray.toString();
List<grantedauthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(roles);/<grantedauthority>
User user = new User(username, "[PROTECTED]", authorities);
// 構建用戶認證token
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user, null, authorities);
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 放入安全上下文中
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
} else {
// token 不匹配
if (log.isDebugEnabled()){
log.debug("token : {} is not in matched", jwtToken);
}
throw new BadCredentialsException("token is not matched");
}
} else {
if (log.isDebugEnabled()) {
log.debug("token : {} is invalid", jwtToken);
}
throw new BadCredentialsException("token is invalid");
}
}
}
具體看代碼註釋部分,邏輯有些地方根據你業務進行調整。匿名訪問必然是不能帶 Token 的!
3.2 配置 JwtAuthenticationFilter
首先將過濾器 JwtAuthenticationFilter 注入 Spring IoC 容器 ,然後一定要將 JwtAuthenticationFilter 順序置於 UsernamePasswordAuthenticationFilter 之前:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.cors()
.and()
// session 生成策略用無狀態策略
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
.and()
.authorizeRequests().anyRequest().authenticated()
.and()
.addFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)
// jwt 必須配置於 UsernamePasswordAuthenticationFilter 之前
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 登錄 成功後返回jwt token 失敗後返回 錯誤信息
.formLogin().loginProcessingUrl(LOGIN_PROCESSING_URL).successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)
.and().logout().addLogoutHandler(new CustomLogoutHandler()).logoutSuccessHandler(new CustomLogoutSuccessHandler());
}
4. 使用 Jwt 進行請求驗證
編寫一個受限接口 ,我們這裡是 http://localhost:8080/foo/test 。直接請求會被 401 。 我們通過下圖方式獲取 Token :
然後在 Postman 中使用 Jwt :
最終會認證成功並訪問到資源。
5. 刷新 Jwt Token
我們在 https://felord.cn/spring-security-custom-jwt.html 中已經實現了 Json Web Token 都是成對出現的邏輯。accessToken 用來接口請求, refreshToken 用來刷新 accessToken 。我們可以同樣定義一個 Filter 可參照 上面的 JwtAuthenticationFilter 。只不過 這次請求攜帶的是 refreshToken,我們在過濾器中攔截 URI跟我們定義的刷新端點進行匹配。同樣驗證 Token ,通過後像登錄成功一樣返回 Token 對即可。這裡不再進行代碼演示。
6. 總結
這是系列原創文章 每一篇都有不同的知識點,而且它們都是相互有聯繫的。有不懂的地方多回頭看。Spring Security 並不難學,關鍵是你找對思路了沒有。本次 DEMO 和文章位置私信我獲取
閱讀更多 碼農小胖哥 的文章