1. 程式人生 > 其它 >介面鑑權實踐

介面鑑權實踐

我們知道,做為一個web系統,少不了要呼叫別的系統的介面或者是提供介面供別的系統呼叫。從介面的使用範圍也可以分為對內和對外兩種,對內的介面主要限於一些我們內部系統的呼叫,多是通過內網進行呼叫,往往不用考慮太複雜的鑑權操作。但是,對於對外的介面,我們就不得不重視這個問題,外部介面沒有做鑑權的操作就直接釋出到網際網路無疑是!

而這不僅有暴露資料的風險,同時還有資料被篡改的風險,嚴重的甚至是影響到系統的正常運轉!!

接下來,我將結合實際程式碼,分享一套介面鑑權實踐方法。

方案一 appIdsecret

介面鑑權?那還不簡單,給每個應用下發一個appIdsecret,介面呼叫方每次攜帶appId

secret呼叫介面。但是這樣真的安全嗎?每次呼叫都要傳輸密碼,很容易被截獲。

方案二 appIdsecret+token

呼叫方根據介面的URLappIdsecret組合在一起,然後加密生成一個token,服務端接收到對應請求之後按照同樣的方法生成一個token,然後校驗token的 正確性。但是這種方式每個url拼接上appIdsecret生成的token是一樣的,未授權系統截獲後還是可以通過重放的方式,偽裝成認證系統,呼叫這個介面。

方案三 appIdsecret+token+時間戳

同方案二類似,token的生成過程中在加入時間戳,校驗token正確性之前先校驗時間戳

是否在一定時間視窗內(比如說1分鐘),如果超過一分鐘,直接拒絕請求,通過後再校驗token

方案四 appId+token+時間戳

相對方案二,方案三的方法相對已經有很大提升了(同樣引數不能無限制呼叫),但是仔細一想,還是有問題,攻擊者截獲請求以後,還是可以在一定時間視窗內通過重放攻擊的方式傳送請求。那麼,有沒有終極大招呢?

實際上,攻防之間沒有絕對的安全,我們能做的是儘量提高攻擊者的成本。這個方案雖然還有漏洞,但是實現起來簡單,而且不會過度影響介面效能。權衡安全性、開發成本以及對系統性能的影響,這個方案算是比較合理的一個了。接下來,我將通過java程式碼一步一步實現這個鑑權功能。

首先,抽出一個AuthToken.java

,定義了生成AuthToken以及校驗token是否過期的方法

package com.info.examples.authentication;

import cn.hutool.core.map.MapUtil;
import cn.hutool.crypto.digest.DigestAlgorithm;
import cn.hutool.crypto.digest.Digester;
import lombok.Getter;
import lombok.ToString;

import java.nio.charset.StandardCharsets;
import java.util.Map;

@ToString
public class AuthToken {

    // 預設超時時間 1 分鐘
    private static final long DEFAULT_EXPIRE_TIME_INTERVAL = 3 * 60 * 1000;

    @Getter
    private String token;

    private long createTime;

    @Getter
    private long expiredTimeInterval = DEFAULT_EXPIRE_TIME_INTERVAL;

    private static final Digester MD5 = new Digester(DigestAlgorithm.MD5);

    public AuthToken(String token, long createTime) {
        this.token = token;
        this.createTime = createTime;
    }

    public AuthToken(String token, long createTime, long expiredTimeInterval) {
        this.token = token;
        this.createTime = createTime;
        this.expiredTimeInterval = expiredTimeInterval;
    }

    public static AuthToken createToken(String appId, String secret, long createTime, String baseUrl, Map<String, String> params) {
        String original = appId + secret + baseUrl + MapUtil.sortJoin(params, "&", "=", true) + createTime;
        String token = MD5.digestHex(original, StandardCharsets.UTF_8);
        return new AuthToken(token, createTime);
    }

    public boolean isExpired() {
        return System.currentTimeMillis() < this.createTime + DEFAULT_EXPIRE_TIME_INTERVAL;
    }
    
}

服務端需要存放已經下發給客戶端的appId以及對應的secret,因為這個secret可以有多種存放方式,比如說記憶體rediszookeeper等多種方式,所以我們抽象出一個用於存放和獲取secret的介面

package com.info.examples.authentication;

public interface CreditService {

    /**
     * 根據 appId 獲取對應的 secret
     *
     * @param appId
     * @return
     */
    String getCreditByAppId(String appId);

    /**
     * 新增appId、secret
     * @param appId
     * @param secret
     */
    void addSecret(String appId,String secret);
}

這裡作為演示程式碼,實現一個基於記憶體儲存的

package com.info.examples.authentication;

import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.Map;

@Service
public class InmemoryCreditServiceImpl implements CreditService {

    private static final Map<String, String> CREDIT_MAP = new HashMap<>(1 << 2);

    static {
     	// 這裡做測試就直接新增一個appId
        CREDIT_MAP.put("testAppId", "secretTest");
    }

    @Override
    public String getCreditByAppId(String appId) {
        return CREDIT_MAP.get(appId);
    }

    @Override
    public void addSecret(String appId, String secret) {
        if (!StringUtils.hasLength(appId) || !StringUtils.hasLength(secret)) {
            return;
        }
        CREDIT_MAP.put(appId, secret);
    }
}

接下來我們實現服務端再接收到請求以後做鑑權

package com.info.examples.authentication;

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestController
public class AuthenticationController {

    private final CreditService creditService;

    public AuthenticationController(CreditService creditService) {
        this.creditService = creditService;
    }

    @GetMapping("/auth")
    public String auth(@RequestParam Map<String, String> params, HttpServletRequest request) {
        final String appId = params.get("appId");
        if (StringUtils.isEmpty(appId)) {
            return "authentication failed";
        }
        final String secret = creditService.getCreditByAppId(appId);
        final long createTime = Long.parseLong(params.get("createTime"));
        final String baseurl = request.getRequestURI();
        final String token = params.get("token");
        params.remove("token");
        params.put("secret", secret);
        AuthToken authToken = AuthToken.createToken(appId, secret, createTime, baseurl, params);
        log.info(authToken.toString());
        if (!authToken.isExpired()) {
            return "authentication failed";
        }
        if (!ObjectUtils.nullSafeEquals(token, authToken.getToken())) {
            return "authentication failed";
        }
        // 執行具體業務邏輯
        params.forEach((k, v) -> log.info("{} = {}", k, v));
        return "success";
    }
}

下面我們寫一個介面來測試

package com.info.examples.authentication;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestController
public class TestAuthController {

    @GetMapping("/testAuth")
    public String testAuth() {
        RestTemplate template = new RestTemplate();
        Map<String, String> parameterMap = new HashMap<>(1 << 2);
        String name = "zhangsan";
        int age = 18;
        String baseUrl = "/auth";
        String appId = "testAppId";
        long createTime = System.currentTimeMillis();
        String secret = "secretTest";
        parameterMap.put("name", name);
        parameterMap.put("age", String.valueOf(age));
        parameterMap.put("appId", appId);
        parameterMap.put("createTime", String.valueOf(createTime));
        parameterMap.put("secret", secret);
        AuthToken authToken = AuthToken.createToken(appId, secret, createTime, baseUrl, parameterMap);
        log.info(authToken.toString());
        String requestUrl = "http://localhost:8080/auth?name=" + name + "&age=" + age +
                "&appId=" + appId + "&createTime=" + createTime + "&token=" + authToken.getToken();
        final String result = template.getForObject(requestUrl, String.class, parameterMap);
        return result;
    }
}

開啟瀏覽器,訪問http://localhost:8080/test,發現我們已經實現了鑑權的效果,但是每個介面前面都有一大堆鑑權的邏輯,這程式碼太那啥了

怎麼處理呢?很簡單,遇到這種情況我們可以使用註解+AOP的方式來優化程式碼,開始改造.....
定義一個註解

package com.info.examples.authentication.annotation;

import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Authentication {
}

通過切面實現驗證token的功能

package com.info.examples.authentication.aop;


import com.info.examples.authentication.AuthToken;
import com.info.examples.authentication.CreditService;
import com.info.examples.authentication.annotation.Authentication;
import com.info.examples.authentication.exception.AuthenticationException;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;


/**
 * @desc 使用數字信封方式對輸入欄位解密,對輸出資料加密
 */

@Slf4j
@Aspect
@Component
public class AuthenticationAop {

    private final CreditService creditService;

    public AuthenticationAop(CreditService creditService) {
        this.creditService = creditService;
    }


    @Pointcut("@annotation(com.info.examples.authentication.annotation.Authentication)")
    public void pointCutMethodBefore() {
    }

    @Before("pointCutMethodBefore()")
    public void doBefore(JoinPoint point) {
        MethodInvocationProceedingJoinPoint mjp = (MethodInvocationProceedingJoinPoint) point;
        MethodSignature signature = (MethodSignature) mjp.getSignature();
        Method method = signature.getMethod();
        Authentication annotation = method.getAnnotation(Authentication.class);
        if (annotation != null) {
            HttpServletRequest request = getRequest(point.getArgs());
            if (StringUtils.isEmpty(request)) {
                throw new AuthenticationException("【鑑權失敗】,獲取HttpServletRequest");
            }
            String appId = request.getParameter("appId");
            if (StringUtils.isEmpty(appId)) {
                throw new AuthenticationException("【鑑權失敗】,appId不存在");
            }
            final String secret = creditService.getCreditByAppId(appId);
            final String token = request.getParameter("token");
            final long createTime = Long.parseLong(request.getParameter("createTime"));
            Map<String, String> params = new HashMap<>(1 << 2);
            final String baseurl = request.getRequestURI();
            request.getParameterMap().forEach((k, v) -> params.put(k, v[0]));
            params.remove("token");
            params.put("secret", secret);
            AuthToken authToken = AuthToken.createToken(appId, secret, createTime, baseurl, params);
            log.info(authToken.toString());
            if (!authToken.isExpired()) {
                throw new AuthenticationException("【鑑權失敗】,token已過期");
            }
            if (!ObjectUtils.nullSafeEquals(token, authToken.getToken())) {
                throw new AuthenticationException("【鑑權失敗】,token錯誤");
            }
        }
    }


    private HttpServletRequest getRequest(Object[] args) {
        for (Object o : args) {
            if (o instanceof HttpServletRequest) {
                return (HttpServletRequest) o;
            }
        }
        return null;
    }
}

這個時候,controller層就不用關注鑑權的邏輯了,只需新增一個@Authentication註解即可。

package com.info.examples.authentication;

import com.info.examples.authentication.annotation.Authentication;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

@Slf4j
@RestController
public class AuthenticationController {

    @GetMapping("/auth")
    @Authentication
    public String auth(@RequestParam Map<String, String> params, HttpServletRequest request) {
        // 執行具體業務邏輯
        params.forEach((k, v) -> log.info("{} = {}", k, v));
        return "success";
    }

}

附上自定義異常程式碼

package com.info.examples.authentication.exception;

import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class AuthenticationException extends RuntimeException {

    private String msg;

    private String code;

    public AuthenticationException(String msg) {
        this.code = "5000";
        this.msg = msg;
    }

}

現在的程式碼是不是清爽多了

今天的分享就到這裡了,晚安各位!