Spring Security結合JWT實現認證與授權
theme: cyanosis
Spring Security系列文章
- 認證與授權之Cookie、Session、Token、JWT
- 基於Session的認證與授權實踐
- Spring Security入門學習
- Spring Security進階學習
- Spring Security自定義認證邏輯實現圖片驗證碼登入
- Spring Security結合JWT實現認證與授權
經過這段時間的學習,我們已不滿足於普通案例的實操,想著啥時候找個更貼合實際應用的案例練練手,比如說 SpringSecurity+JWT,正好將近期的知識點糅合起來,也算是實操了基於 Token 的認證與授權。
閒話少說,我們直奔主題,開始本次案例的學習。
SpringSecurity認證與授權
自定義認證處理
還記得上一篇文章中提到的驗證碼處理邏輯嗎?我們通過自定義 DaoAuthenticationProvider
實現類,並重寫 additionalAuthenticationChecks
方法,將驗證碼比對邏輯和密碼比對邏輯放在一起。但如果只是為了校驗驗證碼,還有一種實現方式:可以自定義一個過濾器,並把這個自定義的過濾器放入 SpringSecurity 過濾器鏈中,每次請求都會通過該過濾器。但該方法存在一個弊端,我們只希望登入請求經過該過濾器即可,其他請求是不需要經過該過濾器的。
Spring Security 的預設 Filter 鏈:
java
SecurityContextPersistenceFilter
->HeaderWriterFilter
->LogoutFilter
->UsernamePasswordAuthenticationFilter
->RequestCacheAwareFilter
->SecurityContextHolderAwareRequestFilter
->SessionManagementFilter
->ExceptionTranslationFilter
->FilterSecurityInterceptor
這些過濾器按照既定的優先順序排列,最終形成一個過濾器鏈,如下圖所示。開發人員也可以自定義過濾器,並通過 @Order 註解來調整自定義過濾器在過濾器鏈中的位置。
下面我們重點關注 UsernamePasswordAuthenticationFilter,該過濾器用於處理基於表單方式的登入驗證,該過濾器預設只有當請求方法為post、請求頁面為/login時過濾器才生效,如果想修改預設攔截url,只需在剛才介紹的Spring Security配置類WebSecurityConfig中配置該過濾器的攔截url:.loginProcessingUrl("url")即可;
當用戶傳送登入請求的時候,首先進入到 UsernamePasswordAuthenticationFilter 中進行校驗。
打斷點發送登入請求進入原始碼中,我們會發現它會進入到 UsernamePasswordAuthenticationFilter
,在該類中,有一個attemptAuthentication方法在這個方法中,會獲取請求傳入的 username 以及 password 引數的資訊,然後使用構造器 new UsernamePasswordAuthenticationToken(username, password)
封裝為一個 UsernamePasswordAuthenticationToken
物件,在這個構造器內部會將對應的資訊賦值給各自的本地變數,並且會呼叫父類 AbstractAuthenticationToken 構造器,傳一個 null值進去,為什麼是 null 呢?因為剛開始並沒有認證,因此使用者沒有任何許可權,並且設定沒有認證的資訊(setAuthenticated(false)),最後會進入AuthenticationManager 介面的實現類 ProviderManager 中,接著就呼叫 authenticate 方法。
看到這裡是不是有點熟悉,這不就來到了前一篇文章中提到的 DaoAuthenticationProvider
嘛,它的父類是 AbstractUserDetailsAuthenticationProvider
,其中就包括 authenticate 方法,這裡就不重複介紹了。
綜上可知,UsernamePasswordAuthenticationFilter
和 DaoAuthenticationProvider
是 SpringSecurity 認證處理的核心邏輯,主要是校驗輸入的賬號密碼是否合規。需要注意的是,之前的案例中,我們都沒有 /login 的處理邏輯,全權交由 SpringScurity 處理,在實際應用中我們並不會這樣做。
現在回看一下我們的需求,即登入認證成功後,之後訪問其他介面需要攜帶認證結果,可以是 token,後端需要驗證認證結果是否有效。那麼就要求我們自定義一個過濾器,針對每次請求攜帶的認證結果。那麼首先需要自定義認證邏輯,即處理使用者呼叫 /login 的邏輯,最終返回認證結果,所以我們要剔除掉 UsernamePasswordAuthenticationFilter
。這裡我們暫時不介紹認證邏輯,重點關注如何解析認證結果。
OncePerRequestFilter
OncePerRequestFilter 是 Spring Boot 裡面的一個過濾器抽象類,其同樣在 Spring Security 裡面被廣泛用到,這個過濾器抽象類通常被用於繼承實現並在每次請求時只執行一次過濾。
OncePerRequestFilter 繼承 Filter,而 doFilter 是 Filter 介面中的方法,doFilterInternal是OncePerRequestFilter 中的一個抽象方法
```java public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) { HttpServletRequest httpRequest = (HttpServletRequest)request; HttpServletResponse httpResponse = (HttpServletResponse)response; String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName(); boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null; if (!this.skipDispatch(httpRequest) && !this.shouldNotFilter(httpRequest)) { if (hasAlreadyFilteredAttribute) { if (DispatcherType.ERROR.equals(request.getDispatcherType())) { this.doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain); return; }
filterChain.doFilter(request, response);
} else {
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
this.doFilterInternal(httpRequest, httpResponse, filterChain);
} finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
} else {
filterChain.doFilter(request, response);
}
} else { throw new ServletException("OncePerRequestFilter just supports HTTP requests"); } } ```
由上可知,OncePerRequestFilter.doFilter 方法中通過 request.getAttribute 判斷當前過濾器是否已執行,若未執行過,則呼叫doFilterInternal方法,交由其子類實現。經測試發現,SpringSecurity 的過濾器都會執行 doFilterInternal 方法。
所以我們自定義過濾器,只需要繼承 OncePerRequestFilter,並重寫 doFilterInternal 方法。
```java @Slf4j public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired private MyUserDetailsService userDetailsService; @Autowired private JwtTokenUtil jwtTokenUtil; @Value("${jwt.tokenHeader}") private String tokenHeader; @Value("${jwt.tokenHead}") private String tokenHead;
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader(this.tokenHeader); if (authHeader != null && authHeader.startsWith(this.tokenHead)) { String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer " String username = jwtTokenUtil.getUserNameFromToken(authToken); logger.info("checking username:" + username); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); if (jwtTokenUtil.validateToken(authToken, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); logger.info("authenticated user:" + username); SecurityContextHolder.getContext().setAuthentication(authentication); } } } filterChain.doFilter(request, response); } } ```
上述方法大致流程如下:
- 從請求頭獲取token資訊;
- 如果 token 不為 null,且格式正確,則獲取 token 中關鍵資訊,然後呼叫 JWT 工具類根據 token 解析出使用者名稱。根據使用者名稱去資料庫裡獲取具體資訊,與 token 進行校驗,匹配成功則將使用者資訊封裝到 UsernamePasswordAuthenticationToken 物件中,並設定到安全上下文中。
在 SecurityConfig 中這樣配置自定義的過濾器:
java
http.addFilterBefore(jwtAuthenticationTokenFilter,
UsernamePasswordAuthenticationFilter.class);// 自定義認證過濾器
自定義許可權處理
Spring Security 可以通過 http.authorizeRequests() 對web請求進行授權保護。Spring Security 使用標準Filter建立了對web請求的攔截,最終實現對資源的授權訪問。授權流程如下:
分析授權流程:
-
攔截請求,已認證使用者訪問受保護的web資源將被 SecurityFilterChain 中的 FilterSecurityInterceptor 的子類攔截。
-
獲取資源訪問策略,FilterSecurityInterceptor 會從 SecurityMetadataSource 的子類 DefaultFilterInvocationSecurityMetadataSource 獲取要訪問當前資源所需要的許可權 Collection
。
SecurityMetadataSource 其實就是讀取訪問策略的抽象,而讀取的內容,其實就是我們配置的訪問規則, 讀取訪問策略如:
java
http.csrf().disable() //遮蔽CSRF控制,即spring security不再限制CSRF
.authorizeRequests()
.antMatchers("/r/r1").hasAuthority("p1")
.antMatchers("/r/r2").hasAuthority("p2")
具體來說是 DefaultFilterInvocationSecurityMetadataSource 檔案中的 getAttributes()方法,該方法會讀取上述訪問規則,然後封裝到 CollectionantMatchers("/r/r1").hasAuthority("p1")
也是無用。
- 最後,FilterSecurityInterceptor 會呼叫 AccessDecisionManager 進行授權決策,若決策通過,則允許訪問資 源,否則將禁止訪問。
AccessDecisionManager(訪問決策管理器)的核心介面如下:
java
public interface AccessDecisionManager {
/**
* 通過傳遞的引數來決定使用者是否有訪問對應受保護資源的許可權
*/
void decide(Authentication authentication , Object object, Collection<ConfigAttribute>
configAttributes ) throws AccessDeniedException, InsufficientAuthenticationException;
//略..
}
這裡著重說明一下decide的引數:
- authentication:要訪問資源的訪問者的身份
- object:要訪問的受保護資源,web請求對應FilterInvocation
- configAttributes:是受保護資源的訪問策略,通過SecurityMetadataSource獲取。
decide介面就是用來鑑定當前使用者是否有訪問對應受保護資源的許可權。
AccessDecisionManager
自定義 AccessDecisionManager: 實現授權邏輯校驗,decide 方法請求引數中的 configAttributes 可以通過我們自定義的 SecurityMetadataSource 實現類獲取。
```java public class DynamicAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object,
Collection
@Override public boolean supports(ConfigAttribute attribute) { return true; }
@Override public boolean supports(Class<?> clazz) { return true; } } ```
SecurityMetadataSource
DynamicSecurityMetadataSource 與 DefaultFilterInvocationSecurityMetadataSource 同等級,不會讀取 SecurityConfig 檔案中配置的 antMatchers("/r/r1").hasAuthority("p1")
。
```java public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
private static Map
@PostConstruct public void loadDataSource() { configAttributeMap = dynamicSecurityService.loadDataSource(); }
public void clearDataSource() { configAttributeMap.clear(); configAttributeMap = null; }
@Override
public Collection
@Override
public Collection
@Override public boolean supports(Class<?> clazz) { return true; } } ```
DynamicSecurityService 用來讀取 permission 表,獲取許可權配置。
```java @Service public class DynamicSecurityService {
@Autowired private PermissionMapper permissionMapper;
// 載入資源ANT萬用字元和資源對應MAP
public Map
FilterSecurityInterceptor
FilterSecurityInterceptor 攔截器,用於判斷當前請求身份認證是否成功,是否有相應的許可權,當身份認證失敗或者許可權不足的時候便會丟擲相應的異常;
Spring Security
使用FilterSecurityInterceptor
過濾器來進行URL許可權校驗,實際使用流程大致如下:
- 通過資料庫動態配置url資源許可權
- 系統啟動時,通過
FilterSecurityInterceptor
濾器到資料庫載入系統資源許可權列表 - 使用者登陸時通過自定義的
UserDetailsService
載入當前使用者的角色列表 - 當有請求訪問時,通過
FilterSecurityInterceptor
對比系統資源許可權列表和使用者資源許可權列表(在使用者登入時新增到使用者資訊中)來判斷使用者是否有該url的訪問許可權。
自定義URL許可權驗證需要在FilterSecurityInterceptor
自定義的配置項
DynamicSecurityMetadataSource
:實現FilterInvocationSecurityMetadataSource
介面,在實現類中載入資源許可權,並在filterSecurityInterceptor
中注入該實現類。DynamicAccessDecisionManager
:通過實現AccessDecisionManager
介面自定義一個決策管理器,判斷是否有訪問許可權。判斷邏輯可以寫在決策管理器的決策方法中,也可以通過投票器實現,除了框架提供的三種投票器還可以新增自定義投票器。自定義投票器通過實現AccessDecisionVoter
介面來實現。
具體程式碼如下:
```java public class DynamicSecurityFilter extends AbstractSecurityInterceptor implements Filter {
@Autowired private IgnoreUrlsConfig ignoreUrlsConfig; @Autowired private DynamicSecurityMetadataSource dynamicSecurityMetadataSource;
@Autowired public void myAccessDecisionManager(DynamicAccessDecisionManager dynamicAccessDecisionManager) { super.setAccessDecisionManager(dynamicAccessDecisionManager); }
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain); //OPTIONS請求直接放行 if (request.getMethod().equals(HttpMethod.OPTIONS.toString())) { fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); return; } //白名單請求直接放行 PathMatcher pathMatcher = new AntPathMatcher(); for (String path : ignoreUrlsConfig.getUrls()) { if (pathMatcher.match(path, request.getRequestURI())) { fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); return; } } //此處會呼叫AccessDecisionManager中的decide方法進行鑑權操作 InterceptorStatusToken token = super.beforeInvocation(fi); try { fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); } finally { super.afterInvocation(token, null); } }
@Override public Class<?> getSecureObjectClass() { return FilterInvocation.class; }
@Override public SecurityMetadataSource obtainSecurityMetadataSource() { return dynamicSecurityMetadataSource; }
} ```
在 yaml 檔案中設定白名單,然後讀取這些 api。
yaml
secure:
ignored:
urls: #安全路徑白名單
- /swagger-ui/
- /swagger-resources/**
- /**/v2/api-docs
- /login
- /register
IgnoreUrlsConfig 相當於之前 SecurityConfig 檔案中的 http.antMatchers("").permitAll()
。
```java @Getter @Setter @ConfigurationProperties(prefix = "secure.ignored") public class IgnoreUrlsConfig {
private List
} ```
自定義異常處理
Spring Security 中的異常主要分為兩大類:一類是認證異常,另一類是授權相關的異常。
HttpSecurity
提供的 exceptionHandling()
方法用來提供異常處理。該方法構造出 ExceptionHandlingConfigurer
異常處理配置類。該配置類提供了兩個實用介面:
- AuthenticationEntryPoint 該類用來統一處理
AuthenticationException
異常 - AccessDeniedHandler 該類用來統一處理
AccessDeniedException
異常
AuthenticationEntryPoint
被 ExceptionTranslationFilter 用來作為認證方案的入口,即當用戶請求處理過程中遇見認證異常時,被異常處理器(ExceptionTranslationFilter)用來開啟特定的認證流程。介面定義如下:
java
public interface AuthenticationEntryPoint {
void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}
其中,request 是遇到了認證異常的使用者請求,response 是將要返回給使用者的響應,authException 請求過程中遇見的認證異常。
自定義 AuthenticationEntryPoint 實現類如下:
```java public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Cache-Control", "no-cache"); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); response.getWriter().println(JSONUtil.parse(Result.unauthorized(authException.getMessage()))); response.getWriter().flush(); } } ```
Spring Security Web 內建AuthenticationEntryPoint
實現類
Spring Security Web
為AuthenticationEntryPoint
提供了一些內建實現 :
Http403ForbiddenEntryPoint
設定響應狀態字為403,並非觸發一個真正的認證流程。通常在一個預驗證(pre-authenticated authentication)已經得出結論需要拒絕使用者請求的情況被用於拒絕使用者請求。
HttpStatusEntryPoint
設定特定的響應狀態字,並非觸發一個真正的認證流程。
LoginUrlAuthenticationEntryPoint
根據配置計算出登入頁面url,將使用者重定向到該登入頁面從而開始一個認證流程。
BasicAuthenticationEntryPoint
對應標準Http Basic認證流程的觸發動作,向響應寫入狀態字401和頭部WWW-Authenticate:"Basic realm="xxx"觸發標準Http Basic認證流程。
DigestAuthenticationEntryPoint
對應標準Http Digest認證流程的觸發動作,向響應寫入狀態字401和頭部WWW-Authenticate:"Digest realm="xxx"觸發標準Http Digest認證流程。
DelegatingAuthenticationEntryPoint
這是一個代理,將認證任務委託給所代理的多個AuthenticationEntryPoint物件,其中一個被標記為預設AuthenticationEntryPoint。
AccessDeniedHandler
處理授權異常,介面定義如下:
java
public interface AccessDeniedHandler {
void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException;
}
自定義 AccessDeniedHandler 實現類如下:
```java public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Cache-Control", "no-cache"); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); response.getWriter() .println(JSONUtil.parse(Result.forbidden(accessDeniedException.getMessage()))); response.getWriter().flush(); } } ```
關於上述自定義的認證與授權處理,以及異常處理,需要在 SecurityConfig 檔案中加以配置,如下所示:
```java @Configuration public class SecurityConfig {
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean public IgnoreUrlsConfig ignoreUrlsConfig() { return new IgnoreUrlsConfig(); }
@Bean public MyAccessDeniedHandler myAccessDeniedHandler() { return new MyAccessDeniedHandler(); }
@Bean public MyAuthenticationEntryPoint myAuthenticationEntryPoint() { return new MyAuthenticationEntryPoint(); }
@Bean public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() { return new JwtAuthenticationTokenFilter(); }
@ConditionalOnBean(name = "dynamicSecurityService") @Bean public DynamicAccessDecisionManager dynamicAccessDecisionManager() { return new DynamicAccessDecisionManager(); }
@ConditionalOnBean(name = "dynamicSecurityService") @Bean public DynamicSecurityMetadataSource dynamicSecurityMetadataSource() { return new DynamicSecurityMetadataSource(); }
@ConditionalOnBean(name = "dynamicSecurityService") @Bean public DynamicSecurityFilter dynamicSecurityFilter() { return new DynamicSecurityFilter(); }
//跨域 @Autowired private CorsFilter corsFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
ExpressionUrlAuthorizationConfigurer
registry.and()
.csrf().disable() //遮蔽CSRF控制,即spring security不再限制CSRF
.authorizeRequests()
.anyRequest().authenticated();
registry.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(corsFilter, CsrfFilter.class)//跨域配置
.exceptionHandling() //異常處理,下面是自定義的兩個異常
.accessDeniedHandler(myAccessDeniedHandler())//授權異常捕獲
.authenticationEntryPoint(myAuthenticationEntryPoint())//認證異常捕獲
.and()
.addFilterBefore(jwtAuthenticationTokenFilter(),
UsernamePasswordAuthenticationFilter.class);// 自定義認證過濾器
//新增動態許可權校驗過濾器
registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class);
return http.build();
} } ```
JWT
那麼如何使用 JWT 呢?此時我們需要使用一個叫做 JJWT 的庫。
JJWT
JJWT 是一個提供端到端的 JWT 建立和驗證的 Java 庫。永遠免費和開源(Apache License,版本2.0),JJWT 很容易使用和理解。它被設計成一個以建築為中心的流暢介面,隱藏了它的大部分複雜性。
- JJWT 的目標是最容易使用和理解用於在 JVM 上建立和驗證 JSON Web 令牌(JWTs)的庫。
- JJWT 是基於 JWT、JWS、JWE、JWK 和 JWA RFC規範的Java實現。
- JJWT 還添加了一些不屬於規範的便利擴充套件,比如 JWT 壓縮和索賠強制。
JJWT 規範相容
- 建立和解析明文壓縮JWTs
- 建立、解析和驗證所有標準JWS演算法的數字簽名壓縮JWTs(又稱JWSs):
- HS256:使用SHA-256的HMAC
- HS384:使用SHA-384的HMAC
- HS512:使用SHA-512的HMAC
- RS256:使用SHA-256的RSASSA-PKCS-v1_5
- RS384:使用SHA-384的RSASSA-PKCS-v1_5
- RS512:使用SHA-512的RSASSA-PKCS-v1_5
- PS256:使用SHA-256的RSASSA-PSS和使用SHA-256的MGF1
- PS384:使用SHA-384的RSASSA-PSS和使用SHA-384的MGF1
- PS512:使用SHA-512的RSASSA-PSS和使用SHA-512的MGF1
- ES256:使用P-256和SHA-256的ECDSA
- ES384:使用P-384和SHA-384的ECDSA
- ES512:使用P-521和SHA-512的ECDSA
實際應用
匯入 maven 依賴
```xml
JWT token的工具類
用於生成和解析JWT token的工具類
```java /* * JwtToken生成的工具類 JWT token的格式:header.payload.signature header的格式(演算法、token的型別): {"alg": * "HS512","typ": "JWT"} payload的格式(使用者名稱、建立時間、生成時間): {"sub":"wang","created":1489079981393,"exp":1489684781} * signature的生成演算法: HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret) / @Slf4j @Component public class JwtTokenUtil {
private static final String CLAIM_KEY_USERNAME = "sub"; private static final String CLAIM_KEY_CREATED = "created"; @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; @Value("${jwt.tokenHead}") private String tokenHead;
/*
* 根據負責生成JWT的token
/
private String generateToken(Map
/* * 從token中獲取JWT中的負載 / private Claims getClaimsFromToken(String token) { Claims claims = null; try { claims = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } catch (Exception e) { log.info("JWT格式驗證失敗:{}", token); } return claims; }
/* * 生成token的過期時間 / private Date generateExpirationDate() { return new Date(System.currentTimeMillis() + expiration * 1000); }
/* * 從token中獲取登入使用者名稱 / public String getUserNameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; }
/* * 驗證token是否還有效 * * @param token 客戶端傳入的token * @param userDetails 從資料庫中查詢出來的使用者資訊 / public boolean validateToken(String token, UserDetails userDetails) { String username = getUserNameFromToken(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); }
/* * 判斷token是否已經失效 / private boolean isTokenExpired(String token) { Date expiredDate = getExpiredDateFromToken(token); return expiredDate.before(new Date()); }
/* * 從token中獲取過期時間 / private Date getExpiredDateFromToken(String token) { Claims claims = getClaimsFromToken(token); return claims.getExpiration(); }
/*
* 根據使用者資訊生成token
/
public String generateToken(UserDetails userDetails) {
Map
/* * 當原來的token沒過期時是可以重新整理的 * * @param oldToken 帶tokenHead的token / public String refreshHeadToken(String oldToken) { if (StrUtil.isEmpty(oldToken)) { return null; } String token = oldToken.substring(tokenHead.length()); if (StrUtil.isEmpty(token)) { return null; } //token校驗不通過 Claims claims = getClaimsFromToken(token); if (Objects.isNull(claims)) { return null; } //如果token已經過期,不支援重新整理 if (isTokenExpired(token)) { return null; } //如果token在30分鐘之內剛重新整理過,返回原token if (tokenRefreshJustBefore(token, 30 * 60)) { return token; } else { claims.put(CLAIM_KEY_CREATED, new Date()); return generateToken(claims); } }
/* * 判斷token在指定時間內是否剛剛重新整理過 * * @param token 原token * @param time 指定時間(秒) / private boolean tokenRefreshJustBefore(String token, int time) { Claims claims = getClaimsFromToken(token); Date created = claims.get(CLAIM_KEY_CREATED, Date.class); Date refreshDate = new Date(); //重新整理時間在建立時間的指定時間內 if (refreshDate.after(created) && refreshDate.before(DateUtil.offsetSecond(created, time))) { return true; } return false; } } ```
專案實踐
資料庫
稍微複雜點的後臺系統都會涉及到使用者許可權管理,既然我們選擇使用 Spring Security 這一安全框架,那麼就需要考慮如何來設計一套許可權管理系統。首先需要知道的是,許可權就是對資料(系統的實體類
)和資料可進行的操作(增刪查改
)的集中管理。要構建一個可用的許可權管理系統,涉及到三個核心類:一個是使用者User
,一個是角色Role
,最後是許可權Permission
。
使用者角色,角色許可權都是多對多關係,即一個使用者擁有多個角色,一個角色屬於多個使用者;一個角色擁有多個許可權,一個許可權屬於多個角色。這種方式需要指定使用者有哪些角色,而角色又有哪些許可權。
執行如下 SQL 語句,來構建資料表並初始化資料。
``sql
CREATE TABLE
user(
idint(11) NOT NULL AUTO_INCREMENT,
usernamevarchar(50) DEFAULT NULL,
passwordvarchar(100) DEFAULT NULL,
phonevarchar(50) DEFAULT NULL,
PRIMARY KEY (
id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='使用者表';
CREATE TABLE role
(
id
int(11) NOT NULL AUTO_INCREMENT,
name
varchar(50) DEFAULT NULL,
desc
varchar(50) DEFAULT NULL,
PRIMARY KEY (id
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色表';
INSERT into role
(name,desc
) values('admin','管理員');
INSERT into role
(name,desc
) values('role1','角色1');
INSERT into role
(name,desc
) values('role2','角色2');
CREATE TABLE permission
(
id
int(11) NOT NULL AUTO_INCREMENT,
name
varchar(50) DEFAULT NULL,
url
varchar(50) DEFAULT NULL,
PRIMARY KEY (id
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='許可權表';
INSERT into permission(name,url) values('all','/'); INSERT into permission(name,url) values('home','/home/'); INSERT into permission(name,url) values('product','/product/'); INSERT into permission(name,url) values('customer','/customer/');
CREATE TABLE user_role
(
id
int(11) NOT NULL AUTO_INCREMENT,
uid
int(11) DEFAULT NULL,
rid
int(11) DEFAULT NULL,
PRIMARY KEY (id
),
KEY users_role_ibfk_1
(uid
),
KEY users_role_ibfk_2
(rid
),
CONSTRAINT users_role_ibfk_1
FOREIGN KEY (uid
) REFERENCES user
(id
),
CONSTRAINT users_role_ibfk_2
FOREIGN KEY (rid
) REFERENCES role
(id
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='使用者角色對照表';
CREATE TABLE role_permission
(
id
int(11) NOT NULL AUTO_INCREMENT,
rid
int(11) DEFAULT NULL ,
pid
int(11) DEFAULT NULL,
PRIMARY KEY (id
),
KEY role_permission_ibfk_1
(rid
),
KEY role_permission_ibfk_2
(pid
),
CONSTRAINT role_permission_ibfk_1
FOREIGN KEY (rid
) REFERENCES role
(id
),
CONSTRAINT role_permission_ibfk_2
FOREIGN KEY (pid
) REFERENCES permission
(id
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色許可權對照表';
```
待會我們可以註冊使用者,對照表資料則需要在資料庫中手動新增,暫時未提供相關介面。
程式碼
專案中有兩個 controller 檔案,一個用於使用者登入,另一個資源訪問,這裡簡單貼一下程式碼,感興趣的可以去我的 github 上下載原始碼。
```java @RestController public class UserController {
@Autowired private UserService userService; @Value("${jwt.tokenHeader}") private String tokenHeader; @Value("${jwt.tokenHead}") private String tokenHead;
@PostMapping("/register") public Result register(@RequestBody UserRequest userRequest) { userService.register(userRequest); return Result.ok(); }
@PostMapping("/login") public Result
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根據賬號去資料庫查詢...
User user = userMapper.selectByUserName(username);
if (!Objects.isNull(user)) {
List
throw new UsernameNotFoundException("使用者名稱或密碼錯誤");
}
} ```
以及處理使用者註冊登入的服務
```java @Service @Slf4j @RequiredArgsConstructor public class UserService {
private final MyUserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; private final JwtTokenUtil jwtTokenUtil; private final UserStruct userStruct; private final UserMapper userMapper;
public String login(String username, String password) { String token = null; try {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
BusinessException.fail("密碼不正確");
}
if (!userDetails.isEnabled()) {
BusinessException.fail("帳號已被禁用");
}
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
token = jwtTokenUtil.generateToken(userDetails);
} catch (AuthenticationException e) {
log.error("登入異常,detail" + e.getMessage());
}
return token;
}
public void register(UserRequest userRequest) { User user = userMapper.selectByUserName(userRequest.getUsername()); if (Objects.nonNull(user)) { BusinessException.fail("使用者名稱已存在!"); } String encodePassword = passwordEncoder.encode(userRequest.getPassword()); User obj = userStruct.toUser(userRequest); obj.setPassword(encodePassword); userMapper.insert(obj); }
public String refreshToken(String oldToken) { return jwtTokenUtil.refreshHeadToken(oldToken); } } ```
測試
註冊使用者
使用者登入並返回 token
手動給 hresh3 使用者賦予 role1 角色,即具備 home 目錄下的訪問許可權。
複製登入後獲取到的 token,訪問 home/level1,可以正常訪問。
如果想要訪問 customer 目錄,則會提示無權訪問。
總結
《從零打造專案》系列的文章並不是一口氣就寫完了的,有些知識點也是邊學邊用,比如 SpringSecurity 安全框架,雖然之前簡單學過,但僅限於皮毛,底層邏輯不瞭解,更無法獨自造輪子。關於 SpringSecurity 的學習其實遠不止這些內容,想要繼續學習推薦大家閱讀《深入淺出 Spring Security》,或者作者在網上釋出的一系列文章。
本文貼合實際應用,詳細介紹瞭如何自定義認證和授權邏輯,測試程式碼基本滿足一個簡單專案的需求。至此,關於 SpringSecurity 的學習暫時到此為止,目前掌握的內容差不多可以滿足專案需求,所以接下來我會繼續完成商城專案的開發。
參考文獻
SpringSecurity系列 之 AuthenticationEntryPoint介面及其實現類的用法
spring security中自定義AccessDeniedHandler不生效的實驗記錄
Spring Security教程(八):使用者認證流程原始碼詳解
spring boot security 授權--自定義AccessDecisionManager和AccessDecisionVoter
- SpringBoot結合Liquibase實現資料庫變更管理
- Spring Security結合JWT實現認證與授權
- Spring Security入門學習
- JVM系列之:你知道為什麼要有兩個 Survivor嗎?關於卡表技術又有多少了解
- 高考報志願:可惜當年填志願時,沒人告訴我這些...
- Java併發進階之:Java記憶體模型(JMM)詳解
- 工作那麼久,該如何提升程式碼質量
- JVM系列之:你知道Java有多少種記憶體溢位嗎
- JVM系列之:日誌分析工具:GCViewer、VisualVM、GCeasy
- JVM系列之:GC調優基礎以及初識jstat命令
- JVM系列之:關於即時編譯器的其他一些優化手段
- Git實操小課堂
- 2020面試準備之併發進階
- 2020面試準備之併發基礎
- Java併發程式設計學習系列八:單例模式
- 全面學習volatile
- 基於SpringBoot將Json資料匯入到資料庫
- SpringCloud學習之Rest服務
- SpringCloud學習之Eureka
- 使用Kettle動態生成頁碼並實現分頁資料同步