乾貨|一個案例學會Spring Security 中使用 JWT

Java菜分享發表於2019-04-08

在前後端分離的專案中,登入策略也有不少,不過 JWT 算是目前比較流行的一種解決方案了,本文就和大家來分享一下如何將 Spring Security 和 JWT 結合在一起使用,進而實現前後端分離時的登入解決方案。

1 無狀態登入

1.1 什麼是有狀態?

有狀態服務,即服務端需要記錄每次會話的客戶端資訊,從而識別客戶端身份,根據使用者身份進行請求的處理,典型的設計如Tomcat中的Session。例如登入:使用者登入後,我們把使用者的資訊儲存在服務端session中,並且給使用者一個cookie值,記錄對應的session,然後下次請求,使用者攜帶cookie值來(這一步有瀏覽器自動完成),我們就能識別到對應session,從而找到使用者的資訊。這種方式目前來看最方便,但是也有一些缺陷,如下:

  • 服務端儲存大量資料,增加服務端壓力
  • 服務端儲存使用者狀態,不支援叢集化部署

1.2 什麼是無狀態

微服務叢集中的每個服務,對外提供的都使用RESTful風格的介面。而RESTful風格的一個最重要的規範就是:服務的無狀態性,即:

  • 服務端不儲存任何客戶端請求者資訊
  • 客戶端的每次請求必須具備自描述資訊,通過這些資訊識別客戶端身份

那麼這種無狀態性有哪些好處呢?

  • 客戶端請求不依賴服務端的資訊,多次請求不需要必須訪問到同一臺伺服器
  • 服務端的叢集和狀態對客戶端透明
  • 服務端可以任意的遷移和伸縮(可以方便的進行叢集化部署)
  • 減小服務端儲存壓力

1.3.如何實現無狀態

無狀態登入的流程:

  • 首先客戶端傳送賬戶名/密碼到服務端進行認證
  • 認證通過後,服務端將使用者資訊加密並且編碼成一個token,返回給客戶端
  • 以後客戶端每次傳送請求,都需要攜帶認證的token
  • 服務端對客戶端傳送來的token進行解密,判斷是否有效,並且獲取使用者登入資訊

1.4 JWT

1.4.1 簡介

JWT,全稱是Json Web Token, 是一種JSON風格的輕量級的授權和身份認證規範,可實現無狀態、分散式的Web應用授權:

乾貨|一個案例學會Spring Security 中使用 JWT


JWT 作為一種規範,並沒有和某一種語言繫結在一起,常用的Java 實現是GitHub 上的開源專案 jjwt

1.4.2 JWT資料格式

JWT包含三部分資料:

  • Header:頭部,通常頭部有兩部分資訊:
  • 宣告型別,這裡是JWT
  • 加密演算法,自定義

我們會對頭部進行Base64Url編碼(可解碼),得到第一部分資料。

  • Payload:載荷,就是有效資料,在官方文件中(RFC7519),這裡給了7個示例資訊:
  • iss (issuer):表示簽發人
  • exp (expiration time):表示token過期時間
  • sub (subject):主題
  • aud (audience):受眾
  • nbf (Not Before):生效時間
  • iat (Issued At):簽發時間
  • jti (JWT ID):編號

這部分也會採用Base64Url編碼,得到第二部分資料。

  • Signature:簽名,是整個資料的認證資訊。一般根據前兩步的資料,再加上服務的的金鑰secret(金鑰儲存在服務端,不能洩露給客戶端),通過Header中配置的加密演算法生成。用於驗證整個資料完整和可靠性。

生成的資料格式如下圖:

乾貨|一個案例學會Spring Security 中使用 JWT


注意,這裡的資料通過 . 隔開成了三部分,分別對應前面提到的三部分,另外,這裡資料是不換行的,圖片換行只是為了展示方便而已。

1.4.3 JWT互動流程

流程圖:

乾貨|一個案例學會Spring Security 中使用 JWT


步驟翻譯:

  1. 應用程式或客戶端向授權伺服器請求授權
  2. 獲取到授權後,授權伺服器會嚮應用程式返回訪問令牌
  3. 應用程式使用訪問令牌來訪問受保護資源(如API)

因為JWT簽發的token中已經包含了使用者的身份資訊,並且每次請求都會攜帶,這樣服務的就無需儲存使用者資訊,甚至無需去資料庫查詢,這樣就完全符合了RESTful的無狀態規範。

1.5 JWT 存在的問題

說了這麼多,JWT 也不是天衣無縫,由客戶端維護登入狀態帶來的一些問題在這裡依然存在,舉例如下:

  1. 續簽問題,這是被很多人詬病的問題之一,傳統的cookie+session的方案天然的支援續簽,但是jwt由於服務端不儲存使用者狀態,因此很難完美解決續簽問題,如果引入redis,雖然可以解決問題,但是jwt也變得不倫不類了。
  2. 登出問題,由於服務端不再儲存使用者資訊,所以一般可以通過修改secret來實現登出,服務端secret修改後,已經頒發的未過期的token就會認證失敗,進而實現登出,不過畢竟沒有傳統的登出方便。
  3. 密碼重置,密碼重置後,原本的token依然可以訪問系統,這時候也需要強制修改secret。
  4. 基於第2點和第3點,一般建議不同使用者取不同secret。

2 實戰

說了這麼久,接下來我們就來看看這個東西到底要怎麼用?

2.1 環境搭建

首先我們來建立一個Spring Boot專案,建立時需要新增Spring Security依賴,建立完成後,新增 jjwt 依賴,完整的pom.xml檔案如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
複製程式碼

然後在專案中建立一個簡單的 User 物件實現 UserDetails 介面,如下:

public class User implements UserDetails {
    private String username;
    private String password;
    private List<GrantedAuthority> authorities;
    public String getUsername() {
        return username;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
    //省略getter/setter
}
複製程式碼

這個就是我們的使用者物件,先放著備用,再建立一個HelloController,內容如下:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello jwt !";
    }
    @GetMapping("/admin")
    public String admin() {
        return "hello admin !";
    }
}
複製程式碼

HelloController 很簡單,這裡有兩個介面,設計是 /hello 介面可以被具有 user 角色的使用者訪問,而 /admin 介面則可以被具有 admin 角色的使用者訪問。

2.2 JWT 過濾器配置

接下來提供兩個和 JWT 相關的過濾器配置:

  1. 一個是使用者登入的過濾器,在使用者的登入的過濾器中校驗使用者是否登入成功,如果登入成功,則生成一個token返回給客戶端,登入失敗則給前端一個登入失敗的提示。
  2. 第二個過濾器則是當其他請求傳送來,校驗token的過濾器,如果校驗成功,就讓請求繼續執行。

這兩個過濾器,我們分別來看,先看第一個:

public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {
    protected JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
        setAuthenticationManager(authenticationManager);
    }
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse resp) throws AuthenticationException, IOException, ServletException {
        User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
        return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
    }
    @Override
    protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
        StringBuffer as = new StringBuffer();
        for (GrantedAuthority authority : authorities) {
            as.append(authority.getAuthority())
                    .append(",");
        }
        String jwt = Jwts.builder()
                .claim("authorities", as)//配置使用者角色
                .setSubject(authResult.getName())
                .setExpiration(new Date(System.currentTimeMillis() + 10 * 60 * 1000))
                .signWith(SignatureAlgorithm.HS512,"sang@123")
                .compact();
        resp.setContentType("application/json;charset=utf-8");
        PrintWriter out = resp.getWriter();
        out.write(new ObjectMapper().writeValueAsString(jwt));
        out.flush();
        out.close();
    }
    protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
        resp.setContentType("application/json;charset=utf-8");
        PrintWriter out = resp.getWriter();
        out.write("登入失敗!");
        out.flush();
        out.close();
    }
}
複製程式碼

關於這個類,我說如下幾點:

,
複製程式碼

再來看第二個token校驗的過濾器:

public class JwtFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        String jwtToken = req.getHeader("authorization");
        System.out.println(jwtToken);
        Claims claims = Jwts.parser().setSigningKey("sang@123").parseClaimsJws(jwtToken.replace("Bearer",""))
                .getBody();
        String username = claims.getSubject();//獲取當前登入使用者名稱
        List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(token);
        filterChain.doFilter(req,servletResponse);
    }
}
複製程式碼

關於這個過濾器,我說如下幾點:

  1. 首先從請求頭中提取出 authorization 欄位,這個欄位對應的value就是使用者的token。
  2. 將提取出來的token字串轉換為一個Claims物件,再從Claims物件中提取出當前使用者名稱和使用者角色,建立一個UsernamePasswordAuthenticationToken放到當前的Context中,然後執行過濾鏈使請求繼續執行下去。

如此之後,兩個和JWT相關的過濾器就算配置好了。

2.3 Spring Security 配置

接下來我們來配置 Spring Security,如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().withUser("admin")
                .password("123").roles("admin")
                .and()
                .withUser("sang")
                .password("456")
                .roles("user");
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/hello").hasRole("user")
                .antMatchers("/admin").hasRole("admin")
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtLoginFilter("/login",authenticationManager()),UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtFilter(),UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}
複製程式碼
  1. 簡單起見,這裡我並未對密碼進行加密,因此配置了NoOpPasswordEncoder的例項。
  2. 簡單起見,這裡並未連線資料庫,我直接在記憶體中配置了兩個使用者,兩個使用者具備不同的角色。
  3. 配置路徑規則時, /hello 介面必須要具備 user 角色才能訪問, /admin 介面必須要具備 admin 角色才能訪問,POST 請求並且是 /login 介面則可以直接通過,其他介面必須認證後才能訪問。
  4. 最後配置上兩個自定義的過濾器並且關閉掉csrf保護。

2.4 測試

做完這些之後,我們的環境就算完全搭建起來了,接下來啟動專案然後在 POSTMAN 中進行測試,如下:

乾貨|一個案例學會Spring Security 中使用 JWT


登入成功後返回的字串就是經過 base64url 轉碼的token,一共有三部分,通過一個 . 隔開,我們可以對第一個 . 之前的字串進行解碼,即Header,如下:

乾貨|一個案例學會Spring Security 中使用 JWT


再對兩個 . 之間的字元解碼,即 payload:

乾貨|一個案例學會Spring Security 中使用 JWT


可以看到,我們設定資訊,由於base64並不是加密方案,只是一種編碼方案,因此,不建議將敏感的使用者資訊放到token中。

接下來再去訪問 /hello 介面,注意認證方式選擇 Bearer Token,Token值為剛剛獲取到的值,如下:

乾貨|一個案例學會Spring Security 中使用 JWT


可以看到,訪問成功。

總結

這就是 JWT 結合 Spring Security 的一個簡單用法,講真,如果例項允許,類似的需求我還是推薦使用 OAuth2 中的 password 模式。

歡迎工作一到五年的Java工程師朋友們加入Java程式設計師開發: 721575865

群內提供免費的Java架構學習資料(裡面有高可用、高併發、高效能及分散式、Jvm效能調優、Spring原始碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!



相關文章