一文搞定 Spring Security 異常處理機(jī)制涂身!

今天來和小伙伴們聊一聊 Spring Security 中的異常處理機(jī)制雄卷。

在 Spring Security 的過濾器鏈中,ExceptionTranslationFilter 過濾器專門用來處理異常蛤售,在 ExceptionTranslationFilter 中丁鹉,我們可以看到,異常被分為了兩大類:認(rèn)證異常和授權(quán)異常悴能,兩種異常分別由不同的回調(diào)函數(shù)來處理揣钦,今天松哥就來和大家分享一下這里的條條框框。

1.異常分類

Spring Security 中的異衬穑可以分為兩大類冯凹,一種是認(rèn)證異常,一種是授權(quán)異常炒嘲。

認(rèn)證異常就是 AuthenticationException宇姚,它有眾多的實(shí)現(xiàn)類:

image

可以看到,這里的異常實(shí)現(xiàn)類還是蠻多的夫凸,都是都是認(rèn)證相關(guān)的異常浑劳,也就是登錄失敗的異常。這些異常寸痢,有的松哥在之前的文章中都和大家介紹過了,例如下面這段代碼(節(jié)選自:Spring Security 做前后端分離啼止,咱就別做頁(yè)面跳轉(zhuǎn)了!統(tǒng)統(tǒng) JSON 交互):

resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
RespBean respBean = RespBean.error(e.getMessage());
if (e instanceof LockedException) {
    respBean.setMsg("賬戶被鎖定滓窍,請(qǐng)聯(lián)系管理員!");
} else if (e instanceof CredentialsExpiredException) {
    respBean.setMsg("密碼過期,請(qǐng)聯(lián)系管理員!");
} else if (e instanceof AccountExpiredException) {
    respBean.setMsg("賬戶過期巩那,請(qǐng)聯(lián)系管理員!");
} else if (e instanceof DisabledException) {
    respBean.setMsg("賬戶被禁用吏夯,請(qǐng)聯(lián)系管理員!");
} else if (e instanceof BadCredentialsException) {
    respBean.setMsg("用戶名或者密碼輸入錯(cuò)誤,請(qǐng)重新輸入!");
}
out.write(new ObjectMapper().writeValueAsString(respBean));
out.flush();
out.close();

另一類就是授權(quán)異常 AccessDeniedException裆赵,授權(quán)異常的實(shí)現(xiàn)類比較少,因?yàn)槭跈?quán)失敗的可能原因比較少跺嗽。

image

2.ExceptionTranslationFilter

ExceptionTranslationFilter 是 Spring Security 中專門負(fù)責(zé)處理異常的過濾器战授,默認(rèn)情況下,這個(gè)過濾器已經(jīng)被自動(dòng)加載到過濾器鏈中植兰。

有的小伙伴可能不清楚是怎么被加載的璃吧,我這里和大家稍微說一下。

當(dāng)我們使用 Spring Security 的時(shí)候畜挨,如果需要自定義實(shí)現(xiàn)邏輯,都是繼承自 WebSecurityConfigurerAdapter 進(jìn)行擴(kuò)展膝晾,WebSecurityConfigurerAdapter 中本身就進(jìn)行了一部分的初始化操作,我們來看下它里邊 HttpSecurity 的初始化過程:

protected final HttpSecurity getHttp() throws Exception {
    if (http != null) {
        return http;
    }
    AuthenticationEventPublisher eventPublisher = getAuthenticationEventPublisher();
    localConfigureAuthenticationBldr.authenticationEventPublisher(eventPublisher);
    AuthenticationManager authenticationManager = authenticationManager();
    authenticationBuilder.parentAuthenticationManager(authenticationManager);
    Map<Class<?>, Object> sharedObjects = createSharedObjects();
    http = new HttpSecurity(objectPostProcessor, authenticationBuilder,
            sharedObjects);
    if (!disableDefaults) {
        http
            .csrf().and()
            .addFilter(new WebAsyncManagerIntegrationFilter())
            .exceptionHandling().and()
            .headers().and()
            .sessionManagement().and()
            .securityContext().and()
            .requestCache().and()
            .anonymous().and()
            .servletApi().and()
            .apply(new DefaultLoginPageConfigurer<>()).and()
            .logout();
        ClassLoader classLoader = this.context.getClassLoader();
        List<AbstractHttpConfigurer> defaultHttpConfigurers =
                SpringFactoriesLoader.loadFactories(AbstractHttpConfigurer.class, classLoader);
        for (AbstractHttpConfigurer configurer : defaultHttpConfigurers) {
            http.apply(configurer);
        }
    }
    configure(http);
    return http;
}

可以看到,在 getHttp 方法的最后禀忆,調(diào)用了 configure(http);,我們?cè)谑褂?Spring Security 時(shí)离熏,自定義配置類繼承自 WebSecurityConfigurerAdapter 并重寫的 configure(HttpSecurity http) 方法就是在這里調(diào)用的戴涝,換句話說,當(dāng)我們?nèi)ヅ渲?HttpSecurity 時(shí)啥刻,其實(shí)它已經(jīng)完成了一波初始化了。

在默認(rèn)的 HttpSecurity 初始化的過程中娄涩,調(diào)用了 exceptionHandling 方法映跟,這個(gè)方法會(huì)將 ExceptionHandlingConfigurer 配置進(jìn)來扬虚,最終調(diào)用 ExceptionHandlingConfigurer#configure 方法將 ExceptionTranslationFilter 添加到 Spring Security 過濾器鏈中。

我們來看下 ExceptionHandlingConfigurer#configure 方法源碼:

@Override
public void configure(H http) {
    AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(http);
    ExceptionTranslationFilter exceptionTranslationFilter = new ExceptionTranslationFilter(
            entryPoint, getRequestCache(http));
    AccessDeniedHandler deniedHandler = getAccessDeniedHandler(http);
    exceptionTranslationFilter.setAccessDeniedHandler(deniedHandler);
    exceptionTranslationFilter = postProcess(exceptionTranslationFilter);
    http.addFilter(exceptionTranslationFilter);
}

可以看到球恤,這里構(gòu)造了兩個(gè)對(duì)象傳入到 ExceptionTranslationFilter 中:

  • AuthenticationEntryPoint 這個(gè)用來處理認(rèn)證異常辜昵。
  • AccessDeniedHandler 這個(gè)用來處理授權(quán)異常碎捺。

具體的處理邏輯則在 ExceptionTranslationFilter 中,我們來看一下:

public class ExceptionTranslationFilter extends GenericFilterBean {
    public ExceptionTranslationFilter(AuthenticationEntryPoint authenticationEntryPoint,
            RequestCache requestCache) {
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.requestCache = requestCache;
    }
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;
        try {
            chain.doFilter(request, response);
        }
        catch (IOException ex) {
            throw ex;
        }
        catch (Exception ex) {
            Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
            RuntimeException ase = (AuthenticationException) throwableAnalyzer
                    .getFirstThrowableOfType(AuthenticationException.class, causeChain);
            if (ase == null) {
                ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
                        AccessDeniedException.class, causeChain);
            }
            if (ase != null) {
                if (response.isCommitted()) {
                    throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
                }
                handleSpringSecurityException(request, response, chain, ase);
            }
            else {
                if (ex instanceof ServletException) {
                    throw (ServletException) ex;
                }
                else if (ex instanceof RuntimeException) {
                    throw (RuntimeException) ex;
                }
                throw new RuntimeException(ex);
            }
        }
    }
    private void handleSpringSecurityException(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, RuntimeException exception)
            throws IOException, ServletException {
        if (exception instanceof AuthenticationException) {
            sendStartAuthentication(request, response, chain,
                    (AuthenticationException) exception);
        }
        else if (exception instanceof AccessDeniedException) {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
                sendStartAuthentication(
                        request,
                        response,
                        chain,
                        new InsufficientAuthenticationException(
                            messages.getMessage(
                                "ExceptionTranslationFilter.insufficientAuthentication",
                                "Full authentication is required to access this resource")));
            }
            else {
                accessDeniedHandler.handle(request, response,
                        (AccessDeniedException) exception);
            }
        }
    }
    protected void sendStartAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain,
            AuthenticationException reason) throws ServletException, IOException {
        SecurityContextHolder.getContext().setAuthentication(null);
        requestCache.saveRequest(request, response);
        logger.debug("Calling Authentication entry point.");
        authenticationEntryPoint.commence(request, response, reason);
    }
}

ExceptionTranslationFilter 的源碼比較長(zhǎng)优构,我這里列出來核心的部分和大家分析:

  1. 過濾器最核心的當(dāng)然是 doFilter 方法,我們就從 doFilter 方法看起拧额。這里的 doFilter 方法中過濾器鏈繼續(xù)向下執(zhí)行彪腔,ExceptionTranslationFilter 處于 Spring Security 過濾器鏈的倒數(shù)第二個(gè),最后一個(gè)是 FilterSecurityInterceptor德挣,F(xiàn)ilterSecurityInterceptor 專門處理授權(quán)問題,在處理授權(quán)問題時(shí)番挺,就會(huì)發(fā)現(xiàn)用戶未登錄屯掖、未授權(quán)等,進(jìn)而拋出異常贴铜,拋出的異常,最終會(huì)被 ExceptionTranslationFilter#doFilter 方法捕獲徘意。
  2. 當(dāng)捕獲到異常之后陷嘴,接下來通過調(diào)用 throwableAnalyzer.getFirstThrowableOfType 方法來判斷是認(rèn)證異常還是授權(quán)異常,判斷出異常類型之后邑退,進(jìn)入到 handleSpringSecurityException 方法進(jìn)行處理;如果不是 Spring Security 中的異常類型地技,則走 ServletException 異常類型的處理邏輯。
  3. 進(jìn)入到 handleSpringSecurityException 方法之后飒硅,還是根據(jù)異常類型判斷作谚,如果是認(rèn)證相關(guān)的異常,就走 sendStartAuthentication 方法妹懒,最終被 authenticationEntryPoint.commence 方法處理;如果是授權(quán)相關(guān)的異常会前,就走 accessDeniedHandler.handle 方法進(jìn)行處理匾竿。

AuthenticationEntryPoint 的默認(rèn)實(shí)現(xiàn)類是 LoginUrlAuthenticationEntryPoint,因此默認(rèn)的認(rèn)證異常處理邏輯就是 LoginUrlAuthenticationEntryPoint#commence 方法岭妖,如下:

public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException authException) throws IOException, ServletException {
    String redirectUrl = null;
    if (useForward) {
        if (forceHttps && "http".equals(request.getScheme())) {
            redirectUrl = buildHttpsRedirectUrlForRequest(request);
        }
        if (redirectUrl == null) {
            String loginForm = determineUrlToUseForThisRequest(request, response,
                    authException);
            RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
            dispatcher.forward(request, response);
            return;
        }
    }
    else {
        redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
    }
    redirectStrategy.sendRedirect(request, response, redirectUrl);
}

可以看到区转,就是重定向,重定向到登錄頁(yè)面(即當(dāng)我們未登錄就去訪問一個(gè)需要登錄才能訪問的資源時(shí)废离,會(huì)自動(dòng)重定向到登錄頁(yè)面)。

AccessDeniedHandler 的默認(rèn)實(shí)現(xiàn)類則是 AccessDeniedHandlerImpl悼尾,所以授權(quán)異常默認(rèn)是在 AccessDeniedHandlerImpl#handle 方法中處理的:

public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDeniedException accessDeniedException) throws IOException,
        ServletException {
    if (!response.isCommitted()) {
        if (errorPage != null) {
            request.setAttribute(WebAttributes.ACCESS_DENIED_403,
                    accessDeniedException);
            response.setStatus(HttpStatus.FORBIDDEN.value());
            RequestDispatcher dispatcher = request.getRequestDispatcher(errorPage);
            dispatcher.forward(request, response);
        }
        else {
            response.sendError(HttpStatus.FORBIDDEN.value(),
                HttpStatus.FORBIDDEN.getReasonPhrase());
        }
    }
}

可以看到肖方,這里就是服務(wù)端跳轉(zhuǎn)返回 403俯画。

3.自定義處理

前面和大家介紹了 Spring Security 中默認(rèn)的處理邏輯析桥,實(shí)際開發(fā)中,我們可以需要做一些調(diào)整埋虹,很簡(jiǎn)單娩怎,在 exceptionHandling 上進(jìn)行配置即可。

首先自定義認(rèn)證異常處理類和授權(quán)異常處理類:

@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.getWriter().write("login failed:" + authException.getMessage());
    }
}
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(403);
        response.getWriter().write("Forbidden:" + accessDeniedException.getMessage());
    }
}

然后在 SecurityConfig 中進(jìn)行配置爬泥,如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                ...
                ...
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(myAuthenticationEntryPoint)
                .accessDeniedHandler(myAccessDeniedHandler)
                .and()
                ...
                ...
    }
}

配置完成后崩瓤,重啟項(xiàng)目,認(rèn)證異常和授權(quán)異常就會(huì)走我們自定義的邏輯了葬馋。

4.小結(jié)

好啦肾扰,今天主要和小伙伴們分享了 Spring Security 中的異常處理機(jī)制蛋逾,感興趣的小伙伴可以試一試哦~

文中代碼下載地址:https://github.com/lenve/spring-security-samples

公眾號(hào)【江南一點(diǎn)雨】后臺(tái)回復(fù) springsecurity,獲取Spring Security 系列 40+ 篇完整文章~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末偷拔,一起剝皮案震驚了整個(gè)濱河市亏钩,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌姑丑,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,884評(píng)論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件震肮,死亡現(xiàn)場(chǎng)離奇詭異留拾,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)痴柔,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,347評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來豪嚎,“玉大人,你說我怎么就攤上這事匙奴⊥螅” “怎么了?”我有些...
    開封第一講書人閱讀 157,435評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵啦租,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我焊刹,道長(zhǎng)恳蹲,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,509評(píng)論 1 284
  • 正文 為了忘掉前任贺奠,我火速辦了婚禮错忱,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘以清。我一直安慰自己,他們只是感情好掷倔,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,611評(píng)論 6 386
  • 文/花漫 我一把揭開白布今魔。 她就那樣靜靜地躺著,像睡著了一般错森。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上殃姓,一...
    開封第一講書人閱讀 49,837評(píng)論 1 290
  • 那天,我揣著相機(jī)與錄音蜗侈,去河邊找鬼。 笑死枷颊,一個(gè)胖子當(dāng)著我的面吹牛该面,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播隔缀,決...
    沈念sama閱讀 38,987評(píng)論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼猾瘸,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了牵触?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,730評(píng)論 0 267
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤敛惊,失蹤者是張志新(化名)和其女友劉穎绰更,沒想到半個(gè)月后锡宋,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,194評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡徐钠,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,525評(píng)論 2 327
  • 正文 我和宋清朗相戀三年役首,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片衡奥。...
    茶點(diǎn)故事閱讀 38,664評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡矮固,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情邻梆,我是刑警寧澤,帶...
    沈念sama閱讀 34,334評(píng)論 4 330
  • 正文 年R本政府宣布浦妄,位于F島的核電站见芹,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏宜咒。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,944評(píng)論 3 313
  • 文/蒙蒙 一故黑、第九天 我趴在偏房一處隱蔽的房頂上張望庭砍。 院中可真熱鬧,春花似錦怠缸、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,764評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至疚俱,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間养晋,已是汗流浹背梁钾。 一陣腳步聲響...
    開封第一講書人閱讀 31,997評(píng)論 1 266
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留姆泻,地道東北人秦忿。 一個(gè)月前我還...
    沈念sama閱讀 46,389評(píng)論 2 360
  • 正文 我出身青樓蛾娶,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親胎许。 傳聞我的和親對(duì)象是個(gè)殘疾皇子罗售,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,554評(píng)論 2 349