Spring Security5整合JWT認證和授權
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清空。使用者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 {
private UserDetailsService userDetailsService;
// 父類沒有無參構造器,必須顯示呼叫父類的含參構造器
public JWTAuthorizationFilter(UserDetailsService userDetailsService) {
super();
this.userDetailsService = userDetailsService;
}
@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 {
/*
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 明文密碼
//auth.inMemoryAuthentication().withUser("user").password("{noop}123456").roles();
auth.inMemoryAuthentication().withUser("admin").password(passwordEncoder().encode("123456")).roles();
}*/
@Autowired
private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;
@Autowired
private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler;
@Autowired
private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;
@Autowired
private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;
@Autowired
private CustomUserDetailService customUserDetailService;
@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(customUserDetailService), 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 Security3原始碼分析(5)-SecurityContextPersistenceFilter分析)
Spring Security addFilter() 順序問題
前後端聯調之Form Data與Request Payload,你真的瞭解嗎?