前後端分離中,使用 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 來寫的,我來給大家稍微解釋下:

  1. 首先登錄請求肯定是 POST,如果不是 POST ,直接拋出異常,後面的也不處理了。
  2. 因為要在這裡處理驗證碼,所以第二步從 session 中把已經下發過的驗證碼的值拿出來。
  3. 接下來通過 contentType 來判斷當前請求是否通過 JSON 來傳遞參數,如果是通過 JSON 傳遞參數,則按照 JSON 的方式解析,如果不是,則調用 super.attemptAuthentication 方法,進入父類的處理邏輯中,也就是說,我們自定義的這個類,既支持 JSON 形式傳遞參數,也支持 key/value 形式傳遞參數。
  4. 如果是 JSON 形式的數據,我們就通過讀取 request 中的 I/O 流,將 JSON 映射到一個 Map 上。
  5. 從 Map 中取出 code,先去判斷驗證碼是否正確,如果驗證碼有錯,則直接拋出異常。驗證碼的判斷邏輯,大家可以參考:松哥手把手教你給微人事添加登錄驗證碼
  6. 接下來從 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