Spring Security + JWT

發表於2022-12-21

Spring Security預設是基於session進行使用者認證的,使用者透過登入請求完成認證之後,認證資訊在伺服器端儲存在session中,之後的請求傳送上來後SecurityContextPersistenceFilter過濾器從session中獲取認證資訊、以便透過後續安全過濾器的安全檢查。

今天的目標是替換Spring Security預設的session儲存認證資訊的機制為透過JWT的方式進行認證。

JWT(JSON WEB TOKEN)的相關內容就不做詳細分析了,我們只需要知道以下幾點:

  1. 使用者登入認證(使用者名稱、密碼驗證)透過之後,系統生成token並送給前端。
  2. token中包含使用者id(或使用者名稱)以及過期時間,包含透過加密機制生成的摘要,具有防篡改的能力。
  3. token資訊不需要在伺服器端儲存,前端獲取到token之後,每次請求都必須攜帶該token。
  4. 後臺接收到請求之後,檢查沒有token、或者token驗證不透過則不生成認證資訊,否則,token驗證透過則表示該使用者透過認證。
  5. 後臺接收到的token如果已過期,則根據應用的需求自動更新token或者要求前端重新登入。

與session方案對比一下,我們需要解決的問題如下:

  1. 需要停用掉Spring Security預設的session管理使用者認證資訊的方案。
  2. 使用者登入後需要生成並返回給前端token。
  3. 前端請求上來之後,需要獲取並驗證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,方案無非就是:

  1. UsernamePasswordAuthenticationFilter之後加一個我們自己的過濾器,與UsernamePasswordAuthenticationFilter一樣只匹配登入請求,生成token。
  2. UsernamePasswordAuthenticationFilter過濾器認證透過後有沒有呼叫過其他可以被我們客戶化的東東,我們客戶化這個東東完成我們的目標。
  3. 客戶化UsernamePasswordAuthenticationFilter,登入成功後生成token。

這裡必須交代一下,第3個方案只是從邏輯上來說應該能解決我們的問題,但是壓根就沒有考慮過這個方案,因為我覺得太麻煩。

先試了第一個方案,沒成功,因為我們知道Spring Security還有一個RequestCacheAwareFilter過濾器,會導致如果你是在尚未獲取授權之前訪問了非登入頁面,那麼Spring Security會導航到登入頁面、登入成功後在UsernamePasswordAuthenticationFilter中就會發生跳轉,這樣的話就跳過了我們後面加的這個過濾器,目標就無法實現或者說即使彎彎繞繞能實現,但是方案也不會太好。

所以,就努力研究第2個方案。

所以大概看了一下UsernamePasswordAuthenticationFilter在登入認證成功後的處理,發現了這個:

image.png

所以就大概去研究了一下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

啟動專案,成功登入系統後,驚喜的發現他已經開始幹活了:
image.png

好了,給了我們信心,擼起袖子加油幹!

RememberMeAuthenticationFilter

RememberMeServices機制依賴RememberMeAuthenticationFilter實現,我們在上面的配置檔案中已經啟用了。

image.png

然後簡單看一眼RememberMeAuthenticationFilter過濾器的doFilter方法,他首先去SecurityContextHolder獲取認證資訊,如果沒有獲取到的話,就呼叫RememberMeService的autoLogin方法,只是從doFilter的原始碼來看(程式碼就不貼出了),autoLogin方法返回的Authentication並未完成認證,因為返回之後還要呼叫authenticationManager進行認證。
image.png

這是與我們預期不符的地方,我們希望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加在請求引數後面:
image.png

如圖,請求成功了!

上一篇Spring Security自定義使用者認證過程(2)

相關文章