Spring Security結合JWT實現認證與授權

語言: CN / TW / HK

theme: cyanosis

Spring Security系列文章

經過這段時間的學習,我們已不滿足於普通案例的實操,想著啥時候找個更貼合實際應用的案例練練手,比如說 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 中進行校驗。

Spring Security認證流程

打斷點發送登入請求進入原始碼中,我們會發現它會進入到 UsernamePasswordAuthenticationFilter,在該類中,有一個attemptAuthentication方法在這個方法中,會獲取請求傳入的 username 以及 password 引數的資訊,然後使用構造器 new UsernamePasswordAuthenticationToken(username, password)封裝為一個 UsernamePasswordAuthenticationToken 物件,在這個構造器內部會將對應的資訊賦值給各自的本地變數,並且會呼叫父類 AbstractAuthenticationToken 構造器,傳一個 null值進去,為什麼是 null 呢?因為剛開始並沒有認證,因此使用者沒有任何許可權,並且設定沒有認證的資訊(setAuthenticated(false)),最後會進入AuthenticationManager 介面的實現類 ProviderManager 中,接著就呼叫 authenticate 方法。

看到這裡是不是有點熟悉,這不就來到了前一篇文章中提到的 DaoAuthenticationProvider 嘛,它的父類是 AbstractUserDetailsAuthenticationProvider,其中就包括 authenticate 方法,這裡就不重複介紹了。

綜上可知,UsernamePasswordAuthenticationFilterDaoAuthenticationProvider 是 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); } } ```

上述方法大致流程如下:

  1. 從請求頭獲取token資訊;
  2. 如果 token 不為 null,且格式正確,則獲取 token 中關鍵資訊,然後呼叫 JWT 工具類根據 token 解析出使用者名稱。根據使用者名稱去資料庫裡獲取具體資訊,與 token 進行校驗,匹配成功則將使用者資訊封裝到 UsernamePasswordAuthenticationToken 物件中,並設定到安全上下文中。

在 SecurityConfig 中這樣配置自定義的過濾器:

java http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);// 自定義認證過濾器

自定義許可權處理

Spring Security 可以通過 http.authorizeRequests() 對web請求進行授權保護。Spring Security 使用標準Filter建立了對web請求的攔截,最終實現對資源的授權訪問。授權流程如下:

授權流程圖

分析授權流程:

  1. 攔截請求,已認證使用者訪問受保護的web資源將被 SecurityFilterChain 中的 FilterSecurityInterceptor 的子類攔截。

  2. 獲取資源訪問策略,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()方法,該方法會讀取上述訪問規則,然後封裝到 Collection 物件中。如果我們自定義 SecurityMetadataSource 實現類,則不會再執行 DefaultFilterInvocationSecurityMetadataSource 程式碼邏輯,即使配置 antMatchers("/r/r1").hasAuthority("p1") 也是無用。

  1. 最後,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 configAttributes) throws AccessDeniedException, InsufficientAuthenticationException { // 當介面未被配置資源時直接放行 if (CollUtil.isEmpty(configAttributes)) { return; } Iterator iterator = configAttributes.iterator(); while (iterator.hasNext()) { ConfigAttribute configAttribute = iterator.next(); //將訪問所需資源或使用者擁有資源進行比對 String needAuthority = configAttribute.getAttribute(); for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) { if (needAuthority.trim().equals(grantedAuthority.getAuthority())) { return; } } } throw new AccessDeniedException("抱歉,您沒有訪問許可權"); }

@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 configAttributeMap = null; @Autowired private DynamicSecurityService dynamicSecurityService;

@PostConstruct public void loadDataSource() { configAttributeMap = dynamicSecurityService.loadDataSource(); }

public void clearDataSource() { configAttributeMap.clear(); configAttributeMap = null; }

@Override public Collection getAttributes(Object object) throws IllegalArgumentException { if (configAttributeMap == null) { this.loadDataSource(); } List configAttributes = new ArrayList<>(); //獲取當前訪問的路徑 String url = ((FilterInvocation) object).getRequestUrl(); String path = URLUtil.getPath(url); PathMatcher pathMatcher = new AntPathMatcher(); Iterator iterator = configAttributeMap.keySet().iterator(); //獲取訪問該路徑所需資源 while (iterator.hasNext()) { String pattern = iterator.next(); if (pathMatcher.match(pattern, path)) { configAttributes.add(configAttributeMap.get(pattern)); } } // 未設定操作請求許可權,返回空集合 return configAttributes; }

@Override public Collection getAllConfigAttributes() { return null; }

@Override public boolean supports(Class<?> clazz) { return true; } } ```

DynamicSecurityService 用來讀取 permission 表,獲取許可權配置。

```java @Service public class DynamicSecurityService {

@Autowired private PermissionMapper permissionMapper;

// 載入資源ANT萬用字元和資源對應MAP public Map loadDataSource() { Map urlAndResourceNameMap = new ConcurrentHashMap<>(); List permissions = permissionMapper.findAll(); permissions.forEach(permission -> urlAndResourceNameMap .put(permission.getUrl(), new SecurityConfig(permission.getName()))); return urlAndResourceNameMap; } } ```

FilterSecurityInterceptor

FilterSecurityInterceptor 攔截器,用於判斷當前請求身份認證是否成功,是否有相應的許可權,當身份認證失敗或者許可權不足的時候便會丟擲相應的異常;

Spring Security使用FilterSecurityInterceptor過濾器來進行URL許可權校驗,實際使用流程大致如下:

  1. 通過資料庫動態配置url資源許可權
  2. 系統啟動時,通過FilterSecurityInterceptor濾器到資料庫載入系統資源許可權列表
  3. 使用者登陸時通過自定義的UserDetailsService載入當前使用者的角色列表
  4. 當有請求訪問時,通過FilterSecurityInterceptor對比系統資源許可權列表和使用者資源許可權列表(在使用者登入時新增到使用者資訊中)來判斷使用者是否有該url的訪問許可權。

自定義URL許可權驗證需要在FilterSecurityInterceptor自定義的配置項

  1. DynamicSecurityMetadataSource:實現FilterInvocationSecurityMetadataSource介面,在實現類中載入資源許可權,並在filterSecurityInterceptor中注入該實現類。
  2. 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 urls = new ArrayList<>();

} ```

自定義異常處理

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 WebAuthenticationEntryPoint提供了一些內建實現 :

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.ExpressionInterceptUrlRegistry registry = http .authorizeRequests(); //不需要保護的資源路徑允許訪問 for (String url : ignoreUrlsConfig().getUrls()) { registry.antMatchers(url).permitAll(); } //允許跨域請求的OPTIONS請求 registry.antMatchers(HttpMethod.OPTIONS) .permitAll();

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

io.jsonwebtoken jjwt 0.9.1 ```

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 claims) { return Jwts.builder() .setClaims(claims) .setExpiration(generateExpirationDate()) .signWith(SignatureAlgorithm.HS512, secret) .compact(); }

/* * 從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 claims = new HashMap<>(); claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername()); claims.put(CLAIM_KEY_CREATED, DateUtil.date()); return generateToken(claims); }

/* * 當原來的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 TABLEuser(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 login(@RequestParam("username") String username, @RequestParam("password") String password) { String token = userService.login(username, password); Map tokenMap = new HashMap<>(); tokenMap.put("token", token); tokenMap.put("tokenHead", tokenHead); return Result.ok(tokenMap); }

@PostMapping("/refreshToken") public Result refreshToken(@RequestBody HttpServletRequest request) { String token = request.getHeader(tokenHeader); String refreshToken = userService.refreshToken(token); Map tokenMap = new HashMap<>(); tokenMap.put("token", refreshToken); tokenMap.put("tokenHead", tokenHead); return Result.ok(tokenMap); }

}

@RestController public class ResourceController {

@GetMapping("/home/level1") public Result getHomeLevel1() { return Result.ok("獲取訪問Home目錄下的Level1的許可權"); }

@GetMapping("/home/level2") public Result getHomeLevel2() { return Result.ok("獲取訪問Home目錄下的Level2的許可權"); }

@GetMapping("/customer/level1") public Result getCustomerLevel1() { return Result.ok("獲取訪問Customer目錄下的Level1的許可權"); }

@GetMapping("/customer/level2") public Result getCustomerLevel2() { return Result.ok("獲取訪問Customer目錄下的Level2的許可權"); }

@GetMapping("/product/level1") public Result getProductLevel1() { return Result.ok("獲取訪問Product目錄下的Level3的許可權"); }

@GetMapping("/product/level2") public Result getProductLevel2() { return Result.ok("獲取訪問Product目錄下的Level的許可權"); } } ```

首先我們需要自定義 UserDetails

```java @Setter @Builder public class MyUserDetails implements UserDetails {

private User user; private List permissionList;

@Override public Collection<? extends GrantedAuthority> getAuthorities() { return permissionList.stream() .map(permission -> new SimpleGrantedAuthority(permission.getName())).collect( Collectors.toList()); }

@Override public String getPassword() { return user.getPassword(); }

@Override public String getUsername() { return user.getUsername(); }

@Override public boolean isAccountNonExpired() { return true; }

@Override public boolean isAccountNonLocked() { return true; }

@Override public boolean isCredentialsNonExpired() { return true; }

@Override public boolean isEnabled() { return !Objects.isNull(user); } } ```

接著處理自定義 UserDetailsService

```java @Component @RequiredArgsConstructor public class MyUserDetailsService implements UserDetailsService {

private final UserMapper userMapper; private final PermissionMapper permissionMapper;

@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //根據賬號去資料庫查詢... User user = userMapper.selectByUserName(username); if (!Objects.isNull(user)) { List permissionList = permissionMapper.findPermissionsByUserId(user.getId()); return MyUserDetails.builder().user(user).permissionList(permissionList).build(); }

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

使用者登入並返回 token

手動給 hresh3 使用者賦予 role1 角色,即具備 home 目錄下的訪問許可權。

複製登入後獲取到的 token,訪問 home/level1,可以正常訪問。

有權訪問頁面

如果想要訪問 customer 目錄,則會提示無權訪問。

無權訪問

總結

《從零打造專案》系列的文章並不是一口氣就寫完了的,有些知識點也是邊學邊用,比如 SpringSecurity 安全框架,雖然之前簡單學過,但僅限於皮毛,底層邏輯不瞭解,更無法獨自造輪子。關於 SpringSecurity 的學習其實遠不止這些內容,想要繼續學習推薦大家閱讀《深入淺出 Spring Security》,或者作者在網上釋出的一系列文章

本文貼合實際應用,詳細介紹瞭如何自定義認證和授權邏輯,測試程式碼基本滿足一個簡單專案的需求。至此,關於 SpringSecurity 的學習暫時到此為止,目前掌握的內容差不多可以滿足專案需求,所以接下來我會繼續完成商城專案的開發。

參考文獻

SpringSecurity系列 之 AuthenticationEntryPoint介面及其實現類的用法

Spring Security 實戰乾貨:自定義異常處理

spring security中自定義AccessDeniedHandler不生效的實驗記錄

OncePerRequestFilter的作用

Spring Security教程(八):使用者認證流程原始碼詳解

Spring Security 認證流程原始碼詳解

spring boot security 授權--自定義AccessDecisionManager和AccessDecisionVoter