iT邦幫忙

2021 iThome 鐵人賽

DAY 18
0
永豐金融APIs

30天全端挑戰!React+Spring Boot+Mongo DB 串接永豐API 打造金融網站系列 第 18

[Day 18] - 初探永豐銀行線上收款API - 豐收款 - 建立訂單!

一轉眼已經到第18天了,照這個速度可能沒辦法完成一個網站,今天要來趕進度!

首先要勘誤

在nonce取值的部分,
我原本是直接return取得nonce的api回傳的value,
https://ithelp.ithome.com.tw/upload/images/20211003/20128973GVhiZN3RrX.png
但回傳的是json格式
所以要把先前寫的getNonce()方法小改一下,加上parse json的部分

另外getHashID()的return應該要轉大寫,補上:

return hashID.toUpperCase();

再來是getSign()的部分我忘了被加密的內容要先加上nonce跟Hash ID/images/emoticon/emoticon02.gif
以上我在
[Day 15] - 初探永豐銀行線上收款API - 豐收款 - HASH ID計算(2) +IV計算
[Day 17] - 初探永豐銀行線上收款API - 豐收款 - Sign值計算(2)
做了更新

另外紀錄一下今天踩到的一顆雷,
在測試時,發現在相同nonce、hash id、內文的情況下,我算出的sign值竟然跟永豐提供的加解密計算機算出的結果不一樣...
我用加解密計算機產生的request去發送api,
明明json的順序、大小寫都沒影響呀...

但是相同的內容,回到我的程式,api卻回

E0000 – 安全簽章錯誤
/images/emoticon/emoticon10.gif

測了老半天才發現

是大小寫的問題

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會進行以下動作
https://ithelp.ithome.com.tw/upload/images/20211003/20128973hFEgjWoXO8.png
在步驟2,並不只是單純的將各欄位轉成 PropertyName=Value 的格式
而且還有將各欄位的PropertyName轉為規格書中定義的名稱,
也就是我原本給的amount,會被它轉為Amount,
經過這樣一轉,SHA256的值也就不一樣了
因為之前沒發現加解密計算機有多做這一動,所以我還以為大小寫不影響/images/emoticon/emoticon02.gif

永豐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"}

這樣終於能成功建立訂單了/images/emoticon/emoticon02.gif

回顧一下,整個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
https://ithelp.ithome.com.tw/upload/images/20211003/20128973TUUXgJVaXV.png

大概就是這樣了,原本今天要趕進度的
結果花在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

明天可能會挑其中一個來做/images/emoticon/emoticon06.gif


上一篇
[Day 17] - 初探永豐銀行線上收款API - 豐收款 - Sign值計算(2)
下一篇
[Day 19] - 初探永豐銀行線上收款API - 訂單查詢及其他(1)
系列文
30天全端挑戰!React+Spring Boot+Mongo DB 串接永豐API 打造金融網站30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言