spring security oauth2.0 流程解析[19.06.19]

前言

本文將從基於spring security oauth2.0流程中的(1)獲取token的認證、受權以及(2)帶token去訪問受保護資源的流程進行源碼解析。前端

身份認證流程

瞭解spring security 的應該知道oauth2.0協議提供了四種受權模式
spring

  • 簡化模式(implicit)
  • 客戶端模式(client_credentials)
  • 受權碼模式(authorization_code)
  • 密碼模式(password)

以client模式爲例,獲取token的地址爲:數據庫

http://localhost:8080/oauth/token?client_id=client_1&client_secret=123456&scope=select&grant_type=client_credentials
複製代碼

其餘三種的獲取token的url也爲/oauth/token,只是後面的參數不一樣。
可想而知,想要了解如何獲取token,/oauth/token即爲入口。 從這個入口開始分析,spring security oauth2內部是如何生成token的。
express

首先開啓debug信息:json

logging:
  level:
    org.springframework: DEBUG
複製代碼

在搭建好的認證服務中,調用上述client模式下獲取token的url,查看打印出來的結果。安全

截取關鍵的打印結果,能夠看出大概的流程,在請求到達/oauth/token以前通過了ClientCredentialsTokenEndpointFilter這個過濾器。 ClientCredentialsTokenEndpointFilter過濾器是做爲client模式獲取token的入口,其餘模式對應的入口過濾器會有不一樣,好比password模式的爲ResourceOwnerPasswordTokenGranter。
bash

ClientCredentialsTokenEndpointFilter

ClientCredentialsTokenEndpointFilter的關鍵方法以下:服務器

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException, IOException, ServletException {
    ...
    String clientId = request.getParameter("client_id");
    String clientSecret = request.getParameter("client_secret");

    ...
    clientId = clientId.trim();
    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(clientId,
            clientSecret);

    return this.getAuthenticationManager().authenticate(authRequest);

}
複製代碼

從request中獲取到client_id與client_secret,Spring Security將獲取到的用戶名和密碼封裝成UsernamePasswordAuthenticationToken做爲身份標識(client模式下取的爲client_id對應username、client_secret對應password來使用)。
將上述產生的Authentication對象使用容器中的頂級身份管理器AuthenticationManager去進行身份認證。app

AuthenticationManager

該接口最主要的做用是用來作驗證,這個接口只有一個方法:框架

public interface AuthenticationManager {

  Authentication authenticate(Authentication authentication)
    throws AuthenticationException;

}
複製代碼

其中authenticate()方法運行後可能會有三種狀況:

  • 驗證成功,返回一個帶有用戶信息的Authentication。
  • 驗證失敗,拋出一個AuthenticationException異常。
  • 沒法判斷,返回null。

ProviderManager

ProviderManager是上面的AuthenticationManager最多見的實現,它不本身處理驗證,而是將驗證委託給其所配置的AuthenticationProvider列表,而後會依次調用每個 AuthenticationProvider進行認證,這個過程當中只要有一個AuthenticationProvider驗證成功,就不會再繼續作更多驗證,會直接以該認證結果做爲ProviderManager的認證結果。

AuthenticationProvider列表在ProviderManager中以一個List(泛型:AuthenticationProvider)存在,循環該List去完成上述的認證過程,認證相關核心代碼以下:

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
		InitializingBean {
...
        private List<AuthenticationProvider> providers = Collections.emptyList();
...
	public Authentication authenticate(Authentication authentication)
			throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		Authentication result = null;
		boolean debug = logger.isDebugEnabled();

		for (AuthenticationProvider provider : getProviders()) {
                ...
			try {
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException e) {
				prepareException(e, authentication);
				throw e;
			}
			catch (InternalAuthenticationServiceException e) {
				prepareException(e, authentication);
				throw e;
			}
			catch (AuthenticationException e) {
				lastException = e;
			}
		}

		if (result == null && parent != null) {
			// Allow the parent to try.
			try {
				result = parent.authenticate(authentication);
			}
			catch (ProviderNotFoundException e) {
			}
			catch (AuthenticationException e) {
				lastException = e;
			}
		}

		if (result != null) {
			if (eraseCredentialsAfterAuthentication
					&& (result instanceof CredentialsContainer)) {
				((CredentialsContainer) result).eraseCredentials();
			}
			eventPublisher.publishAuthenticationSuccess(result);
			return result;
		}

		if (lastException == null) {
			lastException = new ProviderNotFoundException(messages.getMessage(
					"ProviderManager.providerNotFound",
					new Object[] { toTest.getName() },
					"No AuthenticationProvider found for {0}"));
		}
		prepareException(lastException, authentication);
		throw lastException;
	}
}
複製代碼

AuthenticationProvider的經常使用實現類則是DaoAuthenticationProvider,認證中所使用到的用戶信息的獲取是經過類內部聚合了UserDetailsService接口,UserDetailsService接口是獲取用戶詳細信息的最終接口。

UserDetailsService以及其實現類

UserDetailsService做爲獲取用戶信息的接口,其中只有一個方法

public interface UserDetailsService {
   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
複製代碼

該接口的實現類經常使用的分爲三個

  • InMemoryUserDetailsManager (內存中保存的用戶信息)
  • JdbcDaoImpl(數據庫中保存用戶信息)
  • ClientDetailsUserDetailsService (client模式是不存在「用戶」概念,該實現類是將client客戶端的信息(client_id,client_secret)適配成用戶的信息(username,password),這樣認證流程就不須要進行修改)

認證總結

綜上所述,整個認證流程可分爲以下幾步:

  • 用戶使用用戶名和密碼進行登陸。
  • Spring Security將獲取到的用戶名和密碼封裝成一個Authentication接口的實現類,好比經常使用的UsernamePasswordAuthenticationToken。
  • 將上述產生的Authentication對象傳遞給AuthenticationManager的實現類ProviderManager進行認證。
  • ProviderManager依次調用各個AuthenticationProvider進行認證,認證成功後返回一個封裝了用戶權限等信息的Authentication對象。
  • 將AuthenticationManager返回的Authentication對象賦予給當前的SecurityContext。

認證相關UML類圖

受權頒發token流程

前面的兩個ClientCredentialsTokenEndpointFilter和DaoAuthenticationProvider能夠理解爲一些前置校驗,和身份封裝。 基本client模式,通過ClientCredentialsTokenEndpointFilter以後,身份信息已經獲得了AuthenticationManager的驗證。接着便到達了TokenEndpoint。

TokenEndpoint

@FrameworkEndpoint
public class TokenEndpoint extends AbstractEndpoint {

    @RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
    public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
    Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
         ...
        String clientId = getClientId(principal);
        ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
        ...
        TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
        ...
        OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
        ...
        return getResponse(token);

    }
    private TokenGranter tokenGranter;

 }
複製代碼

拿掉了一些校驗代碼以後,真正的/oauth/token端點暴露在了咱們眼前,其中方法參數中的Principal通過以前的過濾器,已經被填充了相關的信息,方法的內部是經過TokenGranter來實現頒發token。

TokenGranter

UML類圖體現的TokenGranter接口的設計以下:

TokenGranter的設計思路是使用CompositeTokenGranter管理一個List列表,每一種grantType對應一個具體的真正受權者,在受權過程當中能夠發現CompositeTokenGranter 內部循環調用五種TokenGranter實現類的grant方法,而granter內部則是經過grantType來區分走哪一種受權類型。

public class CompositeTokenGranter implements TokenGranter {

    private final List<TokenGranter> tokenGranters;

    public CompositeTokenGranter(List<TokenGranter> tokenGranters) {
        this.tokenGranters = new ArrayList<TokenGranter>(tokenGranters);
    }

    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
        for (TokenGranter granter : tokenGranters) {
            OAuth2AccessToken grant = granter.grant(grantType, tokenRequest);
            if (grant!=null) {
                return grant;
            }
        }
        return null;
    }
}
複製代碼

五種受權類型分別是:

  • ResourceOwnerPasswordTokenGranter ==> password密碼模式
  • AuthorizationCodeTokenGranter ==> authorization_code受權碼模式
  • ClientCredentialsTokenGranter ==> client_credentials客戶端模式
  • ImplicitTokenGranter ==> implicit簡化模式
  • RefreshTokenGranter ==>refresh_token 刷新token專用

AbstractTokenGranter

在客戶端(client)模式下token是如何產生的,則須要繼續看5種受權的抽象類:AbstractTokenGranter。

public abstract class AbstractTokenGranter implements TokenGranter {

    protected final Log logger = LogFactory.getLog(getClass());

    //與token相關的service,重點
    private final AuthorizationServerTokenServices tokenServices;
    
    private final ClientDetailsService clientDetailsService;
    
    private final OAuth2RequestFactory requestFactory;

    private final String grantType;
    ...

    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

        ...
        String clientId = tokenRequest.getClientId();
        ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
        validateGrantType(grantType, client);

        logger.debug("Getting access token for: " + clientId);

        return getAccessToken(client, tokenRequest);

    }

    protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
        return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
    }

    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
        OAuth2Request storedOAuth2Request = requestFactory.createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, null);
    }

    ...
}
複製代碼

AuthorizationServerTokenServices

能夠經過上述源碼看到token的生成是調用了AuthorizationServerTokenServices的createAccessToken方法,在這裏針對AuthorizationServerTokenServices進行解析。

public interface AuthorizationServerTokenServices {
    //建立token
    OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
    //刷新token
    OAuth2AccessToken refreshAccessToken(String refreshToken, TokenRequest tokenRequest)
            throws AuthenticationException;
    //獲取token
    OAuth2AccessToken getAccessToken(OAuth2Authentication authentication);

}
複製代碼

在該接口的惟一實現類DefaultTokenServices中,能夠看到token是如何生成的,而且能夠了解到框架對token進行哪些信息的關聯。

@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {

    OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
    OAuth2RefreshToken refreshToken = null;
    if (existingAccessToken != null) {
        if (existingAccessToken.isExpired()) {
            if (existingAccessToken.getRefreshToken() != null) {
                refreshToken = existingAccessToken.getRefreshToken();
                // The token store could remove the refresh token when the
                // access token is removed, but we want to
                // be sure...
                tokenStore.removeRefreshToken(refreshToken);
            }
            tokenStore.removeAccessToken(existingAccessToken);
        }
        else {
            // Re-store the access token in case the authentication has changed
            tokenStore.storeAccessToken(existingAccessToken, authentication);
            return existingAccessToken;
        }
    }

    
    if (refreshToken == null) {
        refreshToken = createRefreshToken(authentication);
    }else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
        ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
        if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
            refreshToken = createRefreshToken(authentication);
        }
    }

    OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
    tokenStore.storeAccessToken(accessToken, authentication);
    // In case it was modified
    refreshToken = accessToken.getRefreshToken();
    if (refreshToken != null) {
        tokenStore.storeRefreshToken(refreshToken, authentication);
    }
    return accessToken;

}
複製代碼

根據上述可大致知道AuthorizationServerTokenServices類,他提供了建立token,刷新token,獲取token的實現。在建立token時,他會調用tokenStore對產生的token和相關信息存儲到對應的實現類中,能夠是Redis,數據庫,內存,jwt。

資源認證流程

獲取到token後,會拿token去請求受限的資源,接下來分析下資源認證的流程是如何運轉的。
實現資源服務器,須要繼承ResourceServerConfigurerAdapter,咱們便從ResourceServerConfigurerAdapter做爲入口進行拓展。

ResourceServerConfigurerAdapter

public class ResourceServerConfigurerAdapter implements ResourceServerConfigurer {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources <1> ) throws Exception {
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
    }

}
複製代碼

咱們能夠看到其實現了ResourceServerConfigurer接口,內部關聯了ResourceServerSecurityConfigurer和HttpSecurity。前者與資源安全配置相關,後者與http安全配置相關,接下來繼續看ResourceServerSecurityConfigurer這個類。

ResourceServerSecurityConfigurer

核心的代碼以下:

public void configure(HttpSecurity http) throws Exception {

    AuthenticationManager oauthAuthenticationManager = oauthAuthenticationManager(http);
    resourcesServerFilter = new OAuth2AuthenticationProcessingFilter();//  <1>注意點
    resourcesServerFilter.setAuthenticationEntryPoint(authenticationEntryPoint);
    resourcesServerFilter.setAuthenticationManager(oauthAuthenticationManager);// <2>注意點
    if (eventPublisher != null) {
        resourcesServerFilter.setAuthenticationEventPublisher(eventPublisher);
    }
    if (tokenExtractor != null) {
        resourcesServerFilter.setTokenExtractor(tokenExtractor);//<3>注意點
    }
    resourcesServerFilter = postProcess(resourcesServerFilter);
    resourcesServerFilter.setStateless(stateless);

    // @formatter:off
    http
        .authorizeRequests().expressionHandler(expressionHandler)
    .and()
        .addFilterBefore(resourcesServerFilter, AbstractPreAuthenticatedProcessingFilter.class)
        .exceptionHandling()
            .accessDeniedHandler(accessDeniedHandler)//<4>注意點
            .authenticationEntryPoint(authenticationEntryPoint);
    // @formatter:on
}
複製代碼

這段是oauth2與HttpSecurity相關的核心配置,其中不少注意點:

  • <1> 建立OAuth2AuthenticationProcessingFilter,一會進行拓展解析。
  • <2> 爲OAuth2AuthenticationProcessingFilter提供了固定的頂級身份認證接口AuthenticationManager,使用的實現類是OAuth2AuthenticationManager。
  • <3> 設置了TokenExtractor默認的實現類—-BearerTokenExtractor,該類大致用於能夠從請求中不一樣位置獲取token,好比:header、url拼接等,一會進行拓展。
  • <4> 異常處理器,能夠重寫達到自定義異常的目的。(這裏強烈建議自定義異常,保證接口返參的格式是統一的,方便前端人員進行統一解析返回結果。)

OAuth2AuthenticationProcessingFilter

在訪問受限資源的時候咱們會在請求中攜帶accessToken,好比:

http://localhost:8080/user/getUserInfo?access_token=950a7cc9-5a8a-42c9-a693-40e817b1a4b0
複製代碼

攜帶它進行訪問,會進入OAuth2AuthenticationProcessingFilter之中,其核心代碼以下:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain){

    final HttpServletRequest request = (HttpServletRequest) req;
    final HttpServletResponse response = (HttpServletResponse) res;

    try {
        //經過tokenExtractor從ServletRequest中獲取出token
        Authentication authentication = tokenExtractor.extract(request);

        if (authentication == null) {
            ...
        }
        else {
            request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
            if (authentication instanceof AbstractAuthenticationToken) {
                AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken) authentication;
                needsDetails.setDetails(authenticationDetailsSource.buildDetails(request));
            }
            //認證身份
            Authentication authResult = authenticationManager.authenticate(authentication);
            ...
            eventPublisher.publishAuthenticationSuccess(authResult);
            //將身份信息綁定到SecurityContextHolder中
            SecurityContextHolder.getContext().setAuthentication(authResult);
        }
    }
    catch (OAuth2Exception failed) {
        ...
        return;
    }

    chain.doFilter(request, response);
}
複製代碼

整個filter中就是對資源訪問認證的關鍵代碼,其中涉及到了兩個關鍵的類TokenExtractor,AuthenticationManager。AuthenticationManager在上述文章裏也有提到,是頂級身份認證接口。對於TokenExtractor稍後描述。

身份管理器–OAuth2AuthenticationManager

攜帶access_token一定得通過身份認證,通過身份認證就須要使用到AuthenticationManager接口,可是資源訪問身份認證時,AuthenticationManager的實現類並非經常使用實現類ProviderManager,而是OAuth2AuthenticationManager。

這裏只須要記住的是:OAuth2AuthenticationManager是與token認證相關的,而不是與獲取token密切相關的。 其判別身份的關鍵代碼以下:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    ...
    String token = (String) authentication.getPrincipal();
    //這裏是關鍵,藉助tokenServices根據token加載身份信息
    OAuth2Authentication auth = tokenServices.loadAuthentication(token);
    ...

    checkClientDetails(auth);

    if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
        ...
    }
    auth.setDetails(authentication.getDetails());
    auth.setAuthenticated(true);
    return auth;

}
複製代碼

經過上述源碼能夠看到,根據token加載身份信息是使用了tokenServices(ResourceServerTokenServices),獲取到身份信息後進行認證。這裏說明下,tokenServices分爲兩類,一個是用在AuthenticationServer端(認證受權端):

public interface AuthorizationServerTokenServices {
    //建立token
    OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
    //刷新token
    OAuth2AccessToken refreshAccessToken(String refreshToken, TokenRequest tokenRequest)
            throws AuthenticationException;
    //獲取token
    OAuth2AccessToken getAccessToken(OAuth2Authentication authentication);
}
複製代碼

在ResourceServer端(資源服務端)有本身的tokenServices接口:

public interface ResourceServerTokenServices {

    //根據accessToken加載客戶端信息
    OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException;

    //根據accessToken獲取完整的訪問令牌詳細信息。
    OAuth2AccessToken readAccessToken(String accessToken);

}
複製代碼

ResourceServerTokenServices的內部加載邏輯與AuthorizationServerTokenServices基本相似,此處不展開介紹。

TokenExtractor

這個接口只有一個實現類,主要用於從request裏獲取access_token。

public class BearerTokenExtractor implements TokenExtractor {

    private final static Log logger = LogFactory.getLog(BearerTokenExtractor.class);

    @Override
    public Authentication extract(HttpServletRequest request) {
        String tokenValue = extractToken(request);
        if (tokenValue != null) {
            PreAuthenticatedAuthenticationToken authentication = new PreAuthenticatedAuthenticationToken(tokenValue, "");
            return authentication;
        }
        return null;
    }

    protected String extractToken(HttpServletRequest request) {
        // first check the header...
        String token = extractHeaderToken(request);

        // bearer type allows a request parameter as well
        if (token == null) {
            ...
            //從requestParameter中獲取token
        }

        return token;
    }

/**
     * Extract the OAuth bearer token from a header.
     */
    protected String extractHeaderToken(HttpServletRequest request) {
        Enumeration<String> headers = request.getHeaders("Authorization");
        while (headers.hasMoreElements()) { // typically there is only one (most servers enforce that)
            ...
            //從Header中獲取token
        }
        return null;
    }

}
複製代碼

它的做用在於分離出請求中包含的token。咱們可使用多種方式攜帶token。

  • 在Header中攜帶
http://localhost:8080/user/getUser
Header:
Authentication:Bearer f732723d-af7f-41bb-bd06-2636ab2be135
複製代碼
  • 拼接在url中做爲requestParam
http://localhost:8080/user/getUser?access_token=f732723d-af7f-41bb-bd06-2636ab2be135
複製代碼
  • 在form-data表單中攜帶
http://localhost:8080/user/getUser
form param:
access_token=f732723d-af7f-41bb-bd06-2636ab2be135
複製代碼

異常處理

這裏格外說下自定義異常處理,若是想要重寫異常機制,能夠直接替換掉相關的Handler,如權限相關的AccessDeniedHandler。具體的配置應該在@EnableResourceServer中被覆蓋。

好比下面的例子:

自定義權限不足時的異常

@Component("customAccessDeniedHandler")
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException)
            throws IOException, ServletException {
        response.setStatus(HttpStatus.OK.value());
        response.setHeader("Content-Type", "application/json;charset=UTF-8");
        try {
        	JSONObject jsonObject = new JSONObject();
        	jsonObject.put("data", null);
        	jsonObject.put("code", "401");
        	jsonObject.put("msg", "權限不足");
            response.getWriter().write(jsonObject.toJSONString());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
複製代碼

建立一個異常類,去實現AccessDeniedHandler接口,重寫handle方法,加入本身想要的異常封裝。

自定義無效token 或token不存在異常類重寫

@Component
public class AuthExceptionEntryPoint implements AuthenticationEntryPoint{
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws ServletException {
        Throwable cause = authException.getCause();
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("code", "401");
        jsonObject.put("data", "");
        response.setStatus(HttpStatus.OK.value());
        response.setHeader("Content-Type", "application/json;charset=UTF-8");
        try {
            if(cause instanceof InvalidTokenException) {
            	jsonObject.put("msg", "token格式非法或失效");
                response.getWriter().write(jsonObject.toJSONString());
            }else{
            	jsonObject.put("msg", "token缺失");
                response.getWriter().write(jsonObject.toJSONString());
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
複製代碼

建立異常類,去實現AuthenticationEntryPoint,重寫commence方法,自定義異常操做。

最後在資源服務器配置上,加入以下配置:

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter{
	
	@Autowired
	private TokenStore tokenStore;
	
	@Autowired
	private CustomAccessDeniedHandler customAccessDeniedHandler;
	
	@Autowired
	private AuthExceptionEntryPoint authExceptionEntryPoint;

        @Override
        public void configure(HttpSecurity http) throws Exception {
        ...
        }

        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            resources.tokenStore(tokenStore);
            //加入自定義異常處理
            resources.authenticationEntryPoint(authExceptionEntryPoint)   
                    .accessDeniedHandler(customAccessDeniedHandler);
        }
}
複製代碼

這樣在請求資源時,出現如上異常時,會拋出自定義的異常格式,方便前端小夥伴統一解析返回結果。

相關文章
相關標籤/搜索