target:离开柬埔寨倒计时-214day
还是美女作为开篇
前言
昨天没有写文章,因为部门团建,我得去给他们画饼,说起来也真的是唏嘘,我一个已经都在计划着离开柬埔寨的人,昨天聚餐还一个个给他们描述未来的前景,看来我还是比较负责人的,在其位谋其政!!!
支付系统对接商户
作为一个支付系统,他除了普通业务的手续费收取之外,还是很需要外部商户接入的,而我负责整个支付系统的上层业务,那么对接商户自然是我推不掉的责任。这个商户系统我写的还是比较早的,核心都是我完成的,今天也由于时间关系,我就不画图了,下次有机会可以画一下。
- 作为支付系统对外对接的时候我还是很有成就感的,因为看到自己的心血在被别人使用。
- 前期亲自对接的几家商户都顺利在运用了,有这边的外卖软件(如简单点E-gets),也有这边的打车软件(如move),这些前期的商户都是我的成果呀,当时对接好了外卖软件后,我当天就用他们外面软件下单使用我们的支付软件支付,感觉很爽。
- 在没有对接商户之前,我们点外卖、打车都是要付现金的,而有时候打车最容易被坑,有一次其实路程价格不到2美金的,结果给了司机20美金,他硬是不补我钱,跟他说了有两分钟吧,最后输的肯定是身为外国人的我了。
- 后期我专门安排了人对接新的商户,现在接入我们支付系统的商户还是有点多了。
对接商户验签处理
在对接商户的时候,跟商户的交互肯定要做很多校验的,验签就是其中之一,我把当时做的验签方案简化后写成了实例代码,今天一直在写这个,所以博客才这么晚还没写完。
- 首先就是验签工具了,我们支持商户签名算法有RSA和ECC,我就以ECC工具来举例,下面就是ECC的工具类
package com.littlehow.security;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.util.Pair;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
/**
* @author littlehow
* @since 6/1/24 12:14
*/
@Slf4j
public class Ecc {
private static final String SIGN = "sign";
private static final String EMPTY = "";
// 提供者的名字,此处是常量BC
private static final String PROVIDER = BouncyCastleProvider.PROVIDER_NAME;
private static final String KEY_ALGORITHM = "EC";
private static final Base64.Encoder ENCODER = Base64.getEncoder();
private static final Base64.Decoder DECODER = Base64.getDecoder();
static {
// 主要提供BC的provider
Security.addProvider(new BouncyCastleProvider());
}
public static Pair<String, String> initKey() {
try {
// 这里可以使用默认的provider,也就是说可以不用BC的provider,默认会根据前置算法找到对应的provider
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM, PROVIDER);
keyPairGen.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair keyPair = keyPairGen.generateKeyPair();
ECPrivateKey privateKey = (ECPrivateKey)keyPair.getPrivate();
ECPublicKey publicKey = (ECPublicKey)keyPair.getPublic();
return Pair.of(ENCODER.encodeToString(privateKey.getEncoded()),
ENCODER.encodeToString(publicKey.getEncoded()));
} catch (Exception e) {
log.error("不支持EC的key初始化", e);
}
return null;
}
/**
* 私钥进行签名
* @param obj - 待签名对象
* @param privateKey - 私钥
*/
public static String sign(Object obj, String privateKey) {
try {
// 待签名字符串
String toSign = createLinkString(obj);
// 处理私钥
PKCS8EncodedKeySpec PKCS8 = new PKCS8EncodedKeySpec(DECODER.decode(privateKey));
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM, PROVIDER);
// 私钥
PrivateKey key = keyFactory.generatePrivate(PKCS8);
// 签名验签
Signature signature = Signature.getInstance("SHA256withECDSA");
signature.initSign(key);
signature.update(toSign.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signature.sign());
} catch (Exception e) {
log.error("签名失败", e);
throw new RuntimeException(e);
}
}
/**
* 验证签名
* @param obj - 验证对象
* @param sign - 签名串
* @param publicKey - 公钥
* @return -
*/
public static boolean verify(Object obj, String sign, String publicKey) {
try {
// 待验签字符串
String orig = createLinkString(obj);
// 处理公钥
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(DECODER.decode(publicKey));
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM, PROVIDER);
PublicKey key = keyFactory.generatePublic(keySpec);
// 签名验签
Signature signature = Signature.getInstance("SHA256withECDSA");
signature.initVerify(key);
signature.update(orig.getBytes(StandardCharsets.UTF_8));
return signature.verify(DECODER.decode(sign));
} catch (Exception e) {
log.error("验签失败", e);
return false;
}
}
/**
* 这里就尽量定义一个层级,二层级对象不能保证签名串的标准组装
* @param obj - 签名对象
* @return - 待签名串
*/
private static String createLinkString(Object obj) {
if (obj instanceof String) {
return (String) obj;
}
JSONObject json = JSONObject.parseObject(JSONObject.toJSONString(obj));
TreeMap<String, String> treeMap = paraFilter(json);
StringBuilder stringBuilder = new StringBuilder();
treeMap.forEach((key, value) ->
stringBuilder.append(key).append("=").append(value).append("&")
);
return stringBuilder.substring(0, stringBuilder.length() - 1);
}
/**
* 过滤掉参数为空和空字符串的,因为项目需要,再过滤掉参数sign
* 如果定义的是BigDecimal这种金额形式的,那么需要调用双方对无用0做处理
* @param map - 原始参数
* @return - 过滤后的参数,treemap是以key排序的,所以直接使用treeMap返回结果
*/
private static TreeMap<String, String> paraFilter(Map<String, Object> map) {
TreeMap<String, String> result = new TreeMap<>();
if (map == null || map.size() <= 0) {
return result;
}
for (String key : map.keySet()) {
Object value = map.get(key);
if (value == null || EMPTY.equals(value) || SIGN.equalsIgnoreCase(key)) {
continue;
}
String v1;
if (value instanceof BigDecimal) {
v1 = ((BigDecimal) value).stripTrailingZeros().toPlainString();
} else {
v1 = value.toString();
}
result.put(key, v1);
}
return result;
}
public static void main(String[] args) {
Pair<String, String> key = initKey();
if (key == null) {
System.out.println("生成key失败");
return;
}
System.out.println("privateKey=" + key.getFirst());
System.out.println("publicKey=" + key.getSecond());
// 测试参数
Map<String, Object> param = new HashMap<>();
param.put("name", "littlehow");
param.put("age", "36");
param.put("nonce", "82341");
param.put("time", System.currentTimeMillis());
String sign = sign(param, key.getFirst());
System.out.println(sign);
// 测试验签通过
System.out.println(verify(param, sign, key.getSecond()));
}
}
- 还有一个就是我不想要业务在实现的时候每一个都需要去处理签名这块的逻辑,所以就做了一个统一处理,大概思路是有一个拦截器获取商户信息,并且把信息放入上下文,然后就是有一个统一前置处理器来处理验签,涉及到的类如下:
1.上下文类
package com.littlehow.web.context;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
/**
* @author littlehow
* @since 6/1/24 19:51
*/
@Setter
@Getter
@Accessors(chain = true)
public class SignCommon {
/**
* 商户公钥
*/
private String publicKey;
/**
* 商户编号
*/
private String merchantId;
}
====================================================================================
package com.littlehow.web.context;
import org.springframework.util.Assert;
/**
* @author littlehow
* @since 6/1/24 19:37
*/
public class SignContext {
private static ThreadLocal<SignCommon> needVerify = new ThreadLocal<>();
public static void set(SignCommon signCommon) {
needVerify.set(signCommon);
}
public static boolean isNeed() {
SignCommon common = needVerify.get();
return common != null;
}
public static String publicKey() {
SignCommon common = needVerify.get();
// 如果没有值表示并没有进入拦截器,所以不能使用这个方法
Assert.notNull(common, "SdkErrorCode.ILLEGAL_CALL_API");
return common.getPublicKey();
}
public static void clear() {
needVerify.remove();
}
}
2.拦截器类
package com.littlehow.web.req;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author JimChery
* @since 6/1/24 19:30
*/
@Setter
@Getter
public class BaseSignReq {
@ApiModelProperty(value = "时间戳")
@NotNull(message = "timestamp required")
protected Long timestamp;
@ApiModelProperty(value = "nonce,可以做一定时间内的防重处理")
@NotBlank(message = "nonce required")
protected String nonce;
@ApiModelProperty(value = "签名")
@NotBlank(message = "sign required")
protected String sign;
}
====================================================================================
package com.littlehow.web.interceptor;
import com.littlehow.web.context.SignCommon;
import com.littlehow.web.context.SignContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.Assert;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 此拦截器配置统一拦截uri为/open/sdk/**的路径所以请求都保持post的签名体格式
* @author littlehow
* @since 6/1/24 20:03
*/
@Slf4j
public class OpenSdkInterceptor implements HandlerInterceptor {
private static final String OPEN_SDK_APP_ID = "App-Id";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws IOException {
// 获取header的key
String appId = request.getHeader(OPEN_SDK_APP_ID);
log.info("request_appId: {}"+appId);
Assert.hasText(appId, "SdkErrorCode.APP_NOT_EXISTS");
// 根据appId去db或者缓存里面获取商户信息
Map<String, String> info = getMerchantInfo(appId);
Assert.notNull(info, "SdkErrorCode.APP_NOT_EXISTS");
//ip白名单校验
checkCallIp(info.get("whiteIps"));
SignContext.set(new SignCommon().setMerchantId(info.get("merchantId"))
.setPublicKey(info.get("publicKey"))
);
return true;
}
/**
* 此处模拟获取商户信息
* @param appId - 应用id
* @return - 商户信息
*/
private Map<String, String> getMerchantInfo(String appId) {
if (appId.length() < 10) {
// 模拟商户不存在
return null;
}
Map<String, String> map = new HashMap<>();
map.put("publicKey", "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZ7N/yJGVE/4rcokcTT/W3Ja5mOSTJbU1EMLSL+oBSVTirYdtiNDW9ASoVysOi1bZPMnJGh96uGtJY0/R4kdoxg==");
map.put("merchantId", "" + System.currentTimeMillis());
map.put("whiteIps", "192.168.1.1");
return map;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object o, Exception e) {
//删除设置的上下文
SignContext.clear();
}
/**
* 检查白名单ip
* @param ips - 商户ip白名单
*/
private void checkCallIp(String ips) {
Assert.hasText(ips, "SdkErrorCode.API_CALL_IP_INVALID");
// 此处ip从网络请求提取ip方法里面读,此处就写死了
String ip = "192.168.1.2";
log.info("whiteIP: {},sourceIp: {}",ips,ip);
// 多个ip白名单用,分割
String[] ipInfo = ips.split(",");
boolean check = false;
for (String s : ipInfo) {
if (s.trim().equalsIgnoreCase(ip)) {
check = true;
break;
}
}
Assert.isTrue(check, "SdkErrorCode.API_CALL_IP_INVALID");
}
}
====================================================================================
package com.littlehow.web.interceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
private static final String OPEN_SDK = "/open/sdk/**";
private static final String OPEN_SDK_MANAGE = "/open/manage/sdk/**";
// 也可以配置其他的拦截器处理真实的业务
// .....
/**
* 该方法用于注册拦截器
* 可注册多个拦截器,多个拦截器组成一个拦截器链
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 增加一个拦截器 涉及提供给外部商户的接口做统一验签
registry.addInterceptor(openSdkInterceptor())
.addPathPatterns(OPEN_SDK);
// 实例:此处管理接口,使用管理接口的权限验证拦截器
//registry.addInterceptor(authInterceptor())
// .addPathPatterns(OPEN_SDK_MANAGE);
}
@Bean
public OpenSdkInterceptor openSdkInterceptor(){return new OpenSdkInterceptor();}
}
3.统一处理签名类
package com.littlehow.web.advice;
import com.littlehow.web.context.SignContext;
import com.littlehow.web.req.BaseSignReq;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice;
import java.io.IOException;
import java.lang.reflect.Type;
/**
* @author littlehow
* @since 6/1/24 19:42
*/
@ControllerAdvice(value = "com.littlehow.web.controller")
public class OpenSdkRequestBodyAdvice implements RequestBodyAdvice {
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
// 判定是否需要验签
return SignContext.isNeed();
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
return inputMessage;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
if(body instanceof BaseSignReq){
// 此处进行验签处理,验签不通过将抛出异常,由统一异常处理器抛向调用端
OpenSdkSignAssist.execute((BaseSignReq) body);
}
return body;
}
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
}
====================================================================================
package com.littlehow.web.advice;
import com.littlehow.security.Ecc;
import com.littlehow.web.context.SignContext;
import com.littlehow.web.req.BaseSignReq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
/**
* @author littlehow
* @since 6/1/24 19:51
*/
@Component
@Slf4j
public class OpenSdkSignAssist {
// 执行签名验证
static void execute(BaseSignReq req) {
long now = System.currentTimeMillis();
// 支持时间误差5分钟 抛出签名时间超时的错误码,这里就简单抛出个异常
Assert.isTrue(Math.abs(now - req.getTimestamp()) <= 5 * 60 * 1000, "SdkErrorCode.SIGN_TIMEOUT");
// 然后进行签名验证,如果验签不过抛出错误码
Assert.isTrue(Ecc.verify(req, req.getSign(), SignContext.publicKey()), "SdkErrorCode.SIGN_INVALID");
}
}
4.实际的业务类
package com.littlehow.web.controller.vo;
import com.littlehow.web.req.BaseSignReq;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
/**
* @author littlehow
* @since 6/1/24 20:25
*/
@Setter
@Getter
public class CreateOrderReq extends BaseSignReq {
@ApiModelProperty(value = "外部订单编号", required = true)
@NotBlank(message = "outOrderNo must be not null")
private String outOrderNo;
@ApiModelProperty(value = "金额", required = true)
@NotNull(message = "amount must be not null")
private BigDecimal amount;
@ApiModelProperty(value = "币种", required = true)
@NotBlank(message = "currency must be not null")
private String currency;
@ApiModelProperty("交易备注")
private String remark;
}
====================================================================================
package com.littlehow.web.controller;
import com.alibaba.fastjson.JSONObject;
import com.littlehow.web.controller.vo.CreateOrderReq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* @author littlehow
* @since 6/1/24 20:24
*/
@RestController
@RequestMapping("/open/sdk/order")
@Slf4j
public class CreateOrderController {
@PostMapping("create")
public void create(@RequestBody @Valid CreateOrderReq req) {
// 此处只要打印请求即可
log.info(JSONObject.toJSONString(req));
}
}
这样在写所有关于对外开放接口里面的签名都统一处理了,每个业务就不再关心验签的逻辑,只需要关心的自己的业务逻辑处理即可
后记
这里是我简化了验证以及业务,实际的验证更严格,毕竟是支付系统,安全才是头等大事,但是大体的处理思路就是这样的,尽量的解放业务。
加油吧littlehow
北京时间:2024-06-01 21:02
金边时间:2024-05-30 20:02