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

在先後端分離的項目中,登陸策略也有很多,不過 JWT 算是目前比較流行的一種解決方案了,本文就和你們來分享一下如何將 Spring Security 和 JWT 結合在一塊兒使用,進而實現先後端分離時的登陸解決方案。前端

1 無狀態登陸

1.1 什麼是有狀態?

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

  • 服務端保存大量數據,增長服務端壓力
  • 服務端保存用戶狀態,不支持集羣化部署

1.2 什麼是無狀態

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

  • 服務端不保存任何客戶端請求者信息
  • 客戶端的每次請求必須具有自描述信息,經過這些信息識別客戶端身份

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

  • 客戶端請求不依賴服務端的信息,屢次請求不須要必須訪問到同一臺服務器
  • 服務端的集羣和狀態對客戶端透明
  • 服務端能夠任意的遷移和伸縮(能夠方便的進行集羣化部署)
  • 減少服務端存儲壓力

1.3.如何實現無狀態

無狀態登陸的流程:web

  • 首先客戶端發送帳戶名/密碼到服務端進行認證
  • 認證經過後,服務端將用戶信息加密而且編碼成一個token,返回給客戶端
  • 之後客戶端每次發送請求,都須要攜帶認證的token
  • 服務端對客戶端發送來的token進行解密,判斷是否有效,而且獲取用戶登陸信息

1.4 JWT

1.4.1 簡介

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

圖片描述

JWT 做爲一種規範,並無和某一種語言綁定在一塊兒,經常使用的Java 實現是GitHub 上的開源項目 jjwt,地址以下:https://github.com/jwtk/jjwt算法

1.4.2 JWT數據格式

JWT包含三部分數據:spring

  • Header:頭部,一般頭部有兩部分信息:數據庫

    • 聲明類型,這裏是JWT
    • 加密算法,自定義

咱們會對頭部進行Base64Url編碼(可解碼),獲得第一部分數據。json

  • 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中配置的加密算法生成。用於驗證整個數據完整和可靠性。

生成的數據格式以下圖:

圖片描述

注意,這裏的數據經過 . 隔開成了三部分,分別對應前面提到的三部分,另外,這裏數據是不換行的,圖片換行只是爲了展現方便而已。

1.4.3 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();
    }
}

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

  1. 自定義 JwtLoginFilter 繼承自 AbstractAuthenticationProcessingFilter,並實現其中的三個默認方法。
  2. attemptAuthentication方法中,咱們從登陸參數中提取出用戶名密碼,而後調用AuthenticationManager.authenticate()方法去進行自動校驗。
  3. 第二步若是校驗成功,就會來到successfulAuthentication回調中,在successfulAuthentication方法中,將用戶角色遍歷而後用一個 , 鏈接起來,而後再利用Jwts去生成token,按照代碼的順序,生成過程一共配置了四個參數,分別是用戶角色、主題、過時時間以及加密算法和密鑰,而後將生成的token寫出到客戶端。
  4. 第二步若是校驗失敗就會來到unsuccessfulAuthentication方法中,在這個方法中返回一個錯誤提示給客戶端便可。

再來看第二個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 中進行測試,以下:

圖片描述

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

圖片描述

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

圖片描述

能夠看到,咱們設置信息,因爲base64並非加密方案,只是一種編碼方案,所以,不建議將敏感的用戶信息放到token中。

接下來再去訪問 /hello 接口,注意認證方式選擇 Bearer Token,Token值爲剛剛獲取到的值,以下:

圖片描述

能夠看到,訪問成功。

總結

這就是 JWT 結合 Spring Security 的一個簡單用法,講真,若是實例容許,相似的需求我仍是推薦使用 OAuth2 中的 password 模式。

不知道大夥有沒有看懂呢?若是沒看懂,鬆哥還有一個關於這個知識點的視頻教程,以下:

圖片描述

如何獲取這個視頻教程呢?很簡單,將本文轉發到一個超過100人的微信羣中(QQ羣不算,鬆哥是羣主的微信羣也不算,羣要爲Java方向),或者多個微信羣中,只要累計人數達到100人便可,而後加鬆哥微信,截圖發給鬆哥便可獲取資料。

關注公衆號【江南一點雨】,專一於 Spring Boot+微服務以及先後端分離等全棧技術,按期視頻教程分享,關注後回覆 Java ,領取鬆哥爲你精心準備的 Java 乾貨!

相關文章
相關標籤/搜索