Spring Security是一個功能強大且高度可定製的身份驗證和訪問控制框架。它是用於保護基於Spring的應用程序的實際標準。Spring Security是一個框架,致力於爲Java應用程序提供身份驗證和授權。與所有Spring項目一樣,Spring Security的真正強大之處在於可以輕鬆擴展以滿足自定義要求
前後端分離開發後,認證這一塊到底是使用傳統的 session 還是使用像 JWT 這樣的 token 來解決呢?傳統的通過 session 來記錄用戶認證信息的方式可以理解爲這是一種有狀態登錄,而 JWT 則代表了一種無狀態登錄。
狀態登錄
-
什麼是有狀態
有狀態服務,即服務端需要記錄每次會話的客戶端信息,從而識別客戶端身份,根據用戶身份進行請求的處理,典型的設計如 Tomcat 中的 Session。例如登錄:用戶登錄後,把用戶的信息保存在服務端 session 中,並且給用戶一個 cookie 值,記錄對應的 session,然後下次請求,用戶攜帶 cookie 值來(這一步有瀏覽器自動完成),就能識別到對應 session,從而找到用戶的信息。這種方式目前來看最方便,但是也有一些缺陷,如下:- 服務端保存大量數據,增加服務端壓力
- 服務端保存用戶狀態,不支持集羣化部署
-
什麼是無狀態
微服務集羣中的每個服務,對外提供的都使用 RESTful 風格的接口。而 RESTful 風格的一個最重要的規範就是:服務的無狀態性,即:
- 服務端不保存任何客戶端請求者信息
- 客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份
那麼這種無狀態性有哪些好處呢?
- 客戶端請求不依賴服務端的信息,多次請求不需要必須訪問到同一臺服務器
- 服務端的集羣和狀態對客戶端透明
- 服務端可以任意的遷移和伸縮(可以方便的進行集羣化部署)
減小服務端存儲壓力
如何實現無狀態
無狀態登錄的流程:
- 首先客戶端發送賬戶名/密碼到服務端進行認證
- 認證通過後,服務端將用戶信息加密並且編碼成一個 token,返回給客戶端
- 以後客戶端每次發送請求,都需要攜帶認證的 token
- 服務端對客戶端發送來的 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