您的位置:首页 > 其它

支付宝WAP支付接口开发

2014-10-09 16:19 323 查看


支付宝WAP支付接口开发

因项目需要,要增加支付宝手机网站支付功能,找了支付宝的样例代码和接口说明,折腾两天搞定,谨以此文作为这两天摸索的总结。由于公司有自己的支付接口,并不直接使用这个接口,所以晚些时候打算把测试代码整理好放到Github上。


1. 开发前准备

到官网了解此接口的信息,下载样例代码(只有ASP.NET和PHP)以便随时参考。
一个通过实名认证的企业支付宝账号,并申请开通手机WAP支付功能,我的测试账号是拿公司的,申请流程不清楚,官网有说怎么申请,各位各显神通吧。
公网域名和node.js环境。下面的代码大多用coffee来表达,不过本文不会贴太多代码,即使对coffee不熟悉也没什么关系。关于coffee可以参考这里

github上有两个开源小项目(搜索
alipay
),但都没有WAP支付功能,可以拿来当参考,可以认为是示例代码的js移植版,结构很相像。我原打算在其中一个项目基础上继续开发,看了代码和接口文档后,还是决定从头开发一个。因为原有代码层次不够清晰,有点过度设计的感觉,而且支付宝的接口很简单,重写工作量不大。

吐槽下: 官网的示例代码真只是示例级(test)而已,跟产品级(production)还隔比较远,感觉还谈不上SDK。接口文档相当的坑爹,正因如此我才觉得有必要好好写篇文章总结。


2. 流程

接口开发最重要的应该是理解数据交互流程了,流程弄清了,并理解为何这么设计,开发起来也是事半功倍

首先,要准备下面几个参数:

企业支付宝账号的PID(也叫ParnerID)和KEY,如果使用RSA签名而不是MD5的话,还要把RSA私钥准备好
支付时用户看到的东西:商品名称(subject)、支付总额(total_fee)、购买数量(通常都是1吧)
交易后的跳转地址,交易成功后用户可以手工点击,或页面延迟自动跳转到这个地址(return_url)
交易状态异步通知地址,交易成功或交易关闭会把消息POST到这个地址(notify_url)

然后,看这幅流程图(不错吧,推荐下这个网站:)



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Alipay WAP pay flow

Browzer->+Site: 1. HTTP GET
note over Site: 2. create a new trade
Site->+Alipay: 3. create redirect
Alipay->-Site: 4. Token
note over Site: 5. build auth url
Site->-Browzer: 6. redirect to auth url
Browzer->Alipay: 7. redirect
Alipay->Browzer: 8. trade info
Browzer->Alipay: 9. auth and pay
Alipay->+Site: 10. HTTP POST notify
note over Site: 11. process trade
Site->-Alipay: 12. reply "success"
Alipay->Browzer: 13. pay success
Browzer->Site: 14. goto return url

这个流程图基本囊括了整个交互过程,下面是说明:

用户点击购买按钮(或其他形式),向网站发起购买请求
网站创建订单,指派一个唯一订单号
网站把订单号、企业支付宝账号、交易金额、数量等信息,用私钥签名发送给支付宝
支付宝创建一个交易订单,返回一个交易令牌(token)
网站按照指定要求,用token和自己的私钥,构造一个重定向得到支付地址
网站把重定向地址返回给浏览器
浏览器自动重定向到该地址,即包含了token、网站签名的支付宝交易页面
支付宝显示当前交易金额、数量、卖家等信息
用户用自己的支付宝账号支付这笔金额
支付宝把用户支付成功(或失败)这个消息和订单号加上支付宝的签名,使用HTTP POST的方式通知网站(失败的话,会隔段时间重新发送)
网站处理交易后续逻辑(发货、订单状态存储之类的)
网站返回"success"字符串给支付宝,表示该通知已经处理,不用再重发
支付宝显示支付成功页面给用户(这一步和第10步是不分先后发生的)
支付成功页面延迟自动跳转,或用户点击“返回商户页面”,跳转到网站的支付结束页面(此时不一定成功处理支付宝发来的通知),但会在URL带上当前的订单号和状态。

可以发现,整个流程有点像OAuth(哎呀,之前那篇文章还没写完呢!),主要分三步:

一是申请支付宝交易号(获取token),这一步可以理解为,让支付宝验证网站的有效性、让网站指定该交易要支付多少钱 二是用户到支付宝页面付款,这一步可以理解为,让支付宝验证用户有效性,让用户在一个不受网站监视的环境下进行支付 三是用户付款后,处理结果页面告诉用户支付成功(同步通知),另外异步通知网站服务器该订单已支付。

支付宝的接口文档说只有两个步骤,感觉不是很好理解,三步还是比较准确的(收钱肯定要办事的嘛)。

好困,细节问题下期继续。。。

2013-07-24


3. 细节


3.1 网站向支付宝申请新订单

网站的订单系统先产生一个新订单,然后请求支付宝创建一个支付宝订单.

申请新订单的service是 alipay.wap.trade.create.direct 需要提交的关键参数包括:


3.1.1 用户在支付宝看到的订单信息:

subject: 商品名称

total_fee: 总金额

seller_account_name: 卖家支付宝账号(估计跟私钥绑定的)

merchant_url: 商品展示URL(似乎这个并非必要)


3.1.2 支付宝通知网站时将附带的信息:

out_trade_no: 该次交易对应网站的订单号(要求唯一)

call_back_url: 交易成功后,支付宝页面上“返回到商家页面”的地址(同步回调)

notify_url: 交易状态变更后,支付宝通知网站的回调地址(异步通知)

支付宝验证通过后,将返回新创建的支付宝订单号,网站可将该订单号与自己订单系统的的订单号绑定在一起。支付宝同时返回的还有该次交易的token,用于(3.2)用户支付。


3.2 用户在支付宝网站,查看订单消息,通过验证并支付

网站返回跳转到支付宝的地址,service是alipay.wap.auth.authAndExecute,包含(3.1)返回的token和网站对跳转地址的签名

这是个HTTPS页面,基本认为是安全的。当然前提是浏览器没被动手脚,安卓不少应用被捆绑广告那是常有的事,手机浏览器对HTTPS也不像PC那样有明显提示,这些也是我不怎么信任手机支付的原因。

用户跳转到支付宝页面后,可以在该页面里看到当前支付的订单的名称和金额,这些是3.1申请时由网站指定的,让用户在支付宝的页面确认一次再付款是合理的。


3.3 支付宝通知网站支付成功,网站收钱做事

这个过程是支付宝通知网站,网站处理后通知用户已到账,共包括两个并行部分:


3.3.1 异步通知

用户支付后,支付宝通过HTTP协议通知网站该订单交易结果。说白了就是支付宝悄悄地告诉网站“这个订单已经已经付款啦”

值得注意的是,异步通知有重发机制,支付宝需要得到响应为"success"才认为该通知成功被接收,否则会间隔一段时间重发,依次间隔2m,10m,10m,1h,2h,6h,15h,最多8次通知,由notify_id说明是同一个通知。8次通知都接收失败怎么办?额orz...文档没说,用那个支付宝订单号登录支付宝去查账吧。


3.3.2 同步通知

用户支付后,支付宝页面提示“支付成功”,可点击返回商家页面,也可等待一段时间自动跳回

个人认为,网站跳到这个页面后,如果仍未收到(3.3.1)异步通知,并且使用的是MD5签名,应该把状态从“待付款”调整为“等待对账”,而不应该贸然相信该通知的结果。原因是这个回调地址用户是可以知道的,MD5签名还是有被伪造的可能(4.3)。当然额外再做个token之类的理论上也行(需要放在urlpath而不是querystring)。

假如接口调用出错,通知是不会签名的。不签名的原因我怀疑是防止有人恶意收集请求-签名样本,见(4.3)。


4. 签名与加密

简单的说,签名防篡改,加密防窃听。上面的两种请求(3.1和3.2)和两个通知(3.3.1和3.3.2)都被要加签名,支付宝支持下面两类签名:


4.1 MD5: 业务数据不加密,防篡改

优点: 相对较简单(当然是相对DSA/RSA来说),计算速度快,明文更直观

缺点: 可抵赖,可能被窃听、安全性不如非对称加密


4.2 DSA/RSA: 业务数据加密,也防篡改

优点:不可抵赖,安全性较高

缺点:相对较复杂,解密速度慢

一开始我想不懂,支付宝既然支持RSA为何还要支持MD5,后来有人说RSA太慢,想想支付宝的业务量就释然了。由于每个商家的私钥都不同,并且跟商家的支付宝账号绑定,即使商家的私钥被破解了,用户支付时HTTPS协议基本可以保证用户支付的目标还是商家的账号。


4.3 使用MD5签名可能存在的风险

以下情景仅是我的推断,没有尝试过,所谓道高一尺魔高一丈,希望读者也别以身犯险。

在用户支付的步骤(3.2)和支付成功响应页面(3.3),用户可以得到一个明文请求内容和对应签名。由于网站和支付宝直接通信共用同一个密钥,一般长期不变,双方都可以对同一段数据产生签名,这就有可能抵赖的风险:

网站:“这个数据是你发过来的,上面有你的签名。”

支付宝:“不是我发的。这个数据是你伪造的,签名是你签的。”

另外,当攻击者收集到足够多的样本,是有可能破解出密钥的,继而可伪造网站或支付宝任意一方。


4.3.1 恶意消耗商家的订单号

攻击者伪造大量未使用的订单号(不少网站的订单号都是递增的纯数字,并公开给用户,且很容易推测到后面的数字),向支付宝请求订单,直到超时。由于商家对此并不知情(回调地址和通知地址均篡改掉),其他用户下单时假如商家用了被伪造过的订单号,就可能被支付宝认为提交了重复订单,结果支付失败。


4.3.2 欺骗商家已支付订单

由同步通知(3.3.2)返回的参数可以看到,网站订单标识和交易token和都是可以得到的。这样的话,关于步骤(3.1),用户不知道的参数包括notify_url和out_user_no,假如网站的用户id本身就是公开的,通知回调地址(3.3.1)被得知或同步通知(3.3.2)实现的不好,就可以通过伪造支付通知,欺骗商家订单已支付。

待续,下期补充实现代码

2013-07-28


5. 代码


5.1 签名

我只做了MD5签名,项目里没用到RSA签名,就没做那方面。按照文档说明和demo源代码,很容易就可以写出下面的签名代码:
1
2
3
4
5
6
7

getSign = (obj,key) ->
return null unless obj
arr = ([k,v] for k,v of obj when k isnt 'sign' and v? and v isnt '')
arr.sort()
src = ("#{i[0]}=#{i[1]}" for i in arr).join '&'
src = "#{src}#{key}"
crypto.createHash('md5').update(src,'utf8').digest 'hex'

支付宝发到网站的通知(3.3)的签名算法跟上面有点不一样,文档有这么段说明:



这里说要按通知的参数的原本顺序计算签名。所以我就把上面的
arr.sort()
去掉然后计算签名,结果发通知发来的签名和我自己计算的不一致,纠结半天后仔细看文档的样例说明,看到下面段:



仔细跟实际接收的数据比较之后发现,文档和样例都说发来的参数顺序是(service,v,sec_id,notify_data),但我实际收到的并不是按这个顺序,只要按照文档的参数顺序重新排列再计算签名就正确了,最终通知的签名算法如下( 真是个蛋疼的大坑orz):
1
2
3
4
5

getNotitySign = (obj,key) ->
return null unless obj
src = ("#{k}=#{obj[k]}" for k in ["service","v","sec_id","notify_data"]).join '&'
src = "#{src}#{key}"
crypto.createHash('md5').update(src,'utf8').digest 'hex'

文档里有说到字符编码参数
_input_charset
,我用的是utf8编码,发现不用传这个参数也可以,看来支付宝默认的字符编码就是utf8了

如果使用RSA签名,需要先解密再计算签名


5.2 辅助方法

为了代码层次更清晰,我把签名、url拼接等方法抽出到一个单独模块(alipay_api.coffee):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1617
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81

api_url = "http://wappaygw.alipay.com/service/rest.htm"
regexTokenXml = /<request_token>(.*)<\/request_token>/

module.exports = api =

services:
create: "alipay.wap.trade.create.direct"
auth: "alipay.wap.auth.authAndExecute"

toReqData: (name,obj) ->
arr = ["<#{name}>"]
arr.push "<#{k}>#{v}</#{k}>" for k,v of obj
arr.push "</#{name}>"
arr.join ''

createReq: (service,partner,req_data) ->
service : service
format  : 'xml'
v       : '2.0'
partner : partner
sec_id  : 'MD5'
sign    : null
req_data: req_data

parseTokenFromXml: (xml) ->
return null unless xml
m = regexTokenXml.exec xml
m?[1]?.trim()

getSign: (obj,key='') ->
return null unless obj
arr = ([k,v] for k,v of obj when k isnt 'sign' and v? and v isnt '')
arr.sort()
src = ("#{i[0]}=#{i[1]}" for i in arr).join '&'
src = "#{src}#{key}"
crypto.createHash('md5').update(src,'utf8').digest 'hex'

getNotitySign: (obj,key='') ->
return null unless obj
src = ("#{k}=#{obj[k]}" for k in ["service","v","sec_id","notify_data"]).join '&'
src = "#{src}#{key}"
crypto.createHash('md5').update(src,'utf8').digest 'hex'

sendCreate: (req,done) ->
opt =
url: createCreateUrl req
request.get opt, (err,res,body) ->
return done err if err
body = "" unless body
ret = querystring.parse body
body = null
done null,ret

createAuthUrl: (token='',key='') ->
req = api.createReq api.services.auth
req.req_data = "<auth_and_execute_req><request_token>#{token}</request_token></auth_and_execute_req>"
req.sign = api.getSign req, yes
createAuthUrl req

createCreateUrl = (req) ->
url = "#{api_url}?"
url += "req_data=#{encodeURIComponent req.req_data}"
url += "&service=#{encodeURIComponent req.service}"
url += "&sec_id=#{encodeURIComponent req.sec_id}"
url += "&partner=#{encodeURIComponent req.partner}"
url += "&req_id=#{encodeURIComponent req.req_id}"
url += "&sign=#{encodeURIComponent req.sign}"
url += "&format=#{encodeURIComponent req.format}"
url += "&v=#{encodeURIComponent req.v}"
url

createAuthUrl = (req) ->
url = "#{api_url}?"
url += "req_data=#{encodeURIComponent req.req_data}"
url += "&service=#{encodeURIComponent req.service}"
url += "&sec_id=#{encodeURIComponent req.sec_id}"
url += "&partner=#{encodeURIComponent req.partner}"
url += "&sign=#{encodeURIComponent req.sign}"
url += "&format=#{encodeURIComponent req.format}"
url += "&v=#{encodeURIComponent req.v}"
url


5.3 业务部分


5.3.1 购买(buy)

购买的逻辑对应于(2)流程图的(2,3,4,5),创建唯一请求ID,填充本次交易信息,发送到支付宝并获取token,然后拼接支付url并签名,然后重定向。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1617
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

demo.buy = (info,done) ->
return done 'bad user' unless info?.user_id?.length>10
req = api.createReq api.services.create, info.partner
ret =
redirect: ''
token: null
async.series [
(cb) ->
getRequestId req.service,(err,req_id) ->
req.req_id = req_id
cb err
(cb) ->
createTrade info,req.req_id,(err, tradeId) ->
return cb err if err
req.req_data =
subject            : info.subject  # 商品名称
out_trade_no       : tradeId.toString() # 网站订单号
total_fee          : info.total_fee  # 价钱(number),单位元,例如 0.01 代表1分钱
seller_account_name: info.seller_account_name # 支付宝账号
call_back_url      : info.call_back_url # 支付成功后浏览器跳转地址
notify_url         : info.notify_url # 支付成功支付宝的通知将异步发送到此地址
out_user           : info.user_id # 网站的用户标识
merchant_url       : info.merchant_url # 商品展示页面, 只是实际测试时(ios)发现支付时没地方可以跳到这个页面
req.pay_expire = info.pay_expire if info.pay_expire? # 支付过期时间
req.req_data = api.toReqData 'direct_trade_create_req',req.req_data
req.sign = api.getSign req, info.key
cb null
(cb) ->
api.sendCreate req, (err,res) ->
return cb err if err
return cb 'bad sign from alipay server' unless req.sign is api.getSign req
ret.token = api.parseTokenFromXml res.res_data
ret.redirect = api.createAuthUrl ret.token
storeTradeInfo req.out_trade_no, req.total_fee, ret.token, (err,success) ->
return cb err if err
cb if success then null else 'store trade info fail'
],(err) ->
done err, ret.redirect

其中用到的几个方法跟存储相关,我用的是MySQL:
1
2
3
4
5
6
78
9
10
11
12
13
14
15

getRequestId = (service, done) ->
sql = "insert into alipay_requests(service,create_time,state) values(?,now(),'CREATE')"
db.queryAll sql,[service],(err,result) ->
done err, result?.insertId

createTrade = (info,req_id,callback) ->
sql = "insert into alipay_trades(user,req_id,create_time) values(?,?,now())"
db.queryAll sql,[info.user_id,req_id],(err,result) ->
callback err, result?.insertId

storeTradeInfo = (tradeId,rmb,token,callback) ->
sql = "update alipay_trades set rmb=?,token=?,state='WAIT_PAY' where id = ?"
args = [rmb,token,tradeId]
db.queryAll sql,args,(err,updateResult) ->
callback err,updateResult?.affectedRows is 1


5.3.2 通知(notify)

用户支付后就等着通知了,按道理应该在TRADE_SUCCESS时处理用户支付成功的逻辑,但我实际测试发现至发送了TRADE_FINISHED事件来,所以干脆两个一并处理了,反正只会有一次成功。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1617
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

xmlreader = require 'xmlreader'rr,updateResult?.affectedRows is 1
demo.onNotify = (req,callback) ->
xmlreader.read req.notify_data,(err,xdoc) ->
return done err if err
notify = xdoc.notify
notify_id = notify?.notify_id?.text()
return done 'bad notify_data' unless notify_id
done = (response) ->
unless 'string' is typeof response
console.error "response notify error: " + (response?.stack ? response ? '')
response = 'server error'
console.error "response notify: #{response}" unless response is 'success'
storeNotifyResponse notify_id,response, (err) ->
callback if response is 'success' then err else response
storeNotifyDetails notify_id, notify, req, (err,success) ->
return done err if err
unless success
return done "store detail error"
trade_status = notify.trade_status.text()
if trade_status is 'TRADE_FINISHED' or trade_status is 'TRADE_SUCCESS'
unless req.sign is api.getNotitySign req
return done 'bad sign'
out_trade_no = notify.out_trade_no.text()
getTradeUser out_trade_no,(err,user) ->
return done err if err
user_id = user?.user
return done 'unknown user' unless user_id
onPayed user_id,(err,success) ->
return done err if err
return done "onPayed error" unless success
storeTradeFinalState out_trade_no, no, console.error
done 'success'
else # TRADE_PENDING, TRADE_CLOSED, WAIT_BUYER_PAY, etc
return done 'unknown trade status'

跟存储相关的几个方法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1617
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

storeTradeFinalState = (tradeId,isError,callback) ->
state = if isError then 'FAILURE' else 'SUCCESS'
sql = "update alipay_trades set state=?,close_time=now() where id = ?"
args = [state,tradeId]
db.queryAll sql,args,(err,updateResult) ->
callback err,updateResult?.affectedRows is 1

getTradeUser = (out_trade_no,callback) ->
sql = "select id,user from alipay_trades where id=?"
db.queryOne sql,[out_trade_no],callback

onPayed = (user_id,callback) ->
sql = "update users set vip=1 where id=? and vip=0 limit 1"
db.queryAll sql,[user_id],(err,updateResult) ->
callback err, updateResult?.affectedRows is 1

storeNotifyDetails = (notify_id,notify,raw,callback) ->
sql = "insert ignore into alipay_notifies(id,recv_time,subject,trade_no,gmt_create,
quantity,out_trade_no,notify_time,total_fee,buyer_email,trade_status,
gmt_payment,gmt_close,raw) values(?,now(),?,?,?,?,?,?,?,?,?,?,?,? )"
args = [
notify_id,
notify.subject?.text()
notify.trade_no?.text()
notify.gmt_create?.text()
notify.quantity?.text()
notify.out_trade_no?.text()
notify.notify_time?.text()
notify.total_fee?.text()
notify.buyer_email?.text()
notify.trade_status?.text()
notify.gmt_payment?.text()
notify.gmt_close?.text()
JSON.stringify raw
]
db.queryAll sql,args,(err,updateResult) ->
callback err,updateResult?.affectedRows is 1

storeNotifyResponse = (notify_id,response,done) ->
sql = "update alipay_notifies set response=? where id=?"
db.queryAll sql,[response,notify_id],done


6. 后记

有些公司有自己的支付平台,封装了一层,结果调用流程变成下面这样:



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1617
18
19
20
21

Alipay WAP using platform pay flow

Browzer->+Site: 1. HTTP GET
note over Site: 2. create a new trade
Site->-Browzer: 3. trade info
Browzer->+Platform: 4. redirect
note over Platform: 5. create a new trade
Platform->+Alipay: 6. create redirect
Alipay->-Platform: 7. Token
note over Platform: 8. build auth url
Platform->-Browzer: 9. redirect to auth url
Browzer->Alipay: 10. redirect
Alipay->Browzer: 11. trade info
Browzer->Alipay: 12. auth and pay
Alipay->+Platform: 13. HTTP POST notify
Platform->Site: 14. proxy
note over Site: 15. process trade
Site->Platform: 16. reply "success"
Platform->-Alipay: 17. reply "success"
Alipay->Browzer: 18. pay success
Browzer->Site: 19. goto return url

咋一看,挺方便的,Site只需要做个跳转即可,细节都被Platform隐藏起来了,切换不同的支付方式也变得很方便。

可是,这个平台的接口并不完善,这些数据都是明文并且没有要求Site做签名,于是就有一个风险。跟(2)的流程图对比,可以发现这个流程多了(3,4,14,16)四个步骤。后面两步只是个代理包装,没什么问题,问题在于步骤3和4。

可以看到,交易细节被放到了重定向url中了,即用户可以得知交易内容并篡改里面的数据。举个例子,假设重定向地址类似这样

http://payment.mysite.com/api/pay?tradeno=10000&rmb=100.00&siteid=1

由于该地址没有验签机制,所以攻击者很容易就可以发现这里面可以随意篡改数据。

方法1: 将rmb=100.00改成0.01,即将100元的支付变成1分钱,然后支付。结果Site得到通知的时候,就需要额外处理这种支付款项和要求款项不一致的情况。当然最简单就是金额不足就不退款并且支付失败了。假如没做这种判断,那就相当把100元的商品以1分钱卖出去了。

方法2: 伪造大量tradeno,然后请求,并且不支付款项。由于tradeno要求唯一,并只能使用一次,这样就相当于消耗掉了site的交易ID,并且site对此毫不知情。结果正常用户要购买时,创建的订单号对于site来说是未使用的,但对platform来说已经是使用过了,会返回支付失败。

要避免这个问题,需要在重定向的地址增加签名,签名异常的请求都抛弃掉。

(全文完)

转自:http://neutra.github.io/2013/%E6%94%AF%E4%BB%98%E5%AE%9DWAP%E6%94%AF%E4%BB%98%E6%8E%A5%E5%8F%A3%E5%BC%80%E5%8F%91/
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: