天天看點

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)

學習在 Spring Boot 中整合 Spring Security 和 JWT ,實作無狀态登入,可做為前後端分離時的解決方案,技術上沒問題,但實際上還是推薦使用 OAuth2 中的 password 模式。

1 登入概述

1.1 有狀态登入

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

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

1.2 無狀态登入

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

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

優勢:

  • 用戶端請求不依賴服務端的資訊,多次請求不需要必須通路到同一台伺服器。
  • 服務端的叢集和狀态對用戶端透明。
  • 服務端可以任意的遷移和伸縮(可以友善的進行叢集化部署)。
  • 減小服務端存儲壓力。

1.3 無狀态登入的流程

無狀态登入的流程:

  1. 首先用戶端發送賬戶名/密碼到服務端進行認證。
  2. 認證通過後,服務端将使用者資訊加密并且編碼成一個 token ,傳回給用戶端。
  3. 以後用戶端每次發送請求,都需要攜帶認證的 token 。
  4. 服務端對用戶端發送來的 token 進行解密,判斷是否有效,并且擷取使用者登入資訊。

2 JWT 概述

2.1 JWT 簡介

JWT (Json Web Token),是一種 JSON 風格的輕量級的授權和身份認證規範,可實作無狀态、分布式的 Web 應用授權。官網:https://jwt.io/

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)

JWT 作為一種規範,并沒有和某一種語言綁定在一起,常用的 Java 實作是 GitHub 上的開源項目

jjwt

,位址如下:https://github.com/jwtk/jjwt

2.2 JWT 資料格式

JWT 包含三部分資料:

  1. Header

    :頭部,通常頭部有兩部分資訊:
    • 聲明類型,這裡是 JWT 。
    • 加密算法,自定義。
    我們會對頭部進行 Base64 編碼(可解碼),得到第一部分資料。
  2. Payload

    :載荷,就是有效資料,在官方文檔中(RFC7519),這裡給了 7 個示例資訊:
    • iss (issuer):表示簽發人。
    • exp (expiration time):表示token過期時間。
    • sub (subject):主題。
    • aud (audience):閱聽人。
    • nbf (Not Before):生效時間。
    • iat (Issued At):簽發時間。
    • jti (JWT ID):編号。
    這部分也會采用 Base64 編碼,得到第二部分資料。
  3. Signature

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

比如,生成的資料格式:

eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ.FuPIltzXi5j14t_gSL1GoIMUZxTHKK0FvB3gds6eTZFDkQr1ZxWVxdqZ5YFbCxdkwQ_VXtPK-GgcW5Kzzx3wvw

注意,這裡的資料通過

.

隔開成了三部分,分别對應前面提到的三部分:

  1. Header :頭部(聲明類型、加密算法),采用 Base64 編碼,如:

    eyJhbGciOiJIUzUxMiJ9

  2. Payload :載荷,就是有效資料,采用 Base64 編碼,如:

    eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ

  3. Signature :簽名,如:

    FuPIltzXi5j14t_gSL1GoIMUZxTHKK0FvB3gds6eTZFDkQr1ZxWVxdqZ5YFbCxdkwQ_VXtPK-GgcW5Kzzx3wvw

2.3 JWT 互動流程

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)
  1. 應用程式或用戶端向授權伺服器請求授權。
  2. 擷取到授權後,授權伺服器會向應用程式傳回通路令牌。
  3. 應用程式使用通路令牌來通路受保護資源(如 API )。

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

2.4 JWT 問題

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

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

3 實戰

3.1 建立工程

建立 Spring Boot 項目

spring-boot-springsecurity-jwt

,添加

Web/Spring Security

依賴,如下:

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)

之後手動在 pom 檔案中添加

jjwt

依賴,最終的依賴如下:

<dependencies>
    <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>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
           

3.2 建立接口

建立實體類

User

實作

UserDetails

接口,如下:

public class User implements UserDetails {
    private String username;
    private String password;
    private List<GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    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;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setAuthorities(List<GrantedAuthority> authorities) {
        this.authorities = authorities;
    }
}
           

建立

HelloController

,如下:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }

    @GetMapping("/admin/hello")
    public String admin() {
        return "hello admin";
    }

    @GetMapping("/user/hello")
    public String user() {
        return "hello user";
    }
}
           

3.3 配置過濾器

這裡主要配置兩個過濾器:

  • 使用者登入的過濾器
// 過濾器1:使用者登入的過濾器,在使用者的登入的過濾器中校驗使用者是否登入成功,
// 如果登入成功,則生成一個 token 傳回給用戶端,登入失敗則給前端一個登入失敗的提示
public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {

    public JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
        setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException {
        // 這裡隻支援 JSON 的登入方式
        // 如果想表單方式也支援,可參考 spring-boot-springsecurity-loginbyjson 中的 MyAuthenticationFilter
        // 擷取輸入參數,如 {"username":"user","password":"123456"}
        User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
        // 進行登入校驗,如果校驗成功,會到 successfulAuthentication 的回調中,否則到 unsuccessfulAuthentication 的回調中
        return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 擷取登入使用者的角色
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
        StringBuffer sb = new StringBuffer();
        for (GrantedAuthority authority : authorities) {
            sb.append(authority.getAuthority()).append(",");
        }

        // 生成 token 并傳回
        // 資料格式:分 3 部分用 . 隔開,如:eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ.FuPIltzXi5j14t_gSL1GoIMUZxTHKK0FvB3gds6eTZFDkQr1ZxWVxdqZ5YFbCxdkwQ_VXtPK-GgcW5Kzzx3wvw
        // 1.Header:頭部(聲明類型、加密算法),采用 Base64 編碼,如:eyJhbGciOiJIUzUxMiJ9
        // 2.Payload:載荷,就是有效資料,采用 Base64 編碼,如:eyJhdXRob3JpdGllcyI6IlJPTEVfdXNlciwiLCJzdWIiOiJ1c2VyIiwiZXhwIjoxNTc0NzczNTkyfQ
        // 3.Signature:簽名,是整個資料的認證資訊。一般根據前兩步的資料,再加上服務的的密鑰 secret (密鑰儲存在服務端,不能洩露給用戶端),通過 Header 中配置的加密算法生成。用于驗證整個資料完整和可靠性。
        String jwt = Jwts.builder()
                .claim("authorities", sb) // 配置使用者角色
                .setSubject(authResult.getName()) // 配置主題
                .setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 1000)) // 配置過期時間
                .signWith(SignatureAlgorithm.HS512, "[email protected]") // 配置加密算法和密鑰
                .compact();
        resp.setContentType("application/json;charset=utf-8");
        Map<String, String> map = new HashMap<>();
        map.put("token", jwt);
        map.put("msg", "登入成功");
        PrintWriter out = resp.getWriter();
        out.write(new ObjectMapper().writeValueAsString(map));
        out.flush();
        out.close();
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
        resp.setContentType("application/json;charset=utf-8");
        Map<String, String> map = new HashMap<>();
        map.put("msg", "登入失敗");
        PrintWriter out = resp.getWriter();
        out.write(new ObjectMapper().writeValueAsString(map));
        out.flush();
        out.close();
    }
}
           
  • 校驗 token 的過濾器
// 過濾器2:當其他請求發送來,校驗 token 的過濾器,如果校驗成功,就讓請求繼續執行
// 請求時注意認證方式選擇 Bearer Token
public class JwtFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        // 擷取 token ,注意擷取方式要跟前台傳的方式保持一緻
        // 這裡請求時注意認證方式選擇 Bearer Token,會用 header 傳遞
        String jwtToken = req.getHeader("authorization");
        // 注意 "[email protected]" 要與生成 token 時的保持一緻
        Jws<Claims> jws = Jwts.parser().setSigningKey("[email protected]")
                .parseClaimsJws(jwtToken.replace("Bearer", ""));
        Claims claims = jws.getBody();
        // 擷取使用者名
        String username = claims.getSubject();
        // 擷取使用者角色,注意 "authorities" 要與生成 token 時的保持一緻
        List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(token);
        filterChain.doFilter(servletRequest, servletResponse);
    }
}
           

3.4 配置 Spring Security

新增

SecurityConfig

配置類,如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        // return NoOpPasswordEncoder.getInstance();// 密碼不加密
        return new BCryptPasswordEncoder();// 密碼加密
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 在記憶體中配置2個使用者
        /*auth.inMemoryAuthentication()
                .withUser("admin").password("123456").roles("admin")
                .and()
                .withUser("user").password("123456").roles("user");// 密碼不加密*/

        auth.inMemoryAuthentication()
                .withUser("admin").password("$2a$10$fB2UU8iJmXsjpdk6T6hGMup8uNcJnOGwo2.QGR.e3qjIsdPYaS4LO").roles("admin")
                .and()
                .withUser("user").password("$2a$10$3TQ2HO/Xz1bVHw5nlfYTBON2TDJsQ0FMDwAS81uh7D.i9ax5DR46q").roles("user");// 密碼加密
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .antMatchers("/user/**").access("hasAnyRole('user','admin')")
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtLoginFilter("/login", authenticationManager()), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class)
                .csrf().disable();
    }
}
           

3.5 測試

項目啟動之後,用

Postman

完成測試。

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)

取出 token 的第 1 部分, Base64 解碼得到 Header ,如下:

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)

取出 token 的第 2 部分, Base64 解碼得到 Payload ,如下:

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)

因為 Base64 是一種編碼方案,并不是加密方案,是以不建議将使用者的敏感資訊放在 token 中。

最後拿着上述 token 通路

/user/hello

,可正常通路。注意:認證方式 Authorization 選擇 Bearer Token 。

  • Spring Boot 教程合集(微信左下方閱讀全文可直達)。
  • Spring Boot 教程合集示例代碼:https://github.com/cxy35/spring-boot-samples
  • 本文示例代碼:https://github.com/cxy35/spring-boot-samples/tree/master/spring-boot-security/spring-boot-springsecurity-jwt

掃碼關注微信公衆号 程式員35 ,擷取最新技術幹貨,暢聊 #程式員的35,35的程式員# 。獨立站點:https://cxy35.com

Spring Boot 整合 Spring Security + JWT(實作無狀态登入)

繼續閱讀