SpringSecurity+JWT 身份驗證及動態權限解決方案(很實用)
花了點時間寫了一個SpringSecurity集合JWT完成身份驗證的Demo,并按照自己的想法完成了動態權限問題。在寫這個Demo之初,使用的是SpringSecurity自帶的注解權限,但是這樣權限就顯得不太靈活,在實現之后,感覺也挺復雜的,歡迎大家給出建議。
認證流程及授權流程
我畫了個建議的認證授權流程圖,后面會結合代碼進行解釋整個流程。

一、登錄認證階段
實現SpringSecurity的UsernamePasswordAuthenticationFilter接口(public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter),在它的實現類的構造方法里設置登錄的請求路徑和請求方式。
this.setPostOnly(false);
// 認證路徑 - 發送什么請求,就會進行認證
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/service_auth/admin/index/login","POST"));
當前端發起配置的請求時,請求會被攔截,進入到attemptAuthentication方法進行驗證,在這個方法里可以從request中取出賬號、密碼,從而調用AuthenticationManager的authenticate去校驗賬號、密碼是否正確。
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
try {
User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
// 也可以直接獲取賬號密碼
String username = obtainUsername(request);
String password = obtainPassword(request);
log.info("TokenLoginFilter-attemptAuthentication:嘗試認證,用戶名:{}, 密碼:{}", username, password);
// 在authenticate里去進行校驗的,校驗過程中會去把UserDetailService里返回的SecurityUser(UserDetails)里的賬號密碼和這里傳的賬號密碼進行比對
// 并在UserDetailService里將權限進行賦予
// 校驗通過,會進入到successfulAuthentication方法
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>()));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
那么這個authenticate方法是怎么驗證我們賬號密碼正確性的呢?
打上斷點,跟隨源碼,我們進入到authenticate方法內部:

然后進入這個方法內部,繼續往下走,看到一段核心代碼:

進入retrieveUser方法里,然后往下走,看到一句核心代碼,這個核心代碼就是獲取用戶信息的:

這里注意,調用了UserDetailsService的loadUserByUsername方法,傳入的就是前端傳過來的username,意思就是要根據這個username去獲取UserDetails對象,所以我們就要去查詢數據庫,所以我們就要實現UserDetailsService接口并重寫loadUserByUsername方法。
@Service("userDetailsService")
@Slf4j
public class UserDetailServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("根據username去數據庫查詢用戶信息,username:{}", username);
// 1、從數據庫中取出用戶信息 - 這里模擬,直接new一個User對象
User user = new User();
user.setUsername(username);
// 111111經過加密后
user.setPassword("96e79218965eb72c92a549dd5a330112");
SecurityUser securityUser = new SecurityUser(user);
// 可以根據查出來的user.getId()去查詢這個用戶對應的權限集合 - 這里模擬,直接new一個結合
List authorities = new ArrayList<>();
// 將權限賦予用戶
securityUser.setPermissionValueList(authorities);
return securityUser;
}
}
在這個方法里,我們通過查詢數據庫,獲取用戶username、password和其對應的權限并設置到UserDetails對象里(代碼里的SecurityUser是我自己implements UserDetails的,也就是它的子類)。
獲取到userDetails對象后,回到之前的代碼(retriveUser所在的地方)里,這個user經過包裝,里面包含我們從數據庫里取出的username、password。

接著往下看,看到核心代碼:

注意這個additionalAuthenticationChecks方法,我們進入到這個方法內部:

可以發現,這是對比密碼的,即前端傳過來的密碼和數據庫中存儲的已經加密過的密碼是否能匹配上。然后我們回到之前的代碼里,直接到結尾,返回一個對象。

在賬號、密碼驗證完之后的一系列操作里,SpringSecurity自己再對數據進行一些封裝放到SecurityContextHolder里。
至此,用戶的認證流程已經走完。
認證成功之后
認證成功之后,我們要告訴前端登錄認證通過,會進入UsernamePasswordAuthenticationFilter的successfulAuthentication方法里。
/**
* 登錄成功
* @param request request
* @param response response
* @param chain chain
* @param auth auth
* @throws IOException
* @throws ServletException
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication auth) throws IOException, ServletException {
log.info("TokenLoginFilter-successfulAuthentication:認證通過!");
SecurityUser user = (SecurityUser) auth.getPrincipal();
// 創建token
String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
log.info("創建的Token為:{}", token);
// 這里建議,以username為Key,權限集合為value將權限存入Redis,因為權限在后面會頻繁被取出來用
// redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(), user.getPermissionValueList());
// 響應給前端調用處
ResponseUtil.out(response, ResponseResult.ok().data("token", token));
}
在這個方法里,我們創建一個token,并相應給前端調用者。ResponseUtil是封裝的一個響應工具,tokenManager是JWT工具,這里不做過多解釋,可以去倉庫克隆我的源碼查看,根據我這個流程走即可。
因為我這個Demo是基于前后端分離的,因此只需響應給前端結果(比如這里的token)即可,讓前端來跳轉。如果不是前后端分離的,可以在這里進行頁面跳轉。
如果認證失敗
認證失敗,會進入UsernamePasswordAuthenticationFilter的unsuccessfulAuthentication方法里。
/**
* 登錄失敗
* @param request
* @param response
* @param e
* @throws IOException
* @throws ServletException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
log.info("TokenLoginFilter-unsuccessfulAuthentication:認證失敗!");
// 響應給前端調用處
ResponseUtil.out(response, ResponseResult.error());
}
在這個方法里,直接響應給前端錯誤情況即可。因為我這個Demo是基于前后端分離的,因此只需響應給前端結果、狀態碼即可,讓前端來跳轉。如果不是前后端分離的,可以在這里進行頁面跳轉。
二、授權階段 - 如果你要做權限控制
繼承BasicAuthenticationFilter類,重寫doFilterInternal過濾器,在這個過濾器里獲取token并驗證,并進行動態權限控制。
@Slf4j
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
private TokenManager tokenManager;
private AntPathMatcher antPathMatcher = new AntPathMatcher();
public TokenAuthenticationFilter(AuthenticationManager authManager, TokenManager tokenManager) {
super(authManager);
this.tokenManager = tokenManager;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
UsernamePasswordAuthenticationToken authentication = null;
try {
log.info("授權過濾器,驗證Token...");
authentication = getAuthentication(request);
} catch (ExpiredJwtException e) {
// 可能token過期
log.info("異常捕獲:{}",e.getMessage());
ResponseUtil.out(response, ResponseResult.unauthorized());
}
if (authentication != null) {
String url = request.getRequestURI();
// setAuthentication設置不設置都行,如果需要用注解來控制權限,則必須設置
SecurityContextHolder.getContext().setAuthentication(authentication);
UserServiceImpl userService = new UserServiceImpl();
List
menuList = userService.getAllMenus();
// 遍歷所有菜單
for (Menu menu : menuList) {
// 如果url匹配上了
if (antPathMatcher.match(menu.getPattern(), url) && menu.getRoles().size() > 0){
log.info("URL匹配上了,請求URL:{},匹配上的URL:{}", url, menu.getPattern());
List stringList = new ArrayList<>();
for (GrantedAuthority authority : authentication.getAuthorities()) {
String authority1 = authority.getAuthority();
stringList.add(authority1);
}
for (Role role : menu.getRoles()) {
if (stringList.contains(role.getName())) {
log.info("角色匹配,角色為:{}", role.getName());
chain.doFilter(request, response);
return;
}
}
// 沒有權限
log.info("URL匹配上了,但無權訪問,請求URL:{},匹配上的URL:{}", url, menu.getPattern());
ResponseUtil.out(response, ResponseResult.noPermission());
return;
}
}
// url沒有匹配上菜單,可以訪問
log.info("URL未匹配上,所有人都可以訪問!");
chain.doFilter(request, response);
} else {
// 沒有登錄
log.info("用戶Token無效!");
}
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
// token置于header里
String token = request.getHeader("X-Token");
log.info("X-Token:{}", token);
if (token != null && !"".equals(token.trim())) {
// 根據token獲取用戶名
String userName = tokenManager.getUserFromToken(token);
// 這里可以根據用戶名去Redis中取出權限集合
// 不應該從SecurityContextHolder獲取,會出現問題,如果你換一個token(這個token也是有效的)來調用方法,從這里取,這權限還是之前token登錄時存進來的(經過我測試)
// 為什么呢?我的猜測是:因為JWT是無狀態的,你沒有辦法在注銷的時候,將SpringSecurity全局對象里的東西清理
// 如果你先用賬號2登錄獲取一個token2,然后用賬號1登錄獲取一個token1,用token1去調用一次api的時候從SecurityContextHolder獲取一次權限,然后用token2去調用一次api獲取一次權限,你會發現這個權限居然是token1擁有的(我測試過)
// Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// List permissionValueList = (List) redisTemplate.opsForValue().get(userName);
// 這里直接模擬從Redis中取出權限
List permissionValueList = new ArrayList<>();
// 權限 - 為了測試根據權限控制訪問權限
permissionValueList.add("admin.test");
// 角色 - 為了測試根據角色控制訪問權限
permissionValueList.add("ROLE_admin");
// 需要將權限轉換成SpringSecurity認識的
Collection authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) {
continue;
}
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
if (!StringUtils.isEmpty(userName)) {
log.info("授權過濾器:授權完成!");
return new UsernamePasswordAuthenticationToken(userName, token, authorities);
}
return null;
}
return null;
}
}
如果不做動態權限,則可以省略那一部分對比url的代碼。但是用戶的擁有的權限,建議存儲在Redis里。每次進入到這個過濾器,就將其取出來封裝成Security認識的,放到SecurityContextHolder里,這個時候你的權限是定死了的,可以在配置文件里進行配置,也可以使用注解在Controller里進行控制。
如果你要做動態權限,則可以根據你自己的邏輯修改那一部分對比url的代碼。基于角色控制權限,某個角色擁有某些權限(可訪問接口)的這個原則來做。
我在寫這一部分代碼的時候,感覺還是有點復雜的。用戶的權限、所有菜單都可以存放在Redis里,兩者發生改變的時候就清除Redis里的數據,下一次讀取的時候先從數據庫里讀取,然后將其放到Redis緩存里,這一部分邏輯,由于一開始只是打算寫一個小Demo(如果真要寫的話,還需要創建相關的數據庫表、連接Redis之類的,有點麻煩),所以我沒有寫,讀者如果有興趣可以自己去實現以下,這個并不難。
三、注銷階段
注銷的時候,應該將Redis里存儲的權限進行刪除(如果有的話)。
public class TokenLogoutHandler implements LogoutHandler {
/** Token管理類 */
private TokenManager tokenManager;
public TokenLogoutHandler(TokenManager tokenManager) {
this.tokenManager = tokenManager;
}
/**
* 登出業務處理
* @param request request
* @param response response
* @param authentication
*/
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String token = request.getHeader("X-Token");
if (token != null) {
/* tokenManager.removeToken(token); */
try{
String userName = tokenManager.getUserFromToken(token);
}catch (ExpiredJwtException e){
// 可能token過期了,所以得捕獲
ResponseUtil.out(response, ResponseResult.ok());
}
// 清空當前用戶緩存中的權限數據
// 如果你的權限使用userName作為key存在Redis中,可以通過userName將緩存清空
// ....
}
ResponseUtil.out(response, ResponseResult.ok());
}
}
四、未授權處理
如果你使用了注解或是在配置文件中手動配置了權限管理,即讓SpringSecurity幫你管理權限,當你訪問到沒有權限訪問的接口時,回來到這里。
@Slf4j
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
/**
* 未授權返回錯誤碼
* @param request request
* @param response response
* @param authException authException
* @throws IOException
* @throws ServletException
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
// 響應錯誤碼
ResponseUtil.out(response, ResponseResult.error());
}
}
五、SpringSecurity配置文件核心代碼
這里主要是想說一件事,就是在我們的認證、授權、退出處理器的配置時,如果這個類里需要使用到其他類(對象),可以通過構造方法的方式傳進去,因為它們沒有被Spring管理,你是沒有辦法使用@Autowired注入的。
/**
* 配置設置 - 更多配置項見官方文檔
* @param http http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new UnauthorizedEntryPoint())
// 允許跨域
.and().cors()
.and().csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
// 退出請求路徑
.and().logout().logoutUrl("/service_auth/admin/index/logout")
// 退出處理器
.addLogoutHandler(new TokenLogoutHandler(tokenManager)).and()
// 認證過濾器
.addFilter(new TokenLoginFilter(authenticationManager(), tokenManager))
// 授權過濾器
.addFilter(new TokenAuthenticationFilter(authenticationManager(), tokenManager)).httpBasic();
}
總結
核心其實就是前面貼出的一、二(認證和授權),其實還是有優化空間的,我本人對于SpringSecurity沒有了解很深,所以只能寫成這樣。
如果讀者感興趣,可以下載源碼閱讀,源碼里注釋也是非常詳細的。
- https://github.com/liuchengyin01/JwtWithSpringSecurityDemo
- https://gitee.com/liuchengyin_vae/JwtWithSpringSecurityDemo