【SpringBoot技術專題】「JWT技術專區」SpringSecurity整合JWT授權和認證實現
- 2021 年 8 月 16 日
- 筆記
- SpringBoot-技術專區-系列專題
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