準備
專案GitHub:github.com/Smith-Cruis…
我之前寫過兩篇關於安全框架的問題,大家可以大致看一看,打下基礎。
Shiro+JWT+Spring Boot Restful簡易教程
Spring Boot+Spring Security+Thymeleaf 簡單教程
在開始前你至少需要了解 Spring Security
的基本配置和 JWT
機制。
一些關於 Maven
的配置和 Controller
的編寫這裡就不說了,自己看下原始碼即可。
本專案中 JWT
金鑰是使用使用者自己的登入密碼,這樣每一個 token
的金鑰都不同,相對比較安全。
改造思路
平常我們使用 Spring Security
會用到 UsernamePasswordAuthenticationFilter
和 UsernamePasswordAuthenticationToken
這兩個類,但這兩個類初衷是為了解決表單登入,對 JWT
這類 Token
鑑權的方式並不是很友好。所以我們要開發屬於自己的 Filter
和 AuthenticationToken 來替換掉
Spring Security
自帶的類。
同時預設的 Spring Security
鑑定使用者是使用了 ProviderManager
這個類進行判斷,同時 ProviderManager
會呼叫 AuthenticationUserDetailsService
這個介面中的 UserDetails loadUserDetails(T token) throws UsernameNotFoundException
來從資料庫中獲取使用者資訊(這個方法需要使用者自己繼承實現)。因為考慮到自帶的實現方式並不能很好的支援JWT,例如 UsernamePasswordAuthenticationToken
中有 username
和 password
欄位進行賦值,但是 JWT
是附帶在請求的 header
中,只有一個 token ,何來 username
和 password
這種說法。
所以我對其進行了大換血,例如獲取使用者的方法並沒有在 AuthenticationUserDetailsService
中實現,但這樣就可能不能完美的遵守 Spring Security
的官方設計,如果有更好的方法請指正。
改造
改造 Authentication
Authentication
是 Security
官方提供的一個介面,是儲存在 SecurityContextHolder
供呼叫鑑權使用的核心。
這裡主要說下三個方法
getCredentials()
原本是用於獲取密碼,現我們打算用其存放前端傳遞過來的 token
getPrincipal()
原本用於存放使用者資訊,現在我們繼續保留。比如儲存一些使用者的 username
,id
等關鍵資訊供 Controller
中使用
getDetails()
原本返回一些客戶端 IP
等雜項,但是考慮到這裡基本都是 restful
這類無狀態請求,這個就顯的無關緊要 ,所以就被閹割了:happy:
預設提供的Authentication介面
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
複製程式碼
JWTAuthenticationToken
我們編寫屬於自己的 Authentication
,注意兩個構造方法的不同。 AbstractAuthenticationToken
是官方實現 Authentication
的一個類。
public class JWTAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private final Object credentials;
/**
* 鑑定token前使用的方法,因為還沒有鑑定token是否合法,所以要setAuthenticated(false)
* @param token JWT金鑰
*/
public JWTAuthenticationToken(String token) {
super(null);
this.principal = null;
this.credentials = token;
setAuthenticated(false);
}
/**
* 鑑定成功後呼叫的方法,返回的JWTAuthenticationToken供Controller裡面呼叫。
* 因為已經鑑定成功,所以要setAuthenticated(true)
* @param token JWT金鑰
* @param userInfo 一些使用者的資訊,比如username, id等
* @param authorities 所擁有的許可權
*/
public JWTAuthenticationToken(String token, Object userInfo, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = userInfo;
this.credentials = token;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
}
複製程式碼
改造 AuthenticationManager
用於判斷使用者 token
是否合法
JWTAuthenticationManager
@Component
public class JWTAuthenticationManager implements AuthenticationManager {
@Autowired
private UserService userService;
/**
* 進行token鑑定
* @param authentication 待鑑定的JWTAuthenticationToken
* @return 鑑定完成的JWTAuthenticationToken,供Controller使用
* @throws AuthenticationException 如果鑑定失敗,丟擲
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String token = authentication.getCredentials().toString();
String username = JWTUtil.getUsername(token);
UserEntity userEntity = userService.getUser(username);
if (userEntity == null) {
throw new UsernameNotFoundException("該使用者不存在");
}
/*
* 官方推薦在本方法中必須要處理三種異常,
* DisabledException、LockedException、BadCredentialsException
* 這裡為了方便就只處理了BadCredentialsException,大家可以根據自己業務的需要進行定製
* 詳情看AuthenticationManager的JavaDoc
*/
boolean isAuthenticatedSuccess = JWTUtil.verify(token, username, userEntity.getPassword());
if (! isAuthenticatedSuccess) {
throw new BadCredentialsException("使用者名稱或密碼錯誤");
}
JWTAuthenticationToken authenticatedAuth = new JWTAuthenticationToken(
token, userEntity, AuthorityUtils.commaSeparatedStringToAuthorityList(userEntity.getRole())
);
return authenticatedAuth;
}
}
複製程式碼
開發屬於自己的 Filter
接下來我們要使用屬於自己的過濾器,考慮到 token
是附加在 header
中,這和 BasicAuthentication
認證很像,所以我們繼承 BasicAuthenticationFilter
進行重寫核心方法改造。
JWTAuthenticationFilter
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
/**
* 使用我們自己開發的JWTAuthenticationManager
* @param authenticationManager 我們自己開發的JWTAuthenticationManager
*/
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header == null || !header.toLowerCase().startsWith("bearer ")) {
chain.doFilter(request, response);
return;
}
try {
String token = header.split(" ")[1];
JWTAuthenticationToken JWToken = new JWTAuthenticationToken(token);
// 鑑定許可權,如果鑑定失敗,AuthenticationManager會丟擲異常被我們捕獲
Authentication authResult = getAuthenticationManager().authenticate(JWToken);
// 將鑑定成功後的Authentication寫入SecurityContextHolder中供後序使用
SecurityContextHolder.getContext().setAuthentication(authResult);
} catch (AuthenticationException failed) {
SecurityContextHolder.clearContext();
// 返回鑑權失敗
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, failed.getMessage());
return;
}
chain.doFilter(request, response);
}
}
複製程式碼
配置
SecurityConfig
// 開啟方法註解功能
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JWTAuthenticationManager jwtAuthenticationManager;
@Override
protected void configure(HttpSecurity http) throws Exception {
// restful具有先天的防範csrf攻擊,所以關閉這功能
http.csrf().disable()
// 預設允許所有的請求通過,後序我們通過方法註解的方式來粒度化控制許可權
.authorizeRequests().anyRequest().permitAll()
.and()
// 新增屬於我們自己的過濾器,注意因為我們沒有開啟formLogin(),所以UsernamePasswordAuthenticationFilter根本不會被呼叫
.addFilterAt(new JWTAuthenticationFilter(jwtAuthenticationManager), UsernamePasswordAuthenticationFilter.class)
// 前後端分離本身就是無狀態的,所以我們不需要cookie和session這類東西。所有的資訊都儲存在一個token之中。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
複製程式碼
關於方法註解鑑權 這塊有很多奇淫巧技,可以看看 Spring Boot+Spring Security+Thymeleaf 簡單教程 這篇文章
統一全域性異常
一個 restful
最後的異常丟擲肯定是要格式統一的,這樣才方便前端的呼叫。
我們平常會使用 RestControllerAdvice
來統一異常,但是他只能管理我們自己丟擲的異常,而管不住框架本身的異常,比如404啥的,所以我們還要改造 ErrorController
ExceptionController
@RestControllerAdvice
public class ExceptionController {
// 捕捉控制器裡面自己丟擲的所有異常
@ExceptionHandler(Exception.class)
public ResponseEntity<ResponseBean> globalException(Exception ex) {
return new ResponseEntity<>(
new ResponseBean(
HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage(), null), HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
複製程式碼
CustomErrorController
如果直接去實現 ErrorController
這個介面,有很多現成方法都沒有,不好用,所以我們選擇 AbstractErrorController
@RestController
public class CustomErrorController extends AbstractErrorController {
// 異常路徑網址
private final String PATH = "/error";
public CustomErrorController(ErrorAttributes errorAttributes) {
super(errorAttributes);
}
@RequestMapping("/error")
public ResponseEntity<ResponseBean> error(HttpServletRequest request) {
// 獲取request中的異常資訊,裡面有好多,比如時間、路徑啥的,大家可以自行遍歷map檢視
Map<String, Object> attributes = getErrorAttributes(request, true);
// 這裡只選擇返回message欄位
return new ResponseEntity<>(
new ResponseBean(
getStatus(request).value() , (String) attributes.get("message"), null), getStatus(request)
);
}
@Override
public String getErrorPath() {
return PATH;
}
}
複製程式碼
測試
寫個控制器試試,大家也可以參考我控制器裡面獲取使用者資訊的方式,推薦使用 @AuthenticationPrincipal
這個方法!!!
@RestController
public class MainController {
@Autowired
private UserService userService;
// 登入,獲取token
@PostMapping("login")
public ResponseEntity<ResponseBean> login(@RequestParam String username, @RequestParam String password) {
UserEntity userEntity = userService.getUser(username);
if (userEntity==null || !userEntity.getPassword().equals(password)) {
return new ResponseEntity<>(new ResponseBean(HttpStatus.BAD_REQUEST.value(), "login fail", null), HttpStatus.BAD_REQUEST);
}
// JWT簽名
String token = JWTUtil.sign(username, password);
return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "login success", token), HttpStatus.OK);
}
// 任何人都可以訪問,在方法中判斷使用者是否合法
@GetMapping("everyone")
public ResponseEntity<ResponseBean> everyone() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.isAuthenticated()) {
// 登入使用者
return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are already login", authentication.getPrincipal()), HttpStatus.OK);
} else {
return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are anonymous", null), HttpStatus.OK);
}
}
@GetMapping("user")
@PreAuthorize("hasAuthority('ROLE_USER')")
public ResponseEntity<ResponseBean> user(@AuthenticationPrincipal UserEntity userEntity) {
return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are user", userEntity), HttpStatus.OK);
}
@GetMapping("admin")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public ResponseEntity<ResponseBean> admin(@AuthenticationPrincipal UserEntity userEntity) {
return new ResponseEntity<>(new ResponseBean(HttpStatus.OK.value(), "You are admin", userEntity), HttpStatus.OK);
}
}
複製程式碼
其他
這裡簡單解答下一些常見問題。
鑑定Token是否合法是每次請求資料庫過於耗費資源
我們不可能每一次鑑定都去資料庫拿一次資料來判斷 token
是否合法,這樣非常浪費資源還影響效率。
我們可以在 JWTAuthenticationManager
使用快取。
當使用者第一次訪問,我們查詢資料庫判斷 token
是否合法,如果合法將其放入快取(快取過期時間和token過期時間一致),此後每個請求先去快取中尋找,如果存在則跳過請求資料庫環節,直接當做該 token
合法。
如何解決JWT過期問題
在 JWTAuthenticationManager
中編寫方法,當 token
即將過期時丟擲一個特定的異常,例如 ReAuthenticateException
,然後我們在 JWTAuthenticationFilter
中單獨捕獲這個異常,返回一個特定的 http
狀態碼,然後前端去單獨另外訪問 GET /re_authentication
獲取一個新的token來替代掉原本的,同時從快取中刪除老的 token
。