JWT介紹
JWT原理
JWT是JSON Web Token的縮寫,是目前最流行的跨域認證解決方法。
互聯網服務認證的一般流程是:
- 用戶向服務器發送賬號、密碼
- 服務器驗證通過后,將用戶的角色、登錄時間等信息保存到當前會話中
- 同時,服務器向用戶返回一個session_id(一般保存在cookie里)
- 用戶再次發送請求時,把含有session_id的cookie發送給服務器
- 服務器收到session_id,查找session,提取用戶信息
上面的認證模式,存在以下缺點:
- cookie不允許跨域
- 因為每台服務器都必須保存session對象,所以擴展性不好
JWT認證原理是:
- 用戶向服務器發送賬號、密碼
- 服務器驗證通過后,生成token令牌返回給客戶端(token可以包含用戶信息)
- 用戶再次請求時,把token放到請求頭
Authorization
里 - 服務器收到請求,驗證token合法后放行請求
JWT token令牌可以包含用戶身份、登錄時間等信息,這樣登錄狀態保持者由服務器端變為客戶端,服務器變成無狀態了;token放到請求頭,實現了跨域
JWT數據結構
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT由三部分組成:
- Header(頭部)
- Payload(負載)
- Signature(簽名)
表現形式為:Header.Payload.Signature
Header
Header 部分是一個 JSON 對象,描述 JWT 的元數據,通常是下面的樣子:
{
"alg": "HS256",
"typ": "JWT"
}
上面代碼中,alg
屬性表示簽名的算法(algorithm),默認是 HMAC SHA256(寫成 HS256);typ
屬性表示這個令牌(token)的類型(type),JWT 令牌統一寫為JWT
。
上面的 JSON 對象使用 Base64URL 算法轉成字符串
Payload
Payload 部分也是一個 JSON 對象,用來存放實際需要傳遞的數據。JWT 規定了7個官方字段:
-
iss (issuer):簽發人
-
exp (expiration time):過期時間
-
sub (subject):主題
-
aud (audience):受眾
-
nbf (Not Before):生效時間
-
iat (Issued At):簽發時間
-
jti (JWT ID):編號
當然,用戶也可以定義私有字段。
這個 JSON 對象也要使用 Base64URL 算法轉成字符串
Signature
Signature 部分是對前兩部分的簽名,防止數據篡改
簽名算法如下:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
算出簽名以后,把 Header、Payload、Signature 三個部分拼成一個字符串,每個部分之間用"."分隔
JWT認證和授權
Security是基於AOP和Servlet過濾器的安全框架,為了實現JWT要重寫那些方法、自定義那些過濾器需要首先了解security自帶的過濾器。security默認過濾器鏈如下:
- org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
- org.springframework.security.web.context.SecurityContextPersistenceFilter
- org.springframework.security.web.header.HeaderWriterFilter
- org.springframework.security.web.authentication.logout.LogoutFilter
- org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
- org.springframework.security.web.savedrequest.RequestCacheAwareFilter
- org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
- org.springframework.security.web.authentication.AnonymousAuthenticationFilter
- org.springframework.security.web.session.SessionManagementFilter
- org.springframework.security.web.access.ExceptionTranslationFilter
- org.springframework.security.web.access.intercept.FilterSecurityInterceptor
SecurityContextPersistenceFilter
這個過濾器有兩個作用:
- 用戶發送請求時,從session對象提取用戶信息,保存到SecurityContextHolder的securitycontext中
- 當前請求響應結束時,把SecurityContextHolder的securitycontext保存的用戶信息放到session,便於下次請求時共享數據;同時將SecurityContextHolder的securitycontext清空
由於禁用session功能,所以該過濾器只剩一個作用即把SecurityContextHolder的securitycontext清空。舉例來說明為何要清空securitycontext:用戶1發送一個請求,由線程M處理,當響應完成線程M放回線程池;用戶2發送一個請求,本次請求同樣由線程M處理,由於securitycontext沒有清空,理應儲存用戶2的信息但此時儲存的是用戶1的信息,造成用戶信息不符
UsernamePasswordAuthenticationFilter
UsernamePasswordAuthenticationFilter
繼承自AbstractAuthenticationProcessingFilter
,處理邏輯在doFilter
方法中:
- 當請求被
UsernamePasswordAuthenticationFilter
攔截時,判斷請求路徑是否匹配登錄URL,若不匹配繼續執行下個過濾器;否則,執行步驟2 - 調用
attemptAuthentication
方法進行認證。UsernamePasswordAuthenticationFilter
重寫了attemptAuthentication
方法,負責讀取表單登錄參數,委托AuthenticationManager
進行認證,返回一個認證過的token(null表示認證失敗) - 判斷token是否為null,非null表示認證成功,null表示認證失敗
- 若認證成功,調用
successfulAuthentication
。該方法把認證過的token放入securitycontext供后續請求授權,同時該方法預留一個擴展點(AuthenticationSuccessHandler.onAuthenticationSuccess方法
),進行認證成功后的處理 - 若認證失敗,同樣可以擴展
uthenticationFailureHandler.onAuthenticationFailure
進行認證失敗后的處理 - 只要當前請求路徑匹配登錄URL,那么無論認證成功還是失敗,當前請求都會響應完成,不再執行過濾器鏈
UsernamePasswordAuthenticationFilter
的attemptAuthentication
方法,執行邏輯如下:
- 從請求中獲取表單參數。因為使用
HttpServletRequest.getParameter
方法獲取參數,它只能處理Content-Type為application/x-www-form-urlencoded或multipart/form-data的請求,若是application/json則無法獲取值 - 把步驟1獲取的賬號、密碼封裝成
UsernamePasswordAuthenticationToken
對象,創建未認證的token。UsernamePasswordAuthenticationToken
有兩個重載的構造方法,其中public UsernamePasswordAuthenticationToken(Object principal, Object credentials)
創建未經認證的token,public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)
創建已認證的token - 獲取認證管理器
AuthenticationManager
,其缺省實現為ProviderManager
,調用其authenticate
進行認證 ProviderManager
的authenticate
是個模板方法,它遍歷所有AuthenticationProvider
,直至找到支持認證某類型token的AuthenticationProvider
,調用AuthenticationProvider.authenticate
方法認證,AuthenticationProvider.authenticate
加載正確的賬號、密碼進行比較驗證AuthenticationManager.authenticate
方法返回一個已認證的token
AnonymousAuthenticationFilter
AnonymousAuthenticationFilter
負責創建匿名token:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
SecurityContextHolder.getContext().setAuthentication(this.createAuthentication((HttpServletRequest)req));
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.of(() -> {
return "Set SecurityContextHolder to " + SecurityContextHolder.getContext().getAuthentication();
}));
} else {
this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
}
} else if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.of(() -> {
return "Did not set SecurityContextHolder since already authenticated " + SecurityContextHolder.getContext().getAuthentication();
}));
}
chain.doFilter(req, res);
}
如果當前用戶沒有認證,會創建一個匿名token,用戶是否能讀取資源交由FilterSecurityInterceptor
過濾器委托給決策管理器判斷是否有權限讀取
實現思路
JWT認證思路:
- 利用Security原生的表單認證過濾器驗證用戶名、密碼
- 驗證通過后自定義
AuthenticationSuccessHandler
認證成功處理器,由該處理器生成token令牌
JWT授權思路:
- 使用JWT目的是讓服務器變成無狀態,不用session共享數據,所以要禁用security的session功能(http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- token令牌數據結構設計時,payload部分要儲存用戶名、角色信息
- token令牌有兩個作用:
- 認證, 用戶發送的token合法即代表認證成功
- 授權,令牌驗證成功后提取角色信息,構造認證過的token,將其放到securitycontext,具體權限判斷交給security框架處理
- 自己實現一個過濾器,攔截用戶請求,實現(3)中所說的功能
代碼實現
創建JWT工具類
JWT的Java實現,利用開源的java-jwt
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.12.0</version>
</dependency>
我們對java-jwt提供的API進行封裝,便於創建、驗證、提取claim
@Slf4j
public class JWTUtil {
// 攜帶token的請求頭名字
public final static String TOKEN_HEADER = "Authorization";
//token的前綴
public final static String TOKEN_PREFIX = "Bearer ";
// 默認密鑰
public final static String DEFAULT_SECRET = "mySecret";
// 用戶身份
private final static String ROLES_CLAIM = "roles";
// token有效期,單位分鍾;
private final static long EXPIRE_TIME = 5 * 60 * 1000;
// 設置Remember-me功能后的token有效期
private final static long EXPIRE_TIME_REMEMBER = 7 * 24 * 60 * 60 * 1000;
// 創建token
public static String createToken(String username, List role, String secret, boolean rememberMe) {
Date expireDate = rememberMe ? new Date(System.currentTimeMillis() + EXPIRE_TIME_REMEMBER) : new Date(System.currentTimeMillis() + EXPIRE_TIME);
try {
// 創建簽名的算法實例
Algorithm algorithm = Algorithm.HMAC256(secret);
String token = JWT.create()
.withExpiresAt(expireDate)
.withClaim("username", username)
.withClaim(ROLES_CLAIM, role)
.sign(algorithm);
return token;
} catch (JWTCreationException jwtCreationException) {
log.warn("Token create failed");
return null;
}
}
// 驗證token
public static boolean verifyToken(String token, String secret) {
try{
Algorithm algorithm = Algorithm.HMAC256(secret);
// 構建JWT驗證器,token合法同時pyload必須含有私有字段username且值一致
// token過期也會驗證失敗
JWTVerifier verifier = JWT.require(algorithm)
.build();
// 驗證token
DecodedJWT decodedJWT = verifier.verify(token);
return true;
} catch (JWTVerificationException jwtVerificationException) {
log.warn("token驗證失敗");
return false;
}
}
// 獲取username
public static String getUsername(String token) {
try {
// 因此獲取載荷信息不需要密鑰
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException jwtDecodeException) {
log.warn("提取用戶姓名時,token解碼失敗");
return null;
}
}
public static List<String> getRole(String token) {
try {
// 因此獲取載荷信息不需要密鑰
DecodedJWT jwt = JWT.decode(token);
// asList方法需要指定容器元素的類型
return jwt.getClaim(ROLES_CLAIM).asList(String.class);
} catch (JWTDecodeException jwtDecodeException) {
log.warn("提取身份時,token解碼失敗");
return null;
}
}
}
認證
驗證賬號、密碼交給UsernamePasswordAuthenticationFilter
,不用修改代碼
認證成功后,需要生成token返回給客戶端,我們通過擴展AuthenticationSuccessHandler.onAuthenticationSuccess方法
實現
@Component
public class JWTAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
ResponseData responseData = new ResponseData();
responseData.setCode("200");
responseData.setMessage("登錄成功!");
// 提取用戶名,准備寫入token
String username = authentication.getName();
// 提取角色,轉為List<String>對象,寫入token
List<String> roles = new ArrayList<>();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities){
roles.add(authority.getAuthority());
}
// 創建token
String token = JWTUtil.createToken(username, roles, JWTUtil.DEFAULT_SECRET, true);
httpServletResponse.setCharacterEncoding("utf-8");
// 為了跨域,把token放到響應頭WWW-Authenticate里
httpServletResponse.setHeader("WWW-Authenticate", JWTUtil.TOKEN_PREFIX + token);
// 寫入響應里
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(httpServletResponse.getWriter(), responseData);
}
}
為了統一返回值,我們封裝了一個ResponseData
對象
授權
自定義一個過濾器JWTAuthorizationFilter
,驗證token,token驗證成功后認為認證成功
@Slf4j
public class JWTAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = getTokenFromRequestHeader(request);
Authentication verifyResult = verefyToken(token, JWTUtil.DEFAULT_SECRET);
if (verifyResult == null) {
// 即便驗證失敗,也繼續調用過濾鏈,匿名過濾器生成匿名令牌
chain.doFilter(request, response);
return;
} else {
log.info("token令牌驗證成功");
SecurityContextHolder.getContext().setAuthentication(verifyResult);
chain.doFilter(request, response);
}
}
// 從請求頭獲取token
private String getTokenFromRequestHeader(HttpServletRequest request) {
String header = request.getHeader(JWTUtil.TOKEN_HEADER);
if (header == null || !header.startsWith(JWTUtil.TOKEN_PREFIX)) {
log.info("請求頭不含JWT token, 調用下個過濾器");
return null;
}
String token = header.split(" ")[1].trim();
return token;
}
// 驗證token,並生成認證后的token
private UsernamePasswordAuthenticationToken verefyToken(String token, String secret) {
if (token == null) {
return null;
}
// 認證失敗,返回null
if (!JWTUtil.verifyToken(token, secret)) {
return null;
}
// 提取用戶名
String username = JWTUtil.getUsername(token);
// 定義權限列表
List<GrantedAuthority> authorities = new ArrayList<>();
// 從token提取角色
List<String> roles = JWTUtil.getRole(token);
for (String role : roles) {
log.info("用戶身份是:" + role);
authorities.add(new SimpleGrantedAuthority(role));
}
// 構建認證過的token
return new UsernamePasswordAuthenticationToken(username, null, authorities);
}
}
OncePerRequestFilter
保證當前請求中,此過濾器只被調用一次,執行邏輯在doFilterInternal
security配置
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;
@Autowired
private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;
@Autowired
private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.successHandler(jwtAuthenticationSuccessHandler)
.failureHandler(ajaxAuthenticationFailureHandler)
.permitAll()
.and()
.addFilterAfter(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling().authenticationEntryPoint(ajaxAuthenticationEntryPoint);
}
}
配置里取消了session功能,把我們定義的過濾器添加到過濾鏈中;同時,定義ajaxAuthenticationEntryPoint
處理未認證用戶訪問未授權資源時拋出的異常
@Component
public class AjaxAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
ResponseData responseData = new ResponseData();
responseData.setCode("401");
responseData.setMessage("匿名用戶,請先登錄再訪問!");
httpServletResponse.setCharacterEncoding("utf-8");
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(httpServletResponse.getWriter(), responseData);
}
}
參考
Spring Security3源碼分析(5)-SecurityContextPersistenceFilter分析
Spring Security addFilter() 順序問題
前后端聯調之Form Data與Request Payload,你真的了解嗎?