【SpringBoot技術專題】「JWT技術專區」SpringSecurity整合JWT授權和認證實現

JWT基本概念

JWT,即 JSON Web Tokens(RFC 7519),是一個廣泛用於驗證 REST APIs 的標準。雖說是一個新興技術,但它卻得以迅速流行。

  • JWT的驗證過程是:

    • 前端(客戶端)首先發送一些憑證來登錄(我們編寫的是 web 應用,所以這裡使用用戶名和密碼來做驗證)。

    • 後端(服務端)這裡指Spring應用校驗這些憑證,如果校驗通過則生成並返回一個 JWT。

    • 客戶端需要在請求頭的Authorization欄位中以 「Bearer TOKEN」 的形式攜帶獲取到的token,服務端會檢查這個token是否可用並決定授權訪問或拒絕請求。

      • token中可能保存了用戶的角色資訊,服務端可以根據用戶角色來確定訪問許可權。

實現

我們來看一下在實際的 Spring 項目中是如何實現JWT登錄和保存機制的。

依賴

下面是我們示例程式碼的 Maven 依賴列表,注意,截圖中並未包含Spring Boot、Hibernate等核心依賴(你需要自行添加)。

用戶模型

  • 創建一個包含保存用戶資訊、基於用戶名和密碼驗證用戶許可權功能的 controller。
  • 創建一個名為 User 的實體類,它是資料庫中 USER 表的映射。需要的話,可以在其中添加其他屬性。

  • 還需要定義一個 UserRepository 類來保存用戶資訊,重寫其 findByUsername 方法,在驗證過程中會用到。
public interface UserRepository extends JpaRepository<User, String>{ 
    User findByUsername(String username); 
}
  • 千萬不能在資料庫中保存明文密碼,因為很多用戶喜歡在各種網站上使用相同的密碼。

  • 哈希演算法有很多,BCrypt是最常用的之一,它也是推薦用於安全加密的演算法。關於這個話題的更多內容,可以查看 這篇文章

為了加密密碼,我們在 @bean 註解標記的主類中定義一個 BCrypt Bean,如下所示:

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder(); 
}

加密密碼的時候將會調用這個Bean裡面的方法。

  • 創建一個名為 UserController 的類,為其添加 @RestController 註解並定義路由映射。

  • 在這個應用中,我們接收前端傳入的 UserDto 對象來保存用戶資訊。你也可以選擇在 @RequestBody 參數中接收 User 對象。

@RestController
@RequestMapping("/api/services/controller/user")
@AllArgsConstructor
public class UserController {
    private UserService userService;
	@PostMapping()
	public ResponseEntity<String> saveUser(@RequestBody UserDto userDto) {
		return new ResponseEntity<>(userService.saveDto(userDto), HttpStatus.OK);
	}
}

我們使用之前定義的 BCrypt Bean 來加密傳入的 UserDto 對象的 password 欄位。這個操作也可以在 controller 之中執行,但是把邏輯操作集中到 service 類中是更好的做法。

@Transactional(rollbackFor = Exception.class)
public String saveDto(UserDto userDto) {                 
	userDto.setPassword(bCryptPasswordEncoder.encode(userDto.getPassword()));
    return save(new User(userDto)).getId();
}

驗證過濾器

需要通過許可權驗證來確定用戶的真實身份。這裡我們使用經典的【用戶名-密碼對】的形式來完成。

驗證步驟:

  • 創建繼承 UsernamePasswordAuthenticationFilter 的驗證過濾器
  • 創建繼承 WebSecurityConfigurerAdapter 的安全配置類並應用過濾器
  • 驗證過濾器的程式碼如下——也許你已經知道了,過濾器是 Spring Security 的核心。
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private AuthenticationManager authenticationManager;
    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        setFilterProcessesUrl("/api/services/controller/user/login"); 
    }
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req,
                                                HttpServletResponse res) throws AuthenticationException {
        try {
            User creds = new ObjectMapper().readValue(req.getInputStream(), User.class);
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            creds.getUsername(),
                            creds.getPassword(),
                            new ArrayList<>())
            );
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    @Override
    protected void successfulAuthentication(HttpServletRequest req,
                                            HttpServletResponse res,
                                            FilterChain chain,
                                            Authentication auth) throws IOException {
        String token = JWT.create()
                .withSubject(((User) auth.getPrincipal()).getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .sign(Algorithm.HMAC512(SECRET.getBytes()));
        String body = ((User) auth.getPrincipal()).getUsername() + " " + token;
        res.getWriter().write(body);
        res.getWriter().flush();
    }
}
  • Spring Security 默認使用繼承了 UsernamePasswordAuthenticationFilter 的子類進行密碼驗證 ,我們可以在其中編寫自定義的驗證邏輯。

    • 我們在構造函數中調用setFilterProcessesUrl 方法,設置默認登錄地址。

    • 如果刪除這行程式碼,Spring Security 會生成一個默認的 「/login」 端點,我們可以不用在 controller 中顯式地定義登錄端點。

    • 這行程式碼執行之後,我們的登錄端點將被設置為 /api/services/controller/user/login,你可以根據自己的實際程式碼來設置。

  • 我們重寫了 UsernameAuthenticationFilter 類的 attemptAuthentication 和 successfulAuthentication 方法。

    • 用戶登錄時會執行 attemptAuthentication方法,它會讀取憑證資訊、創建用戶 POJO、校驗憑證並授權。

      • 我們傳入用戶名、密碼以及一個空列表。我們還沒有定義用戶角色,所以把這個表示用戶許可權(角色)的列表留空就行。
  • 如果驗證成功,就會執行 successfulAuthentication 方法,它的參數由Spring Security自動注入。

    • attemptAuthentication返回Authentication對象,這個對象包含了我們傳入的許可權資訊。

    • 我們想在驗證成功之後返回一個使用用戶名、密鑰和過期時間創建的 token。先定義SECRET和 EXPIRATION_DATE。


public class SecurityConstants {
  public static final String SECRET = "SECRET_KEY";
  public static final long EXPIRATION_TIME = 900_000; // 15 mins
  public static final String TOKEN_PREFIX = "Bearer ";
  public static final String HEADER_STRING = "Authorization";
  public static final String SIGN_UP_URL = "/api/services/controller/user";
}
  • 創建一個類作為常量的容器,SECRET 的值可以任意設置,最佳的做法是在 hash 演算法支援的範圍內使用儘可能長的字元串。例如我們使用的是 HS256 演算法,SECRET 字元串的最佳長度即為 256 bits/32 個字元。

  • 超時時間設置為 15 分鐘,這是防禦暴力破解密碼的最佳實踐。此處使用的時間單位為毫秒。

  • 驗證過濾器準備好了,但還不可用,我們還要創建一個授權過濾器,再通過一個配置類來應用它們。

  • 授權過濾器會校驗 Authorization 請求頭中的 token 是否存在及其可用性。在配置類中指明哪些端點需要使用這個過濾器。

授權過濾器

  • doFilterInternal 方法攔截請求並校驗 Authorization 請求頭,如果不存在或者它的值不是以 「BEARER」 開頭,則直接轉到下一個過濾器。

  • 如果這個請求頭攜帶了合法的值,會調用 getAuthentication 方法,校驗這個 JWT,如果這個 token 是可用的,它會返回一個Spring內部使用的 token。

  • 這個新生成的 token 會被保存在 SecurityContext 中,如果需要基於用戶角色進行授權的話,可以向這個 token 傳入用戶許可權。

過濾器都準備好了,現在要通過配置類把它們投入使用。

public class JWTAuthorizationFilter extends BasicAuthenticationFilter {

    public JWTAuthorizationFilter(AuthenticationManager authManager) {
        super(authManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String header = req.getHeader(HEADER_STRING);
        if (header == null || !header.startsWith(TOKEN_PREFIX)) {
            chain.doFilter(req, res);
            return;
        }
        UsernamePasswordAuthenticationToken authentication = getAuthentication(req);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        chain.doFilter(req, res);
    }
    // Reads the JWT from the Authorization header, and then uses JWT to validate the token
    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        String token = request.getHeader(HEADER_STRING);
        if (token != null) {
            // parse the token.
            String user = JWT.require(Algorithm.HMAC512(SECRET.getBytes()))
                    .build()
                    .verify(token.replace(TOKEN_PREFIX, ""))
                    .getSubject();
            if (user != null) {
                // new arraylist means authorities
                return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
            }
            return null;
        }
        return null;
    }

配置處理

  • 給這個類添加 @EnableWebSecurity 註解,同時讓它繼承 WebSecurityConfigureAdapter 並實現自定義的安全邏輯。

  • 自動注入之前定義的 BCrypt Bean,同時自動注入 UserDetailsService 用來獲取用戶賬戶資訊。

  • 最重要的是那個接收一個 HttpSecurity 對象作為參數的方法,其中聲明了如何在各個端點中應用過濾器、配置了 CORS、放行了所有對註冊介面的 POST 請求。

  • 可以添加其他匹配器來基於 URL 模式和角色進行過濾,你也可以 查看 StackOverflow 上這個問題的相關示例。另一個方法配置了 AuthenticationManager 在登錄校驗時使用我們指定的編碼器。

@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {

    private UserDetailsServiceImpl userDetailsService;
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public WebSecurity(UserDetailsServiceImpl userService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.userDetailsService = userService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().authorizeRequests()
                .antMatchers(HttpMethod.POST, SIGN_UP_URL).permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))
                // this disables session creation on Spring Security
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration().applyPermitDefaultValues();
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }
}

測試實現

發送一些請求來測試應用是否正常工作。

  • 使用 GET 請求訪問受保護的資源,服務端返回了 403 狀態碼。

  • 這是程式設計預期的行為,因為我們沒有在請求頭中攜帶 token 資訊。

現在創建一個用戶:

發送一個攜帶了用戶資訊數據的 POST 請求,以創建用戶。稍後將登陸這個賬戶來獲取 token。

獲取到 token 了,現在可以用這個 token 來訪問受保護的資源。

在 Authorization 請求頭中攜帶 token,就可以訪問受保護的端點了。

總結

Spring 中實現 JWT 授權和密碼認證的步驟,同時學習了如何安全地保存用戶資訊。

參考內容

How to Set Up Java Spring Boot JWT Authorization and