JWT基本概念
JWT,即 JSON Web Tokens(RFC 7519),是一個廣泛用於驗證 REST APIs 的標準。雖說是一個新興技術,但它卻得以迅速流行。
JWT的驗證過程是:
前端(客戶端)首先發送一些憑證來登入(我們編寫的是 web 應用,所以這裡使用使用者名稱和密碼來做驗證)。
後端(服務端)這裡指Spring應用校驗這些憑證,如果校驗通過則生成並返回一個 JWT。
客戶端需要在請求頭的Authorization欄位中以 “Bearer TOKEN” 的形式攜帶獲取到的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