1. 程式人生 > 實用技巧 >API安全(十)-登陸

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認證後,可以正常訪問,說明兩種認證方式都支援。