API安全(十)-登陸
1、登陸
在前面,我們把圖上常見的安全機制都做了一個簡單的實現,但是登陸並沒有在圖中體現,因為並不是每次呼叫API的時候都需要登陸;登陸只是一個偶爾發生的事情,並不像圖中的機制,每一次API的呼叫都貫穿在其中。但登陸也是整個安全機制中,重要的一環。
2、之前認證中(HttpBasic)存在的缺陷
在前面我們實現的HttpBasic認證邏輯中,每次客戶端發請求的時候都要把使用者的使用者名稱密碼通過base64加密傳上來,這樣有以下缺點:
2.1、不安全,每次請求都要帶使用者名稱和密碼,增加了使用者名稱密碼洩漏的風險
2.2、每一次傳上來使用者名稱和密碼以後都要去做check,加密演算法校驗比較消耗系統資源
3、基於token的身份認證
對於上面的問題,我們可以採用基於token的身份認證,流程如下圖;
這樣做的好處是:token跟使用者名稱密碼是有關聯的,但不是直接的關聯,從token中沒有辦法解析出使用者名稱和密碼的。不用每次都傳使用者名稱和密碼,token在伺服器端有一個儲存,伺服器端從客戶端拿到token以後,查一下儲存中是否存在,就知道使用者是否登陸了,不用像之前那樣每次請求都要做密碼比對。
4、基於cookie和session的實現
對於基於token的身份認證實現有很多,對於java來說最常見的就是基於cookie和session的實現,流程如下圖;Web瀏覽器作為客戶端,Servlet容器作為伺服器端(tomcat等),伺服器端的記憶體作為token儲存。
上面的這套邏輯Servlet規範裡面都替我們實現好了,我們只需要在程式碼中執行request.getSession(),就會為我們做上面的生成sessionId,返回set-Cookie這些事情。
優點:提升了客戶體驗,比客戶端儲存使用者名稱密碼安全;使用起來很方便,Servlet容器都替我們實現好了。
缺點:只針對瀏覽器可以使用,APP和第三方應用不支援;伺服器向瀏覽器傳遞cookie容易被劫持;多臺伺服器要保證session的一致性。
5、session固定攻擊
request.getSession()這句程式碼會根據請求裡面cookie的sessionId,在伺服器上去找對應的session,如果能找到直接用,如果沒找到就會建立一個新的session然後返回回去。針對這樣一個邏輯,黑客發明了session固定攻擊,如下圖
為了防止session固定攻擊,我們要保證登陸前和登陸後的session不是同一個。
6、程式碼實現
6.1、登陸方法實現
@PostMapping("/login") public Map<String, String> login(@RequestBody @Validated(Login.class) UserDTO userDTO,HttpServletRequest request) { return userService.login(userDTO,request); }
@Override public Map<String, String> login(UserDTO userDTO, HttpServletRequest request) { Map<String,String> result = Maps.newHashMap(); UserDO userDO = userRepository.findByUsername(userDTO.getUsername()); if (userDO == null){ result.put("message","使用者名稱錯誤"); }else if (!BCrypt.checkpw(userDTO.getPassword(),userDO.getPassword())){ result.put("message","密碼錯誤"); }else { HttpSession session = request.getSession(false); //將之前的session失效掉 if (session != null){ session.invalidate(); } //將使用者資訊放到新的session中 request.getSession(true).setAttribute("user",userDO.buildUserDTO()); result.put("message","登陸成功"); } return result; }
6.2、Acl許可權控制對登陸請求不需要認證
/** * ACL過濾器,這需要審計也是基於Filter實現的 */ @Slf4j @Order(4) @Component @SuppressWarnings("ALL") public class AclFilter extends OncePerRequestFilter implements InitializingBean { @Value("${permit.urls}") private String permitUrls; private Set<String> permitUrlSet = new HashSet<>(); private AntPathMatcher pathMatcher = new AntPathMatcher(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("++++++4、授權++++++"); if (isPermitUrl(request)){ //對於不需要認證和鑑權的請求直接放過 filterChain.doFilter(request, response); }else { /* * 要求請求都必須經過認證才能訪問 */ UserDTO user = (UserDTO) request.getSession().getAttribute("user"); if (user == null) { //說明沒有進行認證,返回401和WWW-Authenticate,讓瀏覽器彈出輸入框 response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setHeader("WWW-Authenticate", "Basic realm=<authentication required>"); return; } /* * 要求有對應的許可權才可以進行訪問 */ if (!hasPermission(user.getPermissions(), request.getMethod())) { response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("Forbidden"); response.getWriter().flush(); return; } filterChain.doFilter(request, response); } } /** * 判斷是否是直接放過的請求 */ private boolean isPermitUrl(HttpServletRequest request) { String uri = request.getRequestURI(); for (String url : permitUrlSet){ if (pathMatcher.match(url,uri)){ // 不需要認證和許可權,直接訪問 return true; } } return false; } /** * 判斷是否有許可權 */ private boolean hasPermission(String permissions, String method) { if (StringUtils.equalsIgnoreCase(method, HttpMethod.GET.name())) { //要有讀許可權 return StringUtils.containsIgnoreCase(permissions, "read"); } else { //要有寫許可權 return StringUtils.containsIgnoreCase(permissions, "write"); } } @Override public void afterPropertiesSet() throws ServletException { super.afterPropertiesSet(); Collections.addAll(permitUrlSet,StringUtils.splitByWholeSeparatorPreserveAllTokens(permitUrls,",")); } }
6.3、修改審計功能,從session中獲取使用者資訊
/** * 獲取當前登陸使用者 */ @Bean public AuditorAware<String> auditorAware() { return () -> { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); HttpSession session = request.getSession(false); String username = "anonymous"; if (session != null) { UserDTO user = (UserDTO) session.getAttribute("user"); if (user != null) { username = user.getUsername(); } } return Optional.of(username); }; }
6.4、修改認證功能,同時支援HttpBasic和cookie、session認證
/** * HttpBasic 認證 */ @Slf4j @Order(2) @Component @SuppressWarnings("ALL") public class BasicAuthorizationFilter extends OncePerRequestFilter { @Resource private UserRepository userRepository; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("++++++2、認證++++++"); String authorizationHeader = request.getHeader("Authorization"); if (StringUtils.isNotBlank(authorizationHeader)) { String token64 = StringUtils.substringAfter(authorizationHeader, "Basic "); if (StringUtils.isNotBlank(token64)) { try { String token = new String(Base64Utils.decodeFromString(token64)); String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(token, ":"); String username = items[0]; String password = items[1]; UserDO user = userRepository.findByUsername(username); if (user != null && BCrypt.checkpw(password, user.getPassword())) { // if (user != null && SCryptUtil.check(password,user.getPassword())) { //認證通過,存放使用者資訊,對於使用httpBasic認證的,新增特殊標記 request.getSession().setAttribute("user", user.buildUserDTO()); request.getSession().setAttribute("httpBasic", Boolean.TRUE); } } catch (Exception e) { log.info("Basic Authorization Fail!"); } } } //不管認證是否正確,繼續往下走,是否可以訪問,交給授權處理 filterChain.doFilter(request, response); //執行完之後,如果是httpBasic方式認證,將session失效 HttpSession session = request.getSession(false); if (session != null && session.getAttribute("httpBasic") != null){ session.invalidate(); } } }
6.5、退出功能
@RequestMapping("/logout") public void logout(HttpServletRequest request){ request.getSession().invalidate(); }
6.6、啟動專案進行測試
6.6.1、輸入錯誤的密碼進行登陸,在響應頭中沒有看到Set-Cookie
6.6.2、輸入正確的密碼進行登陸,響應頭中有Set-Cookie,JSESSIONID=79C8ECFDC0AF3EFD82CAE65FAF226E4C
6.6.3、訪問獲取使用者的請求,因為登陸了,請求頭Cookie中的JSESSIONID=79C8ECFDC0AF3EFD82CAE65FAF226E4C所以可以訪問
6.6.4、呼叫退出功能,將原有session失效
6.6.5、訪問獲取使用者的請求,因為沒有關閉瀏覽器,之前的cookie還在,但是呼叫了退出使對應的session失效了,所以需要使用httpbasic認證
6.6.6、通過httpbasic認證後,可以正常訪問,說明兩種認證方式都支援。