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