一轉眼已經到第18天了,照這個速度可能沒辦法完成一個網站,今天要來趕進度!
首先要勘誤
在nonce取值的部分,
我原本是直接return取得nonce的api回傳的value,
但回傳的是json格式
所以要把先前寫的getNonce()方法小改一下,加上parse json的部分
另外getHashID()的return應該要轉大寫,補上:
return hashID.toUpperCase();
再來是getSign()的部分我忘了被加密的內容要先加上nonce跟Hash ID
以上我在
[Day 15] - 初探永豐銀行線上收款API - 豐收款 - HASH ID計算(2) +IV計算
[Day 17] - 初探永豐銀行線上收款API - 豐收款 - Sign值計算(2)
做了更新
另外紀錄一下今天踩到的一顆雷,
在測試時,發現在相同nonce、hash id、內文的情況下,我算出的sign值竟然跟永豐提供的加解密計算機算出的結果不一樣...
我用加解密計算機產生的request去發送api,
明明json的順序、大小寫都沒影響呀...
但是相同的內容,回到我的程式,api卻回
E0000 – 安全簽章錯誤
測了老半天才發現
ObjectMapper會將我原本寫好的物件名稱轉成小寫,
例如Amount會變成amount,
所以我的內文會變這樣
{"amount":55000,
"backendURL":"http://yahoo.com.tw",
"cardParam":{"autoBilling":"Y"},
"currencyID":"TWD",
"orderNo":"C2021000000022",
"payType":"C",
"prdtName":"信用卡訂單123",
"returnURL":"http://google.com",
"shopNo":"NA9999_999"}
我原本以為沒影響,因為把以上內文放到加解密計算機得出的request,是可以成功建立訂單的
但是事情沒我想得這麼簡單
把內文輸入到加解密計算機後
它在計算sign會進行以下動作
在步驟2,並不只是單純的將各欄位轉成 PropertyName=Value 的格式
而且還有將各欄位的PropertyName轉為規格書中定義的名稱,
也就是我原本給的amount,會被它轉為Amount,
經過這樣一轉,SHA256的值也就不一樣了
因為之前沒發現加解密計算機有多做這一動,所以我還以為大小寫不影響
永豐api那邊,我猜也是會在message解密後,將內文欄位轉為正確的大小寫,再拿來計算sign
並比對我們給的sign跟它算出的sign是否相同
因此我們在計算sign時,要先想到永豐會以大小寫正確的內文欄位名稱來計算sign
簡而言之,我的欄位名稱要跟規格書上的大小寫一致啦
這邊我參考stackoverflow上的做法
增加一個QpayPropNamingStrategy.java
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.introspect.AnnotatedField;
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
public class QpayPropNamingStrategy extends PropertyNamingStrategy {
@Override
public String nameForField(MapperConfig<?> config, AnnotatedField field, String defaultName) {
return convert(field.getName());
}
@Override
public String nameForGetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName) {
return convert(method.getName().toString());
}
@Override
public String nameForSetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName) {
return convert(method.getName().toString());
}
private String convert(String input) {
return input.substring(3);
}
}
這邊大概是藉由取得方法名稱,把前面的get去掉,得出第一個字是大寫的名字,我覺得還滿聰明的
接著在使用ObjectMapper前,先套用mapper.setPropertyNamingStrategy(new MyPropertyNamingStrategy());
就行了
現在大小寫都一致了,
{"Amount":55000,
"BackendURL":"http://yahoo.com.tw",
"CardParam":{"AutoBilling":"Y"},
"CurrencyID":"TWD",
"OrderNo":"C000043000022",
"PayType":"C",
"PrdtName":"信用卡訂單123",
"ReturnURL":"http://google.com",
"ShopNo":"NA00001_001"}
這樣終於能成功建立訂單了
回顧一下,整個orderCreate方法是長這樣
public String orderCreate(OrderCreateReq orderCreateReq){
//1.nonce
String nonce=getNonce();
//2.hashID
String hashID=getHashID();
//3.iv
String iv = getIV(nonce);
ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(new QpayPropNamingStrategy());
Map<String, Object> map =
mapper.convertValue(orderCreateReq, new TypeReference<TreeMap<String, Object>>() {});
//remove null
map.values().removeIf(Objects::isNull);
map.forEach((k, v) -> {
if(v.getClass().equals(java.util.LinkedHashMap.class)){
LinkedHashMap<Object,Object>m =(LinkedHashMap<Object, Object>) v;
m.values().removeIf(Objects::isNull);
map.replace(k, v, m);
}
});
String jsonContent ="";
try {
jsonContent = new ObjectMapper().writeValueAsString(map);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
//4.sign
String sign = getSign(map,nonce,hashID);
//5.message
String message = "";
try {
message= encryptUtil.encrypt(jsonContent, hashID, iv);
} catch (IOException | GeneralSecurityException e) {
e.printStackTrace();
}
//send to api
Map<String,String> request = new HashMap<String,String>();
request.put("Version", "1.0.0");
request.put("ShopNo", "NA0040_001");
request.put("APIService", "OrderCreate");
request.put("Sign", sign);
request.put("Nonce", nonce);
request.put("Message", message);
String reqJson="";
try {
reqJson = new ObjectMapper().writeValueAsString(request);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
String res="";
try {
res=util.post("https://apisbx.sinopac.com/funBIZ/QPay.WebAPI/api/Order", reqJson);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return res;
}
發送訂單到API後,api一樣會回覆json字串,
也就是上面返回的res,
但api返回的字串也是有加密的,
接下來要進行response的解密跟sign驗證
為了要AES解密,到先前創建的EncryptUtil.java增加一個decrypt方法
大概是這樣
public String decrypt(String hexContent, String key,String iv) throws IOException,GeneralSecurityException, DecoderException {
byte[] raw = key.getBytes("UTF-8");
SecretKeySpec keySpec = new SecretKeySpec(raw, "AES");
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
IvParameterSpec ips = new IvParameterSpec(iv.getBytes("UTF-8"));
cipher.init(Cipher.DECRYPT_MODE,keySpec,ips);
byte[] encrypted = Hex.decodeHex(hexContent.toCharArray());
byte[] origin = cipher.doFinal(encrypted);
String string = new String(origin);
return string;
}
回到orderCreate方法
這邊為了方便我直接將api回傳的response轉換成Map,
將sign、nonce、message取出
message一樣是以AES CBC加密,
為了解密需使用一樣的Key(Hash ID)、IV(由回傳的NONCE來計算)
如果用request使用的IV,會沒辦法順利解密
...前略
String res="";
try {
res=util.post("https://apisbx.sinopac.com/funBIZ/QPay.WebAPI/api/Order", reqJson);
} catch (IOException e) {
e.printStackTrace();
}
Map<String, String> resMap =new HashMap<String,String>();
try {
resMap = mapper.readValue(res, new TypeReference<HashMap<String, String>>() {});
} catch (JsonProcessingException e) {
e.printStackTrace();
}
String resSign =resMap.get("Sign");
String resNonce =resMap.get("Nonce");
String resMessage =resMap.get("Message");
System.err.println(resMessage);
String resiv= getIV(resNonce);
try {
resMessage=encryptUtil.decrypt(resMessage, hashID, resiv);
} catch (IOException | GeneralSecurityException | DecoderException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
...
取得解密後的res,到這邊暫停,
先去建立一個OrderCreateRes.java,定義好資料類別
import lombok.Data;
@Data
public class OrderCreateRes {
public enum PayType { A, C };
public enum Status {S,F};
@Data
public class ATMParam{
private String AtmPayNo;
private String WebAtmURL;
private String OtpUrl;
}
@Data
public class CardParam{
private String CardPayURL;
}
private String OrderNo;
private String ShopNo;
private String TSNo;
private Integer Amount;
private Status Status;
private String Description;
private String Param1;
private String Param2;
private String Param3;
private PayType PayType;
private ATMParam ATMParam;
private CardParam CardParam;
}
回到orderCreate
在取得解密完成的resMessage後,因為這也是json字串,所以我先轉成Map,使其可以適用先前寫的getSign方法
Map<String, Object> resMessageMap =new TreeMap<String,Object>();
try {
resMessageMap = mapper.readValue(resMessage, new TypeReference<TreeMap<String, Object>>() {});
} catch (JsonProcessingException e) {
e.printStackTrace();
}
取得自行計算的Sign來跟api回傳時附帶的Sign做比較,如果一樣就表示內容沒被竄改
String resForUser = "";
String signCheck = getSign(resMessageMap, resNonce, hashID);
if(signCheck.equals(resSign)){
System.out.println("Sign Check OK:"+signCheck);
}else{
resForUser="永豐回傳簽章驗證失敗"+"\nA:"+signCheck+"\nresSign:"+resSign;
return resForUser;
}
接著,就可以把解密完的內容套用至剛剛定義好的資料型別OrderCreateRes,做個簡單的if判斷,先回傳必要資訊就好,到時候有需要再做調整
OrderCreateRes orderCreateRes = new OrderCreateRes();
try {
orderCreateRes = mapper.readValue(resMessage, OrderCreateRes.class);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
if(orderCreateRes.getATMParam()!=null){
resForUser = orderCreateRes.getATMParam().getAtmPayNo();
}else if(orderCreateRes.getCardParam()!=null){
resForUser = orderCreateRes.getCardParam().getCardPayURL();
}else if(orderCreateRes.getDescription()!=null){
resForUser = orderCreateRes.getDescription();
}else {
resForUser = "不明錯誤,請聯繫網站負責人";
}
return resForUser;
傳送信用卡訂單,會回傳一個信用卡付款頁面url
大概就是這樣了,原本今天要趕進度的
結果花在debug的時間比我想像的還要長,這邊解決後,那邊又發現有問題,串api果然不是輕鬆的事...
另外,我自己覺得在程式中用了很多Map到近乎有點濫用,之後有時間的話再想想有沒有更好的做法
今天就先到此結束!
目前待辦清單有:
1.繼續實作收款api的其他功能
2.JWT調整(過期前應該要不斷重發新的JWT給client,不然client端每30分鐘要重新登入一次)
3.前端React的撰寫
4.用React寫的前端頁面串接匯率api
5.把收款API的Log紀錄到DB
6.整理code、把參數放到DB、改用Spring IOC的寫法...etc
明天可能會挑其中一個來做