項目GitHub:github.com/Smith-Cruis…前端
我以前寫過兩篇關於安全框架的問題,你們能夠大體看一看,打下基礎。java
Shiro+JWT+Spring Boot Restful簡易教程git
Spring Boot+Spring Security+Thymeleaf 簡單教程github
在開始前你至少須要瞭解 Spring Security
的基本配置和 JWT
機制。spring
一些關於 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
的官方設計,若是有更好的方法請指正。restful
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;
}
}
複製代碼
用於判斷用戶 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;
}
}
複製代碼
接下來咱們要使用屬於本身的過濾器,考慮到 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
是否合法,這樣很是浪費資源還影響效率。
咱們能夠在 JWTAuthenticationManager
使用緩存。
當用戶第一次訪問,咱們查詢數據庫判斷 token
是否合法,若是合法將其放入緩存(緩存過時時間和token過時時間一致),此後每一個請求先去緩存中尋找,若是存在則跳過請求數據庫環節,直接當作該 token
合法。
在 JWTAuthenticationManager
中編寫方法,當 token
即將過時時拋出一個特定的異常,例如 ReAuthenticateException
,而後咱們在 JWTAuthenticationFilter
中單獨捕獲這個異常,返回一個特定的 http
狀態碼,而後前端去單獨另外訪問 GET /re_authentication
獲取一個新的token來替代掉本來的,同時從緩存中刪除老的 token
。