準(zhǔn)備
項(xiàng)目GitHub:https://github.com/Smith-Cruise/Spring-Boot-Security-JWT-SPA
我之前寫過兩篇關(guān)于安全框架的問題笛求,大家可以大致看一看国瓮,打下基礎(chǔ)。
Shiro+JWT+Spring Boot Restful簡(jiǎn)易教程
Spring Boot+Spring Security+Thymeleaf 簡(jiǎn)單教程
在開始前你至少需要了解 Spring Security
的基本配置和 JWT
機(jī)制特占。
一些關(guān)于 Maven
的配置和 Controller
的編寫這里就不說了,自己看下源碼即可云茸。
本項(xiàng)目中 JWT
密鑰是使用用戶自己的登入密碼是目,這樣每一個(gè) token
的密鑰都不同,相對(duì)比較安全标捺。
改造思路
平常我們使用 Spring Security
會(huì)用到 UsernamePasswordAuthenticationFilter
和 UsernamePasswordAuthenticationToken
這兩個(gè)類懊纳,但這兩個(gè)類初衷是為了解決表單登入,對(duì) JWT
這類 Token
鑒權(quán)的方式并不是很友好亡容。所以我們要開發(fā)屬于自己的 Filter
和 AuthenticationToken 來替換掉
Spring Security
自帶的類长踊。
同時(shí)默認(rèn)的 Spring Security
鑒定用戶是使用了 ProviderManager
這個(gè)類進(jìn)行判斷,同時(shí) ProviderManager
會(huì)調(diào)用 AuthenticationUserDetailsService
這個(gè)接口中的 UserDetails loadUserDetails(T token) throws UsernameNotFoundException
來從數(shù)據(jù)庫(kù)中獲取用戶信息(這個(gè)方法需要用戶自己繼承實(shí)現(xiàn))萍倡。因?yàn)榭紤]到自帶的實(shí)現(xiàn)方式并不能很好的支持JWT身弊,例如 UsernamePasswordAuthenticationToken
中有 username
和 password
字段進(jìn)行賦值,但是 JWT
是附帶在請(qǐng)求的 header
中列敲,只有一個(gè) token 阱佛,何來 username
和 password
這種說法。
所以我對(duì)其進(jìn)行了大換血戴而,例如獲取用戶的方法并沒有在 AuthenticationUserDetailsService
中實(shí)現(xiàn)凑术,但這樣就可能不能完美的遵守 Spring Security
的官方設(shè)計(jì),如果有更好的方法請(qǐng)指正所意。
改造
改造 Authentication
Authentication
是 Security
官方提供的一個(gè)接口淮逊,是保存在 SecurityContextHolder
供調(diào)用鑒權(quán)使用的核心催首。
這里主要說下三個(gè)方法
getCredentials()
原本是用于獲取密碼,現(xiàn)我們打算用其存放前端傳遞過來的 token
getPrincipal()
原本用于存放用戶信息泄鹏,現(xiàn)在我們繼續(xù)保留郎任。比如存儲(chǔ)一些用戶的 username
,id
等關(guān)鍵信息供 Controller
中使用
getDetails()
原本返回一些客戶端 IP
等雜項(xiàng)备籽,但是考慮到這里基本都是 restful
這類無(wú)狀態(tài)請(qǐng)求舶治,這個(gè)就顯的無(wú)關(guān)緊要 ,所以就被閹割了:happy:
默認(rèn)提供的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
车猬,注意兩個(gè)構(gòu)造方法的不同霉猛。 AbstractAuthenticationToken
是官方實(shí)現(xiàn) Authentication
的一個(gè)類。
public class JWTAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private final Object credentials;
/**
* 鑒定token前使用的方法珠闰,因?yàn)檫€沒有鑒定token是否合法惜浅,所以要setAuthenticated(false)
* @param token JWT密鑰
*/
public JWTAuthenticationToken(String token) {
super(null);
this.principal = null;
this.credentials = token;
setAuthenticated(false);
}
/**
* 鑒定成功后調(diào)用的方法,返回的JWTAuthenticationToken供Controller里面調(diào)用伏嗜。
* 因?yàn)橐呀?jīng)鑒定成功坛悉,所以要setAuthenticated(true)
* @param token JWT密鑰
* @param userInfo 一些用戶的信息,比如username, id等
* @param authorities 所擁有的權(quán)限
*/
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;
/**
* 進(jìn)行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刃唐,大家可以根據(jù)自己業(yè)務(wù)的需要進(jìn)行定制
* 詳情看AuthenticationManager的JavaDoc
*/
boolean isAuthenticatedSuccess = JWTUtil.verify(token, username, userEntity.getPassword());
if (! isAuthenticatedSuccess) {
throw new BadCredentialsException("用戶名或密碼錯(cuò)誤");
}
JWTAuthenticationToken authenticatedAuth = new JWTAuthenticationToken(
token, userEntity, AuthorityUtils.commaSeparatedStringToAuthorityList(userEntity.getRole())
);
return authenticatedAuth;
}
}
開發(fā)屬于自己的 Filter
接下來我們要使用屬于自己的過濾器羞迷,考慮到 token
是附加在 header
中,這和 BasicAuthentication
認(rèn)證很像画饥,所以我們繼承 BasicAuthenticationFilter
進(jìn)行重寫核心方法改造衔瓮。
JWTAuthenticationFilter
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
/**
* 使用我們自己開發(fā)的JWTAuthenticationManager
* @param authenticationManager 我們自己開發(fā)的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);
// 鑒定權(quán)限,如果鑒定失敗抖甘,AuthenticationManager會(huì)拋出異常被我們捕獲
Authentication authResult = getAuthenticationManager().authenticate(JWToken);
// 將鑒定成功后的Authentication寫入SecurityContextHolder中供后序使用
SecurityContextHolder.getContext().setAuthentication(authResult);
} catch (AuthenticationException failed) {
SecurityContextHolder.clearContext();
// 返回鑒權(quán)失敗
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攻擊热鞍,所以關(guān)閉這功能
http.csrf().disable()
// 默認(rèn)允許所有的請(qǐng)求通過,后序我們通過方法注解的方式來粒度化控制權(quán)限
.authorizeRequests().anyRequest().permitAll()
.and()
// 添加屬于我們自己的過濾器衔彻,注意因?yàn)槲覀儧]有開啟formLogin()薇宠,所以UsernamePasswordAuthenticationFilter根本不會(huì)被調(diào)用
.addFilterAt(new JWTAuthenticationFilter(jwtAuthenticationManager), UsernamePasswordAuthenticationFilter.class)
// 前后端分離本身就是無(wú)狀態(tài)的,所以我們不需要cookie和session這類東西艰额。所有的信息都保存在一個(gè)token之中澄港。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
關(guān)于方法注解鑒權(quán) 這塊有很多奇淫巧技,可以看看 Spring Boot+Spring Security+Thymeleaf 簡(jiǎn)單教程 這篇文章
統(tǒng)一全局異常
一個(gè) restful
最后的異常拋出肯定是要格式統(tǒng)一的柄沮,這樣才方便前端的調(diào)用回梧。
我們平常會(huì)使用 RestControllerAdvice
來統(tǒng)一異常废岂,但是他只能管理我們自己拋出的異常,而管不住框架本身的異常狱意,比如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
如果直接去實(shí)現(xiàn) ErrorController
這個(gè)接口,有很多現(xiàn)成方法都沒有髓涯,不好用袒啼,所以我們選擇 AbstractErrorController
@RestController
public class CustomErrorController extends AbstractErrorController {
// 異常路徑網(wǎng)址
private final String PATH = "/error";
public CustomErrorController(ErrorAttributes errorAttributes) {
super(errorAttributes);
}
@RequestMapping("/error")
public ResponseEntity<ResponseBean> error(HttpServletRequest request) {
// 獲取request中的異常信息,里面有好多纬纪,比如時(shí)間蚓再、路徑啥的,大家可以自行遍歷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;
}
}
測(cè)試
寫個(gè)控制器試試包各,大家也可以參考我控制器里面獲取用戶信息的方式摘仅,推薦使用 @AuthenticationPrincipal
這個(gè)方法!N食娃属!
@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);
}
}
其他
這里簡(jiǎn)單解答下一些常見問題矾端。
鑒定Token是否合法是每次請(qǐng)求數(shù)據(jù)庫(kù)過于耗費(fèi)資源
我們不可能每一次鑒定都去數(shù)據(jù)庫(kù)拿一次數(shù)據(jù)來判斷 token
是否合法,這樣非常浪費(fèi)資源還影響效率卵皂。
我們可以在 JWTAuthenticationManager
使用緩存秩铆。
當(dāng)用戶第一次訪問,我們查詢數(shù)據(jù)庫(kù)判斷 token
是否合法灯变,如果合法將其放入緩存(緩存過期時(shí)間和token過期時(shí)間一致)殴玛,此后每個(gè)請(qǐng)求先去緩存中尋找,如果存在則跳過請(qǐng)求數(shù)據(jù)庫(kù)環(huán)節(jié)添祸,直接當(dāng)做該 token
合法滚粟。
如何解決JWT過期問題
在 JWTAuthenticationManager
中編寫方法,當(dāng) token
即將過期時(shí)拋出一個(gè)特定的異常刃泌,例如 ReAuthenticateException
凡壤,然后我們?cè)?JWTAuthenticationFilter
中單獨(dú)捕獲這個(gè)異常,返回一個(gè)特定的 http
狀態(tài)碼耙替,然后前端去單獨(dú)另外訪問 GET /re_authentication
獲取一個(gè)新的token來替代掉原本的亚侠,同時(shí)從緩存中刪除老的 token
。