項目背景
緊接上文,我們已經(jīng)完成了 SpringBoot中集成Spring Security,并且用戶名帳號和密碼都是從數(shù)據(jù)庫中獲取。但是這種方式還是不能滿足現(xiàn)在的開發(fā)需求。
使用JWT的好處:
- 無狀態(tài)認證:JWT本身包含了認證信息和聲明,服務(wù)器不需要在會話中保存任何狀態(tài)。這樣使得應(yīng)用程序可以更加容易的擴展,并且更適合分布式部署和微服務(wù)架構(gòu)。
- 跨域支持:由于JWT在HTTP頭部中進行傳輸,因此它可以輕松的支持跨域請求。
- 靈活性:JWT可以包含任意數(shù)量的聲明,這些聲明可以用來傳遞用戶、角色、或者其他相關(guān)的元數(shù)據(jù)。這些數(shù)據(jù)可以在服務(wù)器端和客戶端之間共享,從而簡化了授權(quán)和訪問控制管理。
- 安全性:JWT使用數(shù)字簽名或者加密算法來驗證其完整性和真實性。這確保了JWT在傳輸過程中不會被篡改或偽造。
JWT(Json Web Tokens)
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDS
翻譯:JSON Web Token (JWT) 是一個開放標準 (RFC 7519),它定義了一種緊湊且自包含的方式,用于在各方之間安全地傳輸信息作為 JSON 對象。 此信息可以驗證和信任,因為它是數(shù)字簽名的。 JWT 可以使用密鑰(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公鑰/私鑰對進行簽名。
JWT組成
- header: 存放簽名的生成算法。
- payload:存放用戶名、token的生成時間和過期時間。
- signature:以header和payload生成的簽名,一旦header和payload被篡改,驗證將失敗。
可以在該網(wǎng)站上進行解析:https://jwt.io/
Spring Security集成JWT
maven引入
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
首先不論是不是Spring Security中集成JWT,我們得先有個工具類。這個工具類的主要內(nèi)容是什么呢?
創(chuàng)建JWT、驗證JWT、 解析JWT
步驟一:
JwtUtils
工具類
/**
* jwt工具類
*
* @author caojing
* @since 2023/6/14
*/
public class JwtUtils {
/**
* token過期時間
*/
public static final long EXPIRE = 1000 * 60 * 60 * 24;
/**
* 秘鑰
*/
public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";
/**
* 生成token字符串的方法
*
* @param id
* @param nickname
* @return
*/
public static String getJwtToken(String id, String nickname) {
String jwtToken = Jwts.builder().setHeaderParam("typ", "JWT").setHeaderParam("alg", "HS256")
.setSubject("guli-user").setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
//設(shè)置token主體部分 ,存儲用戶信息
.claim("id", id)
.claim("nickname", nickname)
.signWith(SignatureAlgorithm.HS256, APP_SECRET).compact();
return jwtToken;
}
/**
* 判斷token是否存在與有效
*
* @param jwtToken
* @return
*/
public static boolean checkToken(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) {
return false;
}
try {
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 判斷token是否存在與有效
*
* @param request
* @return
*/
public static boolean checkToken(HttpServletRequest request) {
try {
String jwtToken = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwtToken)) {
return false;
}
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 根據(jù)token字符串獲取會員id
*
* @param request
* @return
*/
public static String getUserIdByJwtToken(HttpServletRequest request) {
String jwtToken = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwtToken)) {
return "";
}
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
Claims claims = claimsJws.getBody();
return (String) claims.get("id");
}
/**
* 驗證jwt
*/
public static Claims verifyJwt(String token) {
Claims claims;
try {
//得到DefaultJwtParser
claims = Jwts.parser()
//設(shè)置簽名的秘鑰
.setSigningKey(APP_SECRET)
.parseClaimsJws(token).getBody();
} catch (Exception e) {
e.printStackTrace();
claims = null;
}//設(shè)置需要解析的jwt
return claims;
}
}
我們可以設(shè)想下這么一個流程:
前端在請求頭中設(shè)置 Authorization
參數(shù),后臺再進入到controller之前,會走一個過濾器對header中的Authorization
參數(shù)進行校驗,也就是利用JWTUtils對token進行解析。
1.通過校驗:模擬 spring Security 登錄成功,把token值塞到一個變量里面。
2.未通過校驗:繼續(xù)走spring Security的驗證流程(理論上會拋出異常)
注意以上我們分析的關(guān)鍵字:過濾器
因此,我們新建一個JwtAuthenticationTokenFilter
類繼承OncePerRequestFilter
。
繼承 OncePerRequestFilter
的原因:
- 確保在一次請求中只執(zhí)行一次過濾操作。OncePerRequestFilter是Spring框架提供的一個過濾器基類,它確保每個請求只通過一次,而不會重復(fù)執(zhí)行過濾邏輯。
- 當客戶端發(fā)送請求時,過濾器鏈會按照配置的順序?qū)φ埱筮M行過濾。如果一個過濾器沒有繼承OncePerRequestFilter,它可能會在請求鏈中的多個位置執(zhí)行,導(dǎo)致重復(fù)處理請求的問題。
- 繼承OncePerRequestFilter可以確保JwtAuthenticationTokenFilter在整個過濾器鏈中的每個請求中只執(zhí)行一次,避免了多次處理同一個請求的問題。這對于執(zhí)行基于JWT的身份驗證和授權(quán)邏輯非常重要,因為它確保只有在一次請求中進行一次JWT的驗證和解析,避免了不必要的性能開銷和潛在的安全問題。
總結(jié)來說,JwtAuthenticationTokenFilter繼承OncePerRequestFilter是為了保證它在過濾器鏈中的每個請求中只執(zhí)行一次,避免了重復(fù)處理請求的問題,確保了JWT身份驗證和授權(quán)邏輯的準確性和性能。
步驟二
將jwtFilter添加到Spring Security 過濾器中
JwtAuthenticationTokenFilter
類
/**
* token過濾器 驗證token有效性
* 判斷用戶是否有效走 MyUserDetailService的 loadUserByUsername 方法
*
* @author caojing
*/
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisUtils redisUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 從請求頭中獲取token
String authToken = request.getHeader("Authorization");
// 截取token
if (authToken != null) {
//驗證token,獲取token中的username
Claims claims = JwtUtils.verifyJwt(authToken);
if (claims == null) {
throw new ServletException("token異常,請重新登錄");
}
//從redis 獲取緩存
String redisKey = JwtUtils.getUserIdByJwtToken(request);
UserBean userBean = redisUtils.getCacheObject(redisKey);
//重新設(shè)置token的失效時間
redisUtils.setCacheObject(redisKey, userBean, 30, TimeUnit.MINUTES);
if (userBean != null && SecurityContextHolder.getContext().getAuthentication() == null) {
//獲取到值,相當于手動把session值設(shè)置到此次request中,后續(xù)就會認為已經(jīng)登錄,不做登錄校驗
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userBean, null, userBean.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
//繼續(xù)下一個過濾器
chain.doFilter(request, response);
}
}
JwtAuthenticationTokenFilter
添加到ScurityConfig
類中
/**
* Spring Security 配置類
*
* @author caojing
* @since 2023/6/14
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailService userDetailService;
@Autowired
private JwtAuthenticationTokenFilter JwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.addFilterBefore(JwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// auth.inMemoryAuthentication()
// .passwordEncoder(new BCryptPasswordEncoder())
// .withUser("user").password(encoder.encode("123456")).roles("USER");
auth.userDetailsService(userDetailService).passwordEncoder(new BCryptPasswordEncoder());
}
}
說明:利用addFilterBefore
方法,把jwt認證放到UsernamePasswordAuthenticationFilter
過濾器之前。為什么要放到這里,我們下一篇文章會說。
步驟三
怎么把驗證交給Spring Security
基本工作已經(jīng)做完。我們還剩下一個獲取token的controller。
想一想這個controller應(yīng)該有什么功能?
沒有使用spring Security之前,我們是不是在login獲取用戶輸入的帳號名和密碼,然后根據(jù)帳號名從數(shù)據(jù)庫查詢出來對應(yīng)的用戶信息。然后對比密碼(加密后)是否正確。
使用了Spring Security之后,思考一下,哪些能用,哪些需要替換。
- 帳號名密碼的獲取肯定是要繼續(xù)用的。
- 認證移動到了MyUserDetailService中認證,也就是使用Spring Security的
DaoAuthenticationProvider
進行認證。所以原先的認證需要刪除替換成DaoAuthenticationProvider
認證。
上面第一個問題好解決,那么第二個問題該如何實現(xiàn)呢?
先說結(jié)果:
使用AuthenticationManager
的authenticate
方法進行認證。
如何找到這個入口?
我們現(xiàn)在已知的類是DaoAuthenticationProvider
,所以先從這個類開始。先看下這個類是實現(xiàn)AuthenticationProvider
接口。先說一下這個接口的2個方法構(gòu)成:
// ~ Methods
// ========================================================================================================
/**
* Performs authentication with the same contract as
* {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
* .
*
* @param authentication the authentication request object.
*
* @return a fully authenticated object including credentials. May return
* <code>null</code> if the <code>AuthenticationProvider</code> is unable to support
* authentication of the passed <code>Authentication</code> object. In such a case,
* the next <code>AuthenticationProvider</code> that supports the presented
* <code>Authentication</code> class will be tried.
*
* @throws AuthenticationException if authentication fails.
*/
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
/**
* Returns <code>true</code> if this <Code>AuthenticationProvider</code> supports the
* indicated <Code>Authentication</code> object.
* <p>
* Returning <code>true</code> does not guarantee an
* <code>AuthenticationProvider</code> will be able to authenticate the presented
* instance of the <code>Authentication</code> class. It simply indicates it can
* support closer evaluation of it. An <code>AuthenticationProvider</code> can still
* return <code>null</code> from the {@link #authenticate(Authentication)} method to
* indicate another <code>AuthenticationProvider</code> should be tried.
* </p>
* <p>
* Selection of an <code>AuthenticationProvider</code> capable of performing
* authentication is conducted at runtime the <code>ProviderManager</code>.
* </p>
*
* @param authentication
*
* @return <code>true</code> if the implementation can more closely evaluate the
* <code>Authentication</code> class presented
*/
boolean supports(Class<?> authentication);
這邊重點注意2句話:
-
Performs authentication with the same contract as * {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
翻譯:
執(zhí)行的身份認證和AuthenticationManager#authenticate這個方法具有相同的合同?
黑人問號臉?換個說人話的:
合同 = 契約。
軟件開發(fā)中 contract一般都翻譯成契約的意思。而且契約在軟件開發(fā)中特制:定義了功能、接口或方法應(yīng)該具有的行為和特征的規(guī)范。當兩個功能或組件具有相同的契約時,它們在執(zhí)行特定操作時遵循相同的規(guī)則和約定。也就是俗稱約定。
人話:
這個方法和AuthenticationManager#authenticate(Authentication) 具有相同的認證規(guī)則和約定 -
Selection of an AuthenticationProvider capable of performing authentication is conducted at runtime the ProviderManager.
翻譯:
選擇一個AuthenticationProvider能夠執(zhí)行身份校驗是在ProviderManager運行執(zhí)行期間?
人話:
在ProviderManager運行執(zhí)行期間來使用該方法判斷AuthenticationProvider是否能執(zhí)行身份校驗
這2個方法都提到了一個類:ProviderManager
。所以下一步我們看看這個類。
有點長。。。。。。。。
直接看AuthenticationManager
這個接口吧:
/**
* Processes an {@link Authentication} request.
*
* @author Ben Alex
*/
public interface AuthenticationManager {
// ~ Methods
// ========================================================================================================
/**
* Attempts to authenticate the passed {@link Authentication} object, returning a
* fully populated <code>Authentication</code> object (including granted authorities)
* if successful.
* <p>
* An <code>AuthenticationManager</code> must honour the following contract concerning
* exceptions:
* <ul>
* <li>A {@link DisabledException} must be thrown if an account is disabled and the
* <code>AuthenticationManager</code> can test for this state.</li>
* <li>A {@link LockedException} must be thrown if an account is locked and the
* <code>AuthenticationManager</code> can test for account locking.</li>
* <li>A {@link BadCredentialsException} must be thrown if incorrect credentials are
* presented. Whilst the above exceptions are optional, an
* <code>AuthenticationManager</code> must <B>always</B> test credentials.</li>
* </ul>
* Exceptions should be tested for and if applicable thrown in the order expressed
* above (i.e. if an account is disabled or locked, the authentication request is
* immediately rejected and the credentials testing process is not performed). This
* prevents credentials being tested against disabled or locked accounts.
*
* @param authentication the authentication request object
*
* @return a fully authenticated object including credentials
*
* @throws AuthenticationException if authentication fails
*/
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
該類只有一個方法:authenticate
。
解釋:
嘗試對傳遞的 Authentication 對象進行身份驗證,如果成功則返回一個完全填充的 Authentication 對象(包括授予的權(quán)限)。。。。。。。。。
人話:
對我們傳入的Authentication
對象進行身份認證,通過以后會返回Authentication 對象。
簡而言之。這個類AuthenticationManager
就是我們具體身份認證的入口了,但這是一個接口,具體的實現(xiàn)類是通過默認的ProviderManager
實現(xiàn)。
繼續(xù)看ProviderManager
中的authenticate
方法:
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
boolean debug = logger.isDebugEnabled();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}
......
}
我這里只貼出來部分代碼:我們可以看到代碼的主要結(jié)構(gòu)是一個for循環(huán)。循環(huán)的內(nèi)容是啥呢?是AuthenticationProvider
的實現(xiàn)類。循環(huán)干什么呢?
- 根據(jù)
AuthenticationProvider
中的provider
方法判斷是否支持驗證當前的authentication
,具體行:189行
。 - 判斷具體的身份權(quán)限交給
AuthenticationProvider
的authenticate
方法,具體行:199行
解釋一下第一句話:AuthenticationProvider
和authentication
都是接口,并不是具體的實現(xiàn)類,所以看來比較抽象。因此,我就拿用戶名密碼登錄方式舉例。
在用戶名和密碼登錄模式中 AuthenticationProvider
的具體實現(xiàn)類AbstractUserDetailsAuthenticationProvider
authentication
的具體實現(xiàn)類是UsernamePasswordAuthenticationToken
。那么驗證身份流程就變成了ProviderManager#authentication
-> AbstractUserDetailsAuthenticationProvider#supports
->AbstractUserDetailsAuthenticationProvider#authenticate
->return UsernamePasswordAuthenticationToken
具體時序圖如下所示:
基于以上的流程,我們不難知道在login中需要調(diào)用authenticationManager#authenticate
方法進行認證了
如何引入AuthenticationManager?
看下配置類中繼承的類WebSecurityConfigurerAdapter
其中有個方法:
/**
* Override this method to expose the {@link AuthenticationManager} from
* {@link #configure(AuthenticationManagerBuilder)} to be exposed as a Bean. For
* example:
*
* <pre>
* @Bean(name name="myAuthenticationManager")
* @Override
* public AuthenticationManager authenticationManagerBean() throws Exception {
* return super.authenticationManagerBean();
* }
* </pre>
*
* @return the {@link AuthenticationManager}
* @throws Exception
*/
public AuthenticationManager authenticationManagerBean() throws Exception {
return new AuthenticationManagerDelegator(authenticationBuilder, context);
}
這很好理解吧,不需要翻譯了。
Logservice 代碼如下:
/**
* 登錄接口
*
* @author caojing
* @since 2023/6/15
*/
@Slf4j
@Service
public class LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisUtils redisUtils;
public ResponseBean<String> login(String username, String password) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
//這邊可以獲取用戶信息.這里getPrincipal和 JwtAuthenticationTokenFilter類中 完成token驗證之后
//new UsernamePasswordAuthenticationToken 塞進去的值
UserBean userBean = (UserBean) authentication.getPrincipal();
log.info("用戶信息:{}", JSON.toJSONString(userBean));
String token = JwtUtils.getJwtToken(String.valueOf(userBean.getId()), username);
//每次登錄都獲取最新的值,
redisUtils.setCacheObject(String.valueOf(userBean.getId()), userBean, 30, TimeUnit.MINUTES);
return new ResponseBean<>(HttpStatus.OK.value(), "獲取成功", token);
}
}
SecurityConfig
配置類增加
/**
* Spring Security 配置類
*
* @author caojing
* @since 2023/6/14
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//......................
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
啟動項目
訪問地址:http://127.0.0.1:8889/token
測試一下token值是否有效。
先測試不帶Authorization
的請求:http://127.0.0.1:8889/test
帶Authorization
的請求:http://127.0.0.1:8889/test
總結(jié)
思路:
整體思路分2個部分:
-
登錄認證獲取token
提供一個controller,將controller的地址加到spring Security 的config中不做權(quán)限控制,訪問該controller,將用戶名和密碼的判斷交給spring Security 的userDetailService處理,根據(jù)處理的返回結(jié)果決定是否生成對應(yīng)的token值。- 如何交給Spring Security 處理認證過程:
authenticationManager.authenticate()
。具體是怎么找到這個入口的,詳情可以看步驟三。
- 如何交給Spring Security 處理認證過程:
-
接口認證token值文章來源:http://www.zghlxwxcb.cn/news/detail-500448.html
- 加入JWT生成的工具類
- Spring Security 提供多種認證方式,但我們需要熟悉的是
UsernamePasswordAuthenticationFilter
。剩下的認證方式了解即可。 - 在了解了Spring Security的幾種認證方式之后,我們需要考慮將自定義的jwtFilter加入到Srping Security的過濾器中。對應(yīng)上面的步驟二。
- 步驟二完成以后,當token值存在的時候,會把用戶信息轉(zhuǎn)化成
UsernamePasswordAuthenticationToken
,其實也不是非要這個類,任何一個實現(xiàn)Authentication
即接口的類都可以。然后通過SecurityContextHolder.getContext().setAuthentication()
方法,將用戶信息設(shè)置到SecurityContextHolder
中
下面是一張Spring Security的過濾器的鏈路圖,基本上Spring Security 都是圍繞著這幾個過濾器進行一些功能。比如后續(xù)的異常、權(quán)限控制(選舉策略)都是在過濾器中實現(xiàn)。具體內(nèi)容咱們下個章節(jié)繼續(xù)聊。
習(xí)題:
- 為什么通過
SecurityContextHolder.getContext().setAuthentication()
方法就可以實現(xiàn)登錄了?;蛘哒fSecurityContextHolder
到底有什么用。 - Spring Security中主要分為權(quán)限和認證,認證已經(jīng)講過了,那么權(quán)限是如何控制的?(提示:也是過濾器,涉及的幾個類
SecurityMetadataSource
、GrantedAuthority
、AccessDecisionManager
) - 能否找到Spring Security中的大部分的過濾器?
下一篇主要內(nèi)容是稍微介紹下Spring Security的源碼,順帶解決習(xí)題中的幾個問題。
上一篇文章地址:SpringBoot2.3集成Spring Security(一)文章來源地址http://www.zghlxwxcb.cn/news/detail-500448.html
到了這里,關(guān)于SpringBoot2.3集成Spring Security(二) JWT認證的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!