Spring Security預設是基於session進行使用者認證的,使用者透過登入請求完成認證之後,認證資訊在伺服器端儲存在session中,之後的請求傳送上來後SecurityContextPersistenceFilter過濾器從session中獲取認證資訊、以便透過後續安全過濾器的安全檢查。
今天的目標是替換Spring Security預設的session儲存認證資訊的機制為透過JWT的方式進行認證。
JWT(JSON WEB TOKEN)的相關內容就不做詳細分析了,我們只需要知道以下幾點:
- 使用者登入認證(使用者名稱、密碼驗證)透過之後,系統生成token並送給前端。
- token中包含使用者id(或使用者名稱)以及過期時間,包含透過加密機制生成的摘要,具有防篡改的能力。
- token資訊不需要在伺服器端儲存,前端獲取到token之後,每次請求都必須攜帶該token。
- 後臺接收到請求之後,檢查沒有token、或者token驗證不透過則不生成認證資訊,否則,token驗證透過則表示該使用者透過認證。
- 後臺接收到的token如果已過期,則根據應用的需求自動更新token或者要求前端重新登入。
與session方案對比一下,我們需要解決的問題如下:
- 需要停用掉Spring Security預設的session管理使用者認證資訊的方案。
- 使用者登入後需要生成並返回給前端token。
- 前端請求上來之後,需要獲取並驗證token,驗證透過後生成使用者認證資訊。
下面我們逐一解決上述三個問題。我們仍然使用上一篇文章中用過的demo,已經貼出過的程式碼就不再貼出了。
準備工作
我們需要準備一些與JWT相關的東西,比如引入JWT的生成token、token驗證的模組。
我們引入java-jwt,在pom檔案加入依賴即可:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.2.1</version>
</dependency>
然後需要編寫一個工具類,以便能夠生成、驗證token,我們暫時不考慮token過期等等細節問題的處理,只要能正確生成、驗證token就可以:
public class JwtUtil {
public final static String SECRET_KEY="This is secret key for JWT";
public final static String JWTHeader_Leading_Str="Bearer ";
public final static String JWTHeader_Name="Authorization";
public static String generateToken(String userName){
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND,120);
HashMap header = new HashMap<>();
header.put("alg","HS256");
header.put("Type","JWT");
return JWT.create().withHeader(header)
.withClaim("userName",userName)
.withExpiresAt(calendar.getTime())
.sign(Algorithm.HMAC256(SECRET_KEY));
}
public static String verify(String token){
// 建立解析物件,使用的演算法和secret要與建立token時保持一致
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET_KEY)).build();
// 解析指定的token
DecodedJWT decodedJWT = jwtVerifier.verify(token);
return decodedJWT.getClaims().get("userName").asString();
}
public static String parseToken(HttpServletRequest request){
String rawJwt = request.getHeader(JWTHeader_Name);
if(rawJwt==null){
return null;
}
if(!rawJwt.startsWith(JWTHeader_Leading_Str)){
return null;
}
return rawJwt.substring(JWTHeader_Leading_Str.length()+1);
}
private void showToken(DecodedJWT decodedJWT){
// 獲取解析後的token中的資訊
String header = decodedJWT.getHeader();
System.out.println("type:" + decodedJWT.getType());
System.out.println("header:" + header);
Map<String, Claim> payloadMap = decodedJWT.getClaims();
System.out.println("Payload:" + payloadMap);
Date expires = decodedJWT.getExpiresAt();
System.out.println("過期時間:" + expires);
String signature = decodedJWT.getSignature();
System.out.println("signature:" + signature);
}
public static void main(String[] args) {
String token=JwtUtil.generateToken("Zhang Fu");
System.out.println(token);
String userName = JwtUtil.verify(token);
System.out.println("userName:" +userName);
}
}
OK,準備工作完成。
停用Spring Security的預設session方案
為了停用session,我們需要增加一項配置,所以我們要新建一個配置檔案:
@Configuration
public class WebSecurityConfig {
@Autowired
MyRememberMeService myRememberMeService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception
{
httpSecurity.authorizeRequests()
//.antMatchers("/hello").permitAll()
.anyRequest().authenticated().and()
.httpBasic().and()
.rememberMe().rememberMeServices(myRememberMeService)
.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin();
//httpSecurity.addFilterBefore(new JwtSecurityFilter(), UsernamePasswordAuthenticationFilter.class);
//httpSecurity.addFilterAfter(new JwtAfterUsernamePasswordFilter(),UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
}
設定SessionCreationPolicy.STATELESS就可以達到目的。
原因可以在sessionManagementConfigure.java這個session配置器中找到,在他的init方法中:
@Override
public void init(H http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
boolean stateless = isStateless();
if (securityContextRepository == null) {
if (stateless) {
http.setSharedObject(SecurityContextRepository.class, new NullSecurityContextRepository());
}
如果SessionCreationPolicy設定為stateless的話,那麼他會建立NullSecurityContextRepository作為他的SecurityContextRepository。
這個NullSecurityContextRepository實際就是個假把式,啥也不幹,我們知道使用者認證透過後會呼叫他的saveContext方法儲存認證資訊,他是這麼幹的:
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
}
所以,他就是個偷工減料的貨,啥也沒幹。
所以第一個問題解決了。
使用者登入後生成token並返回給前端
這個問題我嘗試了好幾個方案之後才成功。
我們知道使用者登入是在安全過濾器UsernamePasswordAuthenticationFilter中完成的,登入成功後如果想要生成JWT的token,方案無非就是:
- UsernamePasswordAuthenticationFilter之後加一個我們自己的過濾器,與UsernamePasswordAuthenticationFilter一樣只匹配登入請求,生成token。
- UsernamePasswordAuthenticationFilter過濾器認證透過後有沒有呼叫過其他可以被我們客戶化的東東,我們客戶化這個東東完成我們的目標。
- 客戶化UsernamePasswordAuthenticationFilter,登入成功後生成token。
這裡必須交代一下,第3個方案只是從邏輯上來說應該能解決我們的問題,但是壓根就沒有考慮過這個方案,因為我覺得太麻煩。
先試了第一個方案,沒成功,因為我們知道Spring Security還有一個RequestCacheAwareFilter過濾器,會導致如果你是在尚未獲取授權之前訪問了非登入頁面,那麼Spring Security會導航到登入頁面、登入成功後在UsernamePasswordAuthenticationFilter中就會發生跳轉,這樣的話就跳過了我們後面加的這個過濾器,目標就無法實現或者說即使彎彎繞繞能實現,但是方案也不會太好。
所以,就努力研究第2個方案。
所以大概看了一下UsernamePasswordAuthenticationFilter在登入認證成功後的處理,發現了這個:
所以就大概去研究了一下RememberMeServices,讀了一下他的doc,發現他是一個基於cokie的、確保前臺請求即使在session過期之後傳送上來都可以繼續透過安全認證的“記住我”機制。
除了cokie之外,其他的與JWT的要求完全吻合。如果我們能自己實現一個基於JWT的RememberMeService,是不是就解決問題了?
所以就用他來嘗試一下。
MyRememberMeService#loginSuccess
建立MyRememberMeService並透過配置加入到應用中來,前面停用session的時候的配置檔案中已經加入了,返回去看一眼就行。
我們要實現他的loginSuccess方法,建立並返回token:
@Override
public void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
String token= JwtUtil.generateToken(username);
token=JwtUtil.JWTHeader_Leading_Str+token;
log.info("After login success:"+token);
response.setHeader(JwtUtil.JWTHeader_Name,token);
}
驗證一下建立並返回token
啟動專案,成功登入系統後,驚喜的發現他已經開始幹活了:
好了,給了我們信心,擼起袖子加油幹!
RememberMeAuthenticationFilter
RememberMeServices機制依賴RememberMeAuthenticationFilter實現,我們在上面的配置檔案中已經啟用了。
然後簡單看一眼RememberMeAuthenticationFilter過濾器的doFilter方法,他首先去SecurityContextHolder獲取認證資訊,如果沒有獲取到的話,就呼叫RememberMeService的autoLogin方法,只是從doFilter的原始碼來看(程式碼就不貼出了),autoLogin方法返回的Authentication並未完成認證,因為返回之後還要呼叫authenticationManager進行認證。
這是與我們預期不符的地方,我們希望autoLogin之後就可以完成認證、並且可以將認證資訊放置到SecurityContextHolder中(因為我們是透過JWT做驗證的,token驗證透過的話就相當於完成了認證)。
那我們是不是可以在autoLogin中完成這些操作,並且返回null騙一下RememberMeAuthenticationFilter的doFilter方法不再要求authenticationManager去再次認證呢?
我們試一下!
MyRememberMeService#autoLogin
建立RememberMeService並實現autoLogin方法,為了簡化他的初始化過程,我們直接把他注入到Spring Ioc容器中。
如前所述,方法一定要返回null。
@Slf4j
@Component
public class MyRememberMeService implements RememberMeServices {
@Autowired
MyUserDetailsService myUserDetailsService;
@Override
public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
log.info("autoLogin in MyRememberMeService: ");
String username;
String token = JwtUtil.parseToken(request);
if(token==null){
log.info("I dont get token from header");
token=request.getParameter("token");
}
log.info("finally the token is :" + token);
UsernamePasswordAuthenticationToken authenticationToken=null;
if(token!=null) {
String userName=JwtUtil.verify(token);
UserDetails user = myUserDetailsService.loadUserByUsername(userName);
authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
return null;
}
@Override
public void loginFail(HttpServletRequest request, HttpServletResponse response) {
}
}
測試一下autoLogin
上面的程式碼中已經看到了,我們只是為了測試、如果從請求頭資訊中拿不到token的話就從請求引數中獲取。只是為了學習、測試偷個懶,正式專案實現的時候這個地方還是需要比較多的完善的。
啟動專案,開始測試,第一步先透過login獲取token,上面已經展示過了,然後用獲取到的token發一個需要認證的請求,token加在請求引數後面:
如圖,請求成功了!