Spring Security系列二

Spring Security是一個功能強大且高度可定製的身份驗證和訪問控制框架。它是用於保護基於Spring的應用程序的實際標準。Spring Security是一個框架,致力於爲Java應用程序提供身份驗證和授權。與所有Spring項目一樣,Spring Security的真正強大之處在於可以輕鬆擴展以滿足自定義要求

前後端分離開發後,認證這一塊到底是使用傳統的 session 還是使用像 JWT 這樣的 token 來解決呢?傳統的通過 session 來記錄用戶認證信息的方式可以理解爲這是一種有狀態登錄,而 JWT 則代表了一種無狀態登錄。

狀態登錄

  • 什麼是有狀態
    有狀態服務,即服務端需要記錄每次會話的客戶端信息,從而識別客戶端身份,根據用戶身份進行請求的處理,典型的設計如 Tomcat 中的 Session。例如登錄:用戶登錄後,把用戶的信息保存在服務端 session 中,並且給用戶一個 cookie 值,記錄對應的 session,然後下次請求,用戶攜帶 cookie 值來(這一步有瀏覽器自動完成),就能識別到對應 session,從而找到用戶的信息。這種方式目前來看最方便,但是也有一些缺陷,如下:

    • 服務端保存大量數據,增加服務端壓力
    • 服務端保存用戶狀態,不支持集羣化部署
  • 什麼是無狀態

微服務集羣中的每個服務,對外提供的都使用 RESTful 風格的接口。而 RESTful 風格的一個最重要的規範就是:服務的無狀態性,即:

  • 服務端不保存任何客戶端請求者信息
  • 客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份

那麼這種無狀態性有哪些好處呢?

  • 客戶端請求不依賴服務端的信息,多次請求不需要必須訪問到同一臺服務器
  • 服務端的集羣和狀態對客戶端透明
  • 服務端可以任意的遷移和伸縮(可以方便的進行集羣化部署)
    減小服務端存儲壓力

如何實現無狀態

無狀態登錄的流程:

  1. 首先客戶端發送賬戶名/密碼到服務端進行認證
  2. 認證通過後,服務端將用戶信息加密並且編碼成一個 token,返回給客戶端
  3. 以後客戶端每次發送請求,都需要攜帶認證的 token
  4. 服務端對客戶端發送來的 token 進行解密,判斷是否有效,並且獲取用戶登錄信息

各自優缺點
使用 session 最大的優點在於方便。不用做過多的處理,一切都是默認的即可。

但是使用 session 有另外一個致命的問題就是如果前端是 Android、iOS、小程序等,這些 App 天然的就沒有 cookie,如果非要用 session,就需要這些工程師在各自的設備上做適配,一般是模擬 cookie,從這個角度來說,在移動 App 遍地開花的今天,單純的依賴 session 來做安全管理,似乎也不是特別理想。

這個時候 JWT 這樣的無狀態登錄就展示出自己的優勢了,這些登錄方式所依賴的 token 你可以通過普通參數傳遞,也可以通過請求頭傳遞,怎麼樣都行,具有很強的靈活性。

不過話說回來,如果前後端分離只是網頁+服務端,其實沒必要上無狀態登錄,基於 session 來做就可以了,省事又方便。

前後端分離的數據交互
在前後端分離這樣的開發架構下,前後端的交互都是通過 JSON 來進行,無論登錄成功還是失敗,都不會有什麼服務端跳轉或者客戶端跳轉之類。

登錄成功了,服務端就返回一段登錄成功的提示 JSON 給前端,前端收到之後,該跳轉該展示,由前端自己決定,就和後端沒有關係了。

登錄失敗了,服務端就返回一段登錄失敗的提示 JSON 給前端,前端收到之後,該跳轉該展示,由前端自己決定,也和後端沒有關係了。

登錄成功
之前配置登錄成功的處理是通過如下兩個方法來配置的:

  • defaultSuccessUrl
  • successForwardUrl
    這兩個都是配置跳轉地址的,適用於前後端不分的開發。除了這兩個方法之外,還有一個必殺技,那就是 successHandler。

successHandler 的功能十分強大,甚至已經囊括了 defaultSuccessUrl 和 successForwardUrl 的功能。

 @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .usernameParameter("name")
                .passwordParameter("passwd")
//                .defaultSuccessUrl("/index")
//                .successForwardUrl("/index")
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        Object principal = authentication.getPrincipal();
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write(new ObjectMapper().writeValueAsString(principal));
                        out.flush();
                        out.close();
                    }
                })
                .permitAll()
                .and()
                .csrf().disable();
    }

successHandler 方法的參數是一個 AuthenticationSuccessHandler 對象,這個對象中要實現的方法是 onAuthenticationSuccess。

onAuthenticationSuccess 方法有三個參數,分別是:

  • HttpServletRequest
  • HttpServletResponse
  • Authentication
    有了前兩個參數,就可以在這裏隨心所欲的返回數據了。利用 HttpServletRequest 我們可以做服務端跳轉,利用 HttpServletResponse 可以做客戶端跳轉,當然,也可以返回 JSON 數據。

第三個 Authentication 參數則保存了剛剛登錄成功的用戶信息。

配置完成後,再去登錄,就可以看到登錄成功的用戶信息通過 JSON 返回到前端了,如下:
在這裏插入圖片描述

登錄失敗
登錄失敗也有一個類似的回調,如下:

  @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .usernameParameter("name")
                .passwordParameter("passwd")
//                .defaultSuccessUrl("/index")
//                .successForwardUrl("/index")
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        Object principal = authentication.getPrincipal();
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write(new ObjectMapper().writeValueAsString(principal));
                        out.flush();
                        out.close();
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write(exception.getMessage());
                        out.flush();
                        out.close();
                    }
                })
                .permitAll()
                .and()
                .csrf().disable();
    }

失敗的回調也是三個參數,前兩個就不用說了,第三個是一個 Exception,對於登錄失敗,會有不同的原因,Exception 中則保存了登錄失敗的原因,可以將之通過 JSON 返回到前端。

根據不同的異常類型,可以給用戶一個更加明確的提示:
編寫一個響應bean

public class RespBean {
    private Integer status;
    private String msg;
    private Object obj;

    public static RespBean build() {
        return new RespBean();
    }

    public static RespBean ok(String msg) {
        return new RespBean(200, msg, null);
    }

    public static RespBean ok(String msg, Object obj) {
        return new RespBean(200, msg, obj);
    }

    public static RespBean error(String msg) {
        return new RespBean(500, msg, null);
    }

    public static RespBean error(String msg, Object obj) {
        return new RespBean(500, msg, obj);
    }

    private RespBean() {
    }

    private RespBean(Integer status, String msg, Object obj) {
        this.status = status;
        this.msg = msg;
        this.obj = obj;
    }

    public Integer getStatus() {
        return status;
    }

    public RespBean setStatus(Integer status) {
        this.status = status;
        return this;
    }

    public String getMsg() {
        return msg;
    }

    public RespBean setMsg(String msg) {
        this.msg = msg;
        return this;
    }

    public Object getObj() {
        return obj;
    }

    public RespBean setObj(Object obj) {
        this.obj = obj;
        return this;
    }
}

修改登錄失敗回調

 response.setContentType("application/json;charset=utf-8");
                    PrintWriter out = response.getWriter();
                    RespBean respBean = RespBean.error(exception.getMessage());
                    if (exception instanceof LockedException) {
                        respBean.setMsg("賬戶被鎖定,請聯繫管理員!");
                    } else if (exception instanceof CredentialsExpiredException) {
                        respBean.setMsg("密碼過期,請聯繫管理員!");
                    } else if (exception instanceof AccountExpiredException) {
                        respBean.setMsg("賬戶過期,請聯繫管理員!");
                    } else if (exception instanceof DisabledException) {
                        respBean.setMsg("賬戶被禁用,請聯繫管理員!");
                    } else if (exception instanceof BadCredentialsException) {
                        respBean.setMsg("用戶名或者密碼輸入錯誤,請重新輸入!");
                    }
                    out.write(new ObjectMapper().writeValueAsString(respBean));
                    out.flush();
                    out.close();

未認證處理方案
前後端分離中,如果用戶沒有登錄就訪問一個需要認證後才能訪問的頁面,這個時候,不應該讓用戶重定向到登錄頁面,而是給用戶一個尚未登錄的提示,前端收到提示之後,再自行決定頁面跳轉。

        .csrf().disable().exceptionHandling()
        .authenticationEntryPoint(new AuthenticationEntryPoint() {
            @Override
            public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                out.write("尚未登錄,請先登錄");
                out.flush();
                out.close();
            }
        });

註銷登錄
註銷登錄之後,系統自動跳轉到登錄頁面,這也是不合適的,如果是前後端分離項目,註銷登錄成功後返回 JSON 即可,配置如下:

  @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")
                .usernameParameter("name")
                .passwordParameter("passwd")
//                .defaultSuccessUrl("/index")
//                .successForwardUrl("/index")
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        Object principal = authentication.getPrincipal();
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write(new ObjectMapper().writeValueAsString(principal));
                        out.flush();
                        out.close();
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        RespBean respBean = RespBean.error(exception.getMessage());
                        if (exception instanceof LockedException) {
                            respBean.setMsg("賬戶被鎖定,請聯繫管理員!");
                        } else if (exception instanceof CredentialsExpiredException) {
                            respBean.setMsg("密碼過期,請聯繫管理員!");
                        } else if (exception instanceof AccountExpiredException) {
                            respBean.setMsg("賬戶過期,請聯繫管理員!");
                        } else if (exception instanceof DisabledException) {
                            respBean.setMsg("賬戶被禁用,請聯繫管理員!");
                        } else if (exception instanceof BadCredentialsException) {
                            respBean.setMsg("用戶名或者密碼輸入錯誤,請重新輸入!");
                        }
                        out.write(exception.getMessage());
                        out.flush();
                        out.close();
                    }
                })
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write("註銷成功");
                        out.flush();
                        out.close();
                    }
                })
                .permitAll()
                .and()
                .csrf().disable().exceptionHandling()
        .authenticationEntryPoint(new AuthenticationEntryPoint() {
            @Override
            public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                out.write("尚未登錄,請先登錄");
                out.flush();
                out.close();
            }
        });
    }

訪問:http://localhost:8080/logout
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章