前後端分離中,使用 JSON 格式登錄原來這麼簡單!
- 2020 年 4 月 10 日
- 筆記
http://mpvideo.qpic.cn/0bf25uaaeaaadaakxl4brrpfb3odalwqaaqa.f10002.mp4?dis_k=8e0188de863b6999a4f60e5a50088ae2&dis_t=1586488857
本影片節選自松哥自製的 Spring Boot+Vue+微人事系列影片,如果小夥伴們覺得松哥的影片風格還能接受,也可以看看這裡 Spring Boot + Vue 系列影片教程
以下是影片筆記。
做微人事的小夥伴(https://github.com/lenve/vhr),應該都發現了在微人事中有一個極為特殊的請求,那就是登錄。
登錄請求是一個 POST 請求,但是數據傳輸格式是 key/value 的形式。整個項目里就只有這一個 POST 請求是這樣,其他 POST 請求都是 JSON 格式的數據。
為什麼做成這個樣子呢?還是懶唄。
因為 Spring Security 中默認的登錄數據格式就是 key/value 的形式,一直以來懶得改。最近剛好在錄 Spring Security,就抽空把這裡調整了下,這樣前後端就能統一起來了。
好了,我們一起來看下怎麼實現。
1.服務端介面調整
首先大家知道,用戶登錄的用戶名/密碼是在 UsernamePasswordAuthenticationFilter
類中處理的,具體的處理程式碼如下:
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String username = obtainUsername(request); String password = obtainPassword(request); //省略 } protected String obtainPassword(HttpServletRequest request) { return request.getParameter(passwordParameter); } protected String obtainUsername(HttpServletRequest request) { return request.getParameter(usernameParameter); }
從這段程式碼中,我們就可以看出來為什麼 Spring Security 默認是通過 key/value 的形式來傳遞登錄參數,因為它處理的方式就是 request.getParameter。
所以我們要定義成 JSON 的,思路很簡單,就是自定義來定義一個過濾器代替 UsernamePasswordAuthenticationFilter
,然後在獲取參數的時候,換一種方式就行了。
「這裡有一個額外的點需要注意,就是我們的微人事現在還有驗證碼的功能,所以如果自定義過濾器,要連同驗證碼一起處理掉。」
2.自定義過濾器
接下來我們來自定義一個過濾器代替 UsernamePasswordAuthenticationFilter
,如下:
public class LoginFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String verify_code = (String) request.getSession().getAttribute("verify_code"); if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)) { Map<String, String> loginData = new HashMap<>(); try { loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class); } catch (IOException e) { }finally { String code = loginData.get("code"); checkCode(response, code, verify_code); } String username = loginData.get(getUsernameParameter()); String password = loginData.get(getPasswordParameter()); if (username == null) { username = ""; } if (password == null) { password = ""; } username = username.trim(); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } else { checkCode(response, request.getParameter("code"), verify_code); return super.attemptAuthentication(request, response); } } public void checkCode(HttpServletResponse resp, String code, String verify_code) { if (code == null || verify_code == null || "".equals(code) || !verify_code.toLowerCase().equals(code.toLowerCase())) { //驗證碼不正確 throw new AuthenticationServiceException("驗證碼不正確"); } } }
這段邏輯我們基本上是模仿官方提供的 UsernamePasswordAuthenticationFilter
來寫的,我來給大家稍微解釋下:
- 首先登錄請求肯定是 POST,如果不是 POST ,直接拋出異常,後面的也不處理了。
- 因為要在這裡處理驗證碼,所以第二步從 session 中把已經下發過的驗證碼的值拿出來。
- 接下來通過 contentType 來判斷當前請求是否通過 JSON 來傳遞參數,如果是通過 JSON 傳遞參數,則按照 JSON 的方式解析,如果不是,則調用 super.attemptAuthentication 方法,進入父類的處理邏輯中,也就是說,我們自定義的這個類,既支援 JSON 形式傳遞參數,也支援 key/value 形式傳遞參數。
- 如果是 JSON 形式的數據,我們就通過讀取 request 中的 I/O 流,將 JSON 映射到一個 Map 上。
- 從 Map 中取出 code,先去判斷驗證碼是否正確,如果驗證碼有錯,則直接拋出異常。驗證碼的判斷邏輯,大家可以參考:松哥手把手教你給微人事添加登錄驗證碼。
- 接下來從 Map 中取出 username 和 password,構造 UsernamePasswordAuthenticationToken 對象並作校驗。
過濾器定義完成後,接下來用我們自定義的過濾器代替默認的 UsernamePasswordAuthenticationFilter
,首先我們需要提供一個 LoginFilter 的實例:
@Bean LoginFilter loginFilter() throws Exception { LoginFilter loginFilter = new LoginFilter(); loginFilter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); Hr hr = (Hr) authentication.getPrincipal(); hr.setPassword(null); RespBean ok = RespBean.ok("登錄成功!", hr); String s = new ObjectMapper().writeValueAsString(ok); out.write(s); out.flush(); out.close(); } }); loginFilter.setAuthenticationFailureHandler(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(new ObjectMapper().writeValueAsString(respBean)); out.flush(); out.close(); } }); loginFilter.setAuthenticationManager(authenticationManagerBean()); loginFilter.setFilterProcessesUrl("/doLogin"); return loginFilter; }
當我們代替了 UsernamePasswordAuthenticationFilter
之後,原本在 SecurityConfig#configure 方法中關於 form 表單的配置就會失效,那些失效的屬性,都可以在配置 LoginFilter 實例的時候配置。
另外記得配置一個 AuthenticationManager,根據 WebSecurityConfigurerAdapter 中提供的配置即可。
FilterProcessUrl 則可以根據實際情況配置,如果不配置,默認的就是 /login
。
最後,我們用自定義的 LoginFilter 實例代替 UsernamePasswordAuthenticationFilter
,如下:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() ... //省略 http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class); }
調用 addFilterAt 方法完成替換操作。
篇幅原因,我這裡只展示了部分程式碼,完整程式碼小夥伴們可以在 GitHub 上看到:https://github.com/lenve/vhr。
配置完成後,重啟後端,先用 POSTMAN 測試登錄介面,如下:

3.前端修改
原本我們的前端登錄程式碼是這樣的:
this.$refs.loginForm.validate((valid) => { if (valid) { this.loading = true; this.postKeyValueRequest('/doLogin', this.loginForm).then(resp => { this.loading = false; //省略 }) } else { return false; } });
首先我們去校驗數據,在校驗成功之後,通過 postKeyValueRequest 方法來發送登錄請求,這個方法是我自己封裝的通過 key/value 形式傳遞參數的 POST 請求,如下:
export const postKeyValueRequest = (url, params) => { return axios({ method: 'post', url: `${base}${url}`, data: params, transformRequest: [function (data) { let ret = ''; for (let i in data) { ret += encodeURIComponent(i) + '=' + encodeURIComponent(data[i]) + '&' } return ret; }], headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); } export const postRequest = (url, params) => { return axios({ method: 'post', url: `${base}${url}`, data: params }) }
postKeyValueRequest 是我封裝的通過 key/value 形式傳遞參數,postRequest 則是通過 JSON 形式傳遞參數。
所以,前端我們只需要對登錄請求稍作調整,如下:
this.$refs.loginForm.validate((valid) => { if (valid) { this.loading = true; this.postRequest('/doLogin', this.loginForm).then(resp => { this.loading = false; //省略 }) } else { return false; } });
配置完成後,再去登錄,瀏覽器按 F12 ,就可以看到登錄請求的參數形式了:

好啦,這就是松哥和大家介紹的 SpringSecurity+JSON+驗證碼登錄
完整程式碼小夥伴們可以在 GitHub 上下載:https://github.com/lenve/vhr