Spring Boot請求攔截及請求參數加解密

代碼已上傳至github,如遇到問題,可參照代碼

https://github.com/dfyang55/auth
以下介紹的只是一種思路,這種東西不是死的
在這裏插入圖片描述

1)加密實現

後臺代碼實現:CodecUtil
這裏我生成兩個AES的私鑰,一個只是提高SHA1加密的複雜度(這個可以不要,或者可以說任意的,類似於鹽),另一個纔是用於AES的加解密

/** AES密鑰長度,支持128、192、256 */
private static final int AES_SECRET_KEY_LENGTH = 128;
private static String generateAESSecretKeyBase64(String key) {
    try {
        KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
        keyGenerator.init(AES_SECRET_KEY_LENGTH);
        SecretKey secretKey = keyGenerator.generateKey();
        return Base64Utils.encodeToString(secretKey.getEncoded());
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}
/** AES加密密鑰 */
public static final byte[] AES_SECRET_KEY_BYTES = Base64Utils.decodeFromString("XjjkaLnlzAFbR399IP4kdQ==");
/** SHA1加密密鑰(用於增加加密的複雜度) */
public static final String SHA1_SECRET_KEY = "QGZUanpSaSy9DEPQFVULJQ==";

使用AES實現加密解密

public static String aesEncrypt(String data) {
    try {
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); // 加密算法/工作模式/填充方式
        byte[] dataBytes = data.getBytes();
        cipher.init(Cipher.ENCRYPT_MODE,  new SecretKeySpec(AES_SECRET_KEY_BYTES, "AES"));
        byte[] result = cipher.doFinal(dataBytes);
        return Base64Utils.encodeToString(result);
    } catch (Exception e) {
        log.error("執行CodecUtil.aesEncrypt失敗:data={},異常:{}", data, e);
    }
    return null;
}
public static String aesDecrypt(String encryptedDataBase64) {
    try {
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); // 加密算法/工作模式/填充方式
        byte[] dataBytes = Base64Utils.decodeFromString(encryptedDataBase64);
        cipher.init(Cipher.DECRYPT_MODE,  new SecretKeySpec(AES_SECRET_KEY_BYTES, "AES"));
        byte[] result = cipher.doFinal(dataBytes);
        return new String(result);
    } catch (Exception e) {
        log.error("執行CodecUtil.aesDecrypt失敗:data={},異常:{}", encryptedDataBase64, e);
    }
    return null;
}

使用SHA1加密

public static String sha1Encrypt(String data) {
    return DigestUtils.sha1Hex(data + SHA1_SECRET_KEY);
}

前端加密示例,這裏是一個登陸的請求例子,先對數據進行加密,再用加密數據同時間戳和提高複雜度的AES密鑰使用SHA1加密生成簽名,最終將數據組裝發送到後臺。
注意這裏有兩個AES密鑰,需要和後臺對應。

$(function () {
    $("#login_submit").click(function() {
        var username = $("#username").val();
        var password = $("#password").val();
        if (username != undefined && username != null && username != ""
            && password != undefined && password != null && password != "") {
            var loginJSON = JSON.stringify({"username": username,"password": password});
            var encryptedData = CryptoJS.AES.encrypt(loginJSON, CryptoJS.enc.Base64.parse('XjjkaLnlzAFbR399IP4kdQ=='), {
                mode: CryptoJS.mode.ECB,
                padding: CryptoJS.pad.Pkcs7,
                length: 128
            }).toString();
            var timestamp = new Date().getTime();
            var sign = CryptoJS.SHA1(encryptedData + timestamp + "QGZUanpSaSy9DEPQFVULJQ==").toString();
            $.ajax({
                url: "/user/login",
                contentType: "application/json",
                type: "post",
                data: JSON.stringify({"sign": sign, "encryptedData": encryptedData, "timestamp": timestamp}),
                dataType: "json",
                success: function (data) {
                    document.cookie = "authToken=" + data.data.authToken;
                }
            })
        } else {
            alert("用戶名或密碼不能爲空");
        }
    });
});
2)解密實現

首先創建一個類用於接收前端傳過來的加密請求。

@Data
public class EncryptedReq<T> {
    /** 簽名 */
    @NotBlank(message = "用戶簽名不能爲空")
    private String sign;
    /** 加密請求數據 */
    @NotBlank(message = "加密請求不能爲空")
    private String encryptedData;
    /** 原始請求數據(解密後回填到對象) */
    private T data;
    /** 請求的時間戳 */
    @NotNull(message = "時間戳不能爲空")
    private Long timestamp;
}

這裏將使用AOP在切面中進行解密的操作,首先創建註解,在接收加密請求的接口方法上添加該註解,然後對該方法的EncryptedReq參數進行驗籤及解密。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptAndVerify {
    /** 解密後的參數類型 */
    Class<?> decryptedClass();
}

AOP代碼如下,具體步驟就是參數校驗,驗證及解密,數據回填。

@Slf4j
@Aspect
@Component
public class DecryptAndVerifyAspect {
    @Pointcut("@annotation(com.dfy.auth.annotation.DecryptAndVerify)")
    public void pointCut() {}
    @Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0) {
            throw new DecryptAndVerifyException(joinPoint.getSignature().getName() + ",參數爲空");
        }
        EncryptedReq encryptedReq = null;
        for (Object obj : args) {
            if (obj instanceof EncryptedReq) {
                encryptedReq = (EncryptedReq) obj;
                break;
            }
        }
        if (encryptedReq == null) {
            throw new DecryptAndVerifyException(joinPoint.getSignature().getName() + ",參數中無待解密類");
        }
        String decryptedData = decryptAndVerify(encryptedReq);
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        DecryptAndVerify annotation = methodSignature.getMethod().getAnnotation(DecryptAndVerify.class);
        if (annotation == null || annotation.decryptedClass() == null) {
            throw new DecryptAndVerifyException(joinPoint.getSignature().getName() + ",未指定解密類型");
        }
        encryptedReq.setData(JSON.parseObject(decryptedData, annotation.decryptedClass()));
        return joinPoint.proceed();
    }

    private String decryptAndVerify(EncryptedReq encryptedReq) {
        String sign = CodecUtil.sha1Encrypt(encryptedReq.getEncryptedData() + encryptedReq.getTimestamp());
        if (sign.equals(encryptedReq.getSign())) {
            return CodecUtil.aesDecrypt(encryptedReq.getEncryptedData());
        } else {
            throw new DecryptAndVerifyException("驗籤失敗:" + JSON.toJSONString(encryptedReq));
        }
    }
}

最後寫一個接口進行測試

@PostMapping("/login")
@ResponseBody
@DecryptAndVerify(decryptedClass = UserLoginReq.class)
public ResponseVo login(@RequestBody @Validated EncryptedReq<UserLoginReq> encryptedReq, HttpServletRequest request) {
    UserLoginReq userLoginReq = encryptedReq.getData();
    // TODO 從數據庫覈實用戶登錄信息,這裏懶得查數據庫了
    if (userLoginReq.getUsername().equals("admin") && userLoginReq.getPassword().equals("admin")) {
        request.getSession().setAttribute("username", userLoginReq.getUsername());
        return ResponseVo.getSuccess();
    } else {
        return ResponseVo.getFailure(ResponseStatusEnum.USER_AUTH_FAILURE);
    }
}

訪問localhost:8080/user/login,傳入加密數據,即可獲得正確的響應(加密過程工具類有,這裏不展示了)

{"encryptedData":"AN8LpQrOTFEFi8l4MQYyYriUDsKTwLhWtkaI9q6Ck/zjlm1PY/5rQObOeOAFBipY","sign":"ba8dac258b7802b9a407911524ba6f8448e8ea25","timestamp":1585702530560}
3)請求攔截

這裏從上往下開始介紹,先介紹配置類。
這裏配置了攔截所有路徑,然後添加了三個啓動參數,用於在攔截器中獲取並分別進行處理。

@Configuration
public class MainConfig {
    /** 不作攔截的URL路徑 */
    private String excludedURLPaths = "/index,/user/login,/user/register,/**/*.jpg,/**/*.css,/test/**";
    private String logoutURL = "/user/logout";
    private String loginURI = "/user/login";
    @Bean
    public FilterRegistrationBean filterRegistrationBean() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setFilter(new AuthFilter());
        filterRegistrationBean.addInitParameter(AuthConstants.EXCLUDED_URI_PATHS, excludedURLPaths);
        filterRegistrationBean.addInitParameter(AuthConstants.LOGOUT_URI, logoutURL);
        filterRegistrationBean.addInitParameter(AuthConstants.LOGIN_URI, loginURI);
        return filterRegistrationBean;
    }
}

接下來介紹攔截器,具體邏輯就是判斷請求是否需要攔截,如果需要判斷用戶是否登錄,如果已登錄判斷是否爲登出

public class AuthFilter implements Filter {
    private static String loginURI;
    private static String logoutURI;
    /** 用於識別出不需要攔截的URI */
    private static ExcludedURIUtil excludedURIUtil;
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        String[] excludedURLPaths = filterConfig.getInitParameter(AuthConstants.EXCLUDED_URI_PATHS).split(",");
        excludedURIUtil = ExcludedURIUtil.getInstance(excludedURLPaths);
        logoutURI = filterConfig.getInitParameter(AuthConstants.LOGOUT_URI);
        loginURI = filterConfig.getInitParameter(AuthConstants.LOGIN_URI);
    }
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        String requestURI = request.getRequestURI();
        if (excludedURIUtil.match(requestURI)) { // 如果請求URI不需要進行攔截
            filterChain.doFilter(request, response);
            return;
        }
        if (!UserLoginUtil.verify(request)) { // 如果用戶未登錄
            response.sendRedirect(loginURI);
        } else {
            if (requestURI.equals(logoutURI)) { // 用戶登出時刪除相關數據
                UserLoginUtil.logout(request);
            }
            filterChain.doFilter(request, response);
        }
    }
}

判斷URI是否需要攔截這裏使用的是正則表達式,將不需要攔截的URI轉換爲正則存起來,之後直接用請求的URI來匹配 (這裏的正則也是現學現用,百度了半天寫出來的,如果有更好的,可替代)

public class ExcludedURIUtil {
    /** 單例 */
    private static ExcludedURIUtil excludedUriUtil;
    /** uri、正則表達式映射表 */
    private static Map<String, String> uriRegexMap = new HashMap<String, String>();
    private ExcludedURIUtil() {}
    public static ExcludedURIUtil getInstance(String[] uris) {
        if (excludedUriUtil == null) {
            synchronized (ExcludedURIUtil.class) {
                if (excludedUriUtil == null) {
                    excludedUriUtil = new ExcludedURIUtil();
                    if (uris != null && uris.length > 0) {
                        for (String uri : uris) {
                            String regex = uri2Regex(uri);
                            uriRegexMap.put(uri, regex);
                        }
                    }
                }
            }
        }
        return excludedUriUtil;
    }
    /**
     * 判斷給定uri是否匹配映射表中的正則表達式
     */
    public boolean match(String uri) {
        for (String regex : uriRegexMap.values()) {
            if (uri.matches(regex)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 將URI轉換爲正則表達式
     */
    public static String uri2Regex(String uri) {
        int lastPointIndex = uri.lastIndexOf('.');
        char[] uriChars = uri.toCharArray();
        StringBuilder regexBuilder = new StringBuilder();
        for (int i = 0, length = uriChars.length; i < length; i++) {
            if (uriChars[i] == '*' && uriChars[i + 1] == '*') {
                regexBuilder.append("(/[^/]*)*");
                i++;
            } else if (uriChars[i] == '*') {
                regexBuilder.append("/[^/]*");
            } else if (uriChars[i] == '.' && i == lastPointIndex) {
                regexBuilder.append("\\.");
                regexBuilder.append(uri.substring(i + 1));
                break;
            } else if (uriChars[i] == '/') {
                if (!uri.substring(i + 1, i + 2).equals("*")) {
                    regexBuilder.append("/");
                }
            } else {
                regexBuilder.append(uriChars[i]);
            }
        }
        return regexBuilder.toString();
    }
}

用戶登錄認證邏輯具體步驟歸納爲:用戶登錄時認證信息正確則生成一個authToken返回,前端保存到cookie,當請求需要認證時從cookie中獲取,沒有再從header中獲取,再判讀authToken有效性。

public class UserLoginUtil {
    /** 用戶名,token緩存映射,有效時間2小時 */
    private static Cache<String,String> usernameAuthTokenCache = CacheBuilder.newBuilder()
            .expireAfterWrite(2, TimeUnit.HOURS)
            .build();
    /**
     * 爲保證token的唯一性,這裏使用用戶名+UUID+時間戳,最後通過SHA1算法進行加密生成唯一token
     */
    public static String generate(String username) {
        StringBuilder sb = new StringBuilder();
        sb.append(username)
                .append(UUID.randomUUID().toString().replaceAll("-", ""))
                .append(System.currentTimeMillis());
        String authToken = CodecUtil.sha1Encrypt(sb.toString());
        usernameAuthTokenCache.put(username, authToken);
        return authToken;
    }
    /**
     * 驗證用戶token是否存在或是否過期
     */
    public static boolean isValid(String username, String authToken) {
        if (username == null || authToken == null) return false;
        String findAuthToken = usernameAuthTokenCache.getIfPresent(username);
        return findAuthToken != null && authToken.equals(findAuthToken);
    }
    /**
     * 驗證用戶當前登錄是否登錄(先從cookie中獲取,再從header中獲取)
     */
    public static boolean verify(HttpServletRequest request) {
        String username = (String) request.getSession().getAttribute("username");
        for (Cookie cookie : request.getCookies()) {
            if (cookie.getName().equals("authToken") && isValid(username, cookie.getValue())) {
                return true;
            }
        }
        String findAuthToken = request.getHeader("authToken");
        if (isValid(username, findAuthToken)) {
            return true;
        }
        return false;
    }
    /**
     * 用戶登出時刪除緩存中的數據
     */
    public static void logout(HttpServletRequest request) {
        String username = (String) request.getSession().getAttribute("username");
        usernameAuthTokenCache.invalidate(username);
    }
}

最後修改我們的接口進行測試,如果我們直接訪問/user/info則跳轉登錄頁面,只有訪問/user/login post成功登錄後才能訪問需認證的頁面。

@Controller
@RequestMapping("/user")
public class UserController {
    @GetMapping("/login")
    public String login() {
        return "/login";
    }
    @PostMapping("/login")
    @ResponseBody
    @DecryptAndVerify(decryptedClass = UserLoginReq.class)
    public ResponseVo login(@RequestBody @Validated EncryptedReq<UserLoginReq> encryptedReq, HttpServletRequest request) {
        System.out.println(request.getRequestURI());
        UserLoginReq userLoginReq = encryptedReq.getData();
        // TODO 從數據庫覈實用戶登錄信息,這裏懶得查數據庫了
        if (userLoginReq.getUsername().equals("admin") && userLoginReq.getPassword().equals("admin")) {
            request.getSession().setAttribute("username", userLoginReq.getUsername());
            String authToken = UserLoginUtil.generate(userLoginReq.getUsername());
            return ResponseVo.getSuccess(new UserLoginRes(authToken));
        } else {
            return ResponseVo.getFailure(ResponseStatusEnum.USER_AUTH_FAILURE);
        }
    }
    @GetMapping("/info")
    @ResponseBody
    public ResponseVo info() {
        return ResponseVo.getSuccess("用戶信息");
    }
}

詳情可查看github:https://github.com/dfyang55/auth

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章