带有重复回发的禁用按钮的 ASP.NET
我有一个 ASP.NET 页面,允许输入信用卡信息和付款金额以授权付款。大约两周前,我们突然开始收到双重收费的报告,但我们没有对页面进行任何更改。该页面已设置为在单击时禁用提交按钮。在尝试解决问题时,我还在单击按钮时在页面上设置了一个标志,这样如果设置了该标志,它将不允许按钮回发(这是我们在另一个页面上使用的方法)没有问题),但这种情况仍在继续发生。
我认为用户刷新页面是极不可能的问题根源有几个原因。首先,我们在 WPF Web 浏览器控件中显示页面,它与所在的窗口相匹配,并且如果您右键单击正文,或者如果有页面错误。唯一的刷新或后退按钮位于浏览器的上下文菜单中。接下来,我认为用户没有任何动机想要刷新或返回,除非他们收到页面错误,但他们报告在此过程中没有收到错误。最后,我采取了措施,通过在会话中放置令牌并在处理卡片之前检查它来避免服务器端的重复回发。因此,用户必须比第一个请求将令牌写入会话状态的速度更快地刷新并点击“重试”按钮。实现这一目标的最快方法是按提交、F5、连续输入所有内容。我不想忽视我所知道的唯一可能发生的情况,但似乎可以肯定地说这并不是正在发生的事情。最后,在回发页面时,通过脚本对象向 WPF 应用程序发出信号,表明它可以关闭,以便用户在回发后、浏览器消失之前无法在页面上执行任何操作。
唯一的问题是,我不知道发生了什么。不知何故,提交的内容刚刚通过了 JavaScript 安全防护和服务器端令牌安全防护,并被双重收费,我不知道如何。它们被记录为在 2 秒内发生。我已经验证我们的 WPF 应用程序的代码没有调用刷新或以其他方式控制浏览器的导航。有人有什么想法吗?
更新 这是一些相关代码:
<style type="text/css">
...
</style>
<script type="text/javascript" language="javascript">
function OnProcessing(button) //
{
//Check if client side validation passes before disabling
// if postback - return false. If it's 1, then it's a postback.
if (document.getElementById("<%=HFSubmitForm.ClientID %>").value == '1') {
return false;
}
else {
// mark that submit is to be done and return true
document.getElementById("<%=HFSubmitForm.ClientID %>").value = '1';
button.disabled = true;
window.external.OnPaymentProcessing();
return true;
}
}
</script>
</head>
<body id="body" runat="server" style="font-family: arial, Helvetica, sans-serif; font-size: 11px;" scroll="no" onkeydown="return CancelEnterKey(event)">
<form id="form1" runat="server">
<asp:scriptmanager ID="Scriptmanager1" runat="server" EnablePageMethods="True"></asp:scriptmanager>
<script src="Resources/Scripts/CardInput.js?<%= DateTime.Now.Ticks %>" type="text/javascript" language="javascript"></script>
<div id="divCardSwiper" style="text-align:center;" runat="server">
<input id="txtSwipeTarget" type="text" onblur="FocusOnSwipeTarget()" onkeydown="return SwipeTargetCharAdded(event)"
style="position: absolute; left: -1000px" />
<table style="margin-left:auto; margin-right:auto">
<tr>
<td style="text-align:center">
<span style="font-size: 20pt; font-weight: bold; color: #808080">Please Swipe Credit Card</span>
</td>
</tr>
<tr><td style="text-align:center"><img alt="Card Swiper Image" src="Resources/scra-magnesafe-mini-3.png"/></td></tr>
<tr><td style="text-align:center"><span style="font-size: 12pt; font-weight: bold; color: #808080">Or <a href="#" onclick="ManualEntry();return false;">click here</a> to enter manually.</span></td></tr>
</table>
</div>
<div id="divCcForm" runat="server">
<table>
<!-- Input Fields -->
</table>
<asp:Label ID="lblError" runat="server" Font-Bold="True" ForeColor="Red"></asp:Label>
<div style="text-align:center;">
<asp:Button ID="btnProcess" runat="server"
Text="Process" OnClick="btnProcess_Click" OnClientClick="if (OnProcessing(this)==false){return false;}" UseSubmitBehavior="False"/>
<p><strong>Processing may take a moment.<br><font color="red">PLEASE ONLY CLICK PROCESS ONCE</font></strong></p>
</div>
</div>
<asp:Label ID="label1" runat="server" Visible="False"></asp:Label>
<asp:HiddenField ID="HFRequestToken" runat="server"/>
<asp:HiddenField ID="HFSubmitForm" runat="server"/>
</form>
</body>
protected void btnProcess_Click(object sender, EventArgs e)
{
if (IsProcessing())
{
//Payment was already processing
btnProcess.Enabled = false; //Make sure button doesn't become available again
logger.Warn(String.Format("PaymentCollection.aspx was submitted multiple times. Only processing the initial request (Session Token: {0}). FacilityID: {1}, FamilyID: {2}, Amount: {3}",
Session[_postBackTokenKey], ViewState[_facilityIDKey], ViewState[_familyIDKey], txtAmount.Text));
return;
}
lblError.Text = String.Empty;
string script = "window.external.OnPaymentProcessingCancelled()";
bool isRefund = (bool)ViewState[_isRefundKey];
bool processed = false;
if (ValidateForm(isRefund))
{
ProcessingInput pi = new ProcessingInput();
try
{
CreditCardType cardType = (CreditCardType)Int32.Parse(ddlCardType.SelectedValue);
pi.CreditCardNumber = txtCardNum.Text.Trim();
pi.ExpirationMonth = Int32.Parse(ddlExpMo.SelectedValue);
pi.ExpirationYear = Int32.Parse(ddlExpYr.SelectedValue);
pi.FacilityID = new Guid(ViewState[_facilityIDKey].ToString());
pi.FamilyID = new Guid(ViewState[_familyIDKey].ToString());
pi.NameOnCard = txtName.Text.Trim();
pi.OrderID = Guid.NewGuid();
pi.PaymentType = cardType.ToMpsPaymentType();
pi.PurchaseAmount = Math.Abs(Decimal.Parse(txtAmount.Text));
pi.Cvc = txtCvc.Text.Trim();
pi.IsCardPresent = cbCardPresent.Checked;
if (pi.PurchaseAmount >= 0.01m)
{
MerchantProcessingClient svc = new MerchantProcessingClient();
try
{
ProcessingResult result;
logger.Debug("Processing transaction (Session Token: {0}) for Facility: {1}, Family: {2}, Purchase Amount{3}",
Session[_postBackTokenKey], pi.FacilityID, pi.FamilyID, pi.PurchaseAmount);
if (!isRefund)
result = svc.AuthorizePayment(pi);
else
result = svc.RefundTransaction(pi);
if (result.Approved)
{
//Signal Oasis that it can continue
StringBuilder scriptFormat = new StringBuilder();
scriptFormat.AppendLine("window.external.OrderID = '{0}';");
scriptFormat.AppendLine("window.external.AuthCode = '{1}';");
scriptFormat.AppendLine("window.external.AmountCharged = {2};");
scriptFormat.AppendLine("window.external.SetPaymentDateFromBinary('{3}');"); //Had to script Int64 as string or it caused an overflow exception for some reason
scriptFormat.AppendLine("window.external.CcLast4 = '{4}';");
scriptFormat.AppendLine("window.external.SetCreditCardType({5});");
scriptFormat.AppendLine("window.external.CardPresent = {6};");
scriptFormat.AppendLine("window.external.OnPaymentProcessed();");
script = String.Format(scriptFormat.ToString(), result.OrderID, result.AuthCode, result.TransAmount, result.TransDate.ToBinary(),
(result.MaskedCardNum == null ? String.Empty : result.MaskedCardNum.Replace("*", "")), (int)cardType,
pi.IsCardPresent.ToString().ToLower());
processed = true; //Don't allow processing again
}
else
{
//log and display errors
}
}
catch (Exception ex)
{
//log, email, and display errors
}
}
else
lblError.Text = "Transaction Amount is zero or too small to process.";
}
catch (Exception ex)
{
//log, e-mail, and display errors
}
}
this.ClientScript.RegisterStartupScript(this.GetType(), "PaymentApprovedScript", script, true);
//Session[_isProcessingKey] = processed; //Set is processing back to false if there was an error
if (!processed)
Session[_postBackTokenKey] = null; //Clear postback token if there was an error to allow re-submission
}
private bool IsProcessing()
{
bool isProcessing = false;
Guid postbackToken = new Guid(HFRequestToken.Value);
// This won't prevent simultaneous POSTs because the second could read the value from
// session before the first writes it to session. It will help eliminate duplicate posts
// if the user is messing with the back button or refreshing.
if (Session[_postBackTokenKey] != null && (Guid)Session[_postBackTokenKey] == postbackToken)
isProcessing = true;
else
Session[_postBackTokenKey] = postbackToken;
return isProcessing;
}
I have an asp.net page that allows credit card information and a payment amount to be entered to authorize a payment. All of the sudden about 2 weeks ago, we started getting reports of double charges but we had not made any changes to the page. The page was already set up to disable the submit button upon being clicked. In trying to resolve the problem, I've since also set a flag on the page when the button is clicked so that if the flag is set, it won't allow the button to postback (this is the method we use on another page that has no problems), but it continues to happen.
There's a few reasons why I'm considering the user refreshing the page to be an extremely unlikely source of the problem. First, we display the page in a WPF web browser control, it matches the window its in, and the only indication that it's even a webpage is the clicking noise of postbacks, if you were to right click on the body, or if there were a page error. The only refresh or back buttons are in the browser's context menu. Next, I can think of no motive for users to want to refresh or go back unless they were to receive a page error, but they report receiving no errors in the process. Finally, I took measures to avoid duplicate postbacks on the server side by placing an token in session and checking for it before processing the card. So the user would have to refresh and hit the "Retry" button faster than the first request could write the token to session state. The fastest way to achieve that would be press submit, F5, Enter all in a row. I hate to ignore the only way I know it could happen, but it seems safe to say this isn't what's happening. Finally, upon posting back the page signals the WPF app, via a scripting object, that it can close so the user isn't able to do anything on the page after a postback before the browser disappears.
The only problem is, I don't know what is happening. Somehow a submission just got past the javascript safe guard and the server side token safe guard and got double charged and I have no idea how. They were logged as happening within 2 seconds of each other. I've verified that our WPF app's code isn't calling Refresh or otherwise controlling navigation of the browser. Anyone have any ideas?
UPDATE
Here is some of the relevant code:
<style type="text/css">
...
</style>
<script type="text/javascript" language="javascript">
function OnProcessing(button) //
{
//Check if client side validation passes before disabling
// if postback - return false. If it's 1, then it's a postback.
if (document.getElementById("<%=HFSubmitForm.ClientID %>").value == '1') {
return false;
}
else {
// mark that submit is to be done and return true
document.getElementById("<%=HFSubmitForm.ClientID %>").value = '1';
button.disabled = true;
window.external.OnPaymentProcessing();
return true;
}
}
</script>
</head>
<body id="body" runat="server" style="font-family: arial, Helvetica, sans-serif; font-size: 11px;" scroll="no" onkeydown="return CancelEnterKey(event)">
<form id="form1" runat="server">
<asp:scriptmanager ID="Scriptmanager1" runat="server" EnablePageMethods="True"></asp:scriptmanager>
<script src="Resources/Scripts/CardInput.js?<%= DateTime.Now.Ticks %>" type="text/javascript" language="javascript"></script>
<div id="divCardSwiper" style="text-align:center;" runat="server">
<input id="txtSwipeTarget" type="text" onblur="FocusOnSwipeTarget()" onkeydown="return SwipeTargetCharAdded(event)"
style="position: absolute; left: -1000px" />
<table style="margin-left:auto; margin-right:auto">
<tr>
<td style="text-align:center">
<span style="font-size: 20pt; font-weight: bold; color: #808080">Please Swipe Credit Card</span>
</td>
</tr>
<tr><td style="text-align:center"><img alt="Card Swiper Image" src="Resources/scra-magnesafe-mini-3.png"/></td></tr>
<tr><td style="text-align:center"><span style="font-size: 12pt; font-weight: bold; color: #808080">Or <a href="#" onclick="ManualEntry();return false;">click here</a> to enter manually.</span></td></tr>
</table>
</div>
<div id="divCcForm" runat="server">
<table>
<!-- Input Fields -->
</table>
<asp:Label ID="lblError" runat="server" Font-Bold="True" ForeColor="Red"></asp:Label>
<div style="text-align:center;">
<asp:Button ID="btnProcess" runat="server"
Text="Process" OnClick="btnProcess_Click" OnClientClick="if (OnProcessing(this)==false){return false;}" UseSubmitBehavior="False"/>
<p><strong>Processing may take a moment.<br><font color="red">PLEASE ONLY CLICK PROCESS ONCE</font></strong></p>
</div>
</div>
<asp:Label ID="label1" runat="server" Visible="False"></asp:Label>
<asp:HiddenField ID="HFRequestToken" runat="server"/>
<asp:HiddenField ID="HFSubmitForm" runat="server"/>
</form>
</body>
protected void btnProcess_Click(object sender, EventArgs e)
{
if (IsProcessing())
{
//Payment was already processing
btnProcess.Enabled = false; //Make sure button doesn't become available again
logger.Warn(String.Format("PaymentCollection.aspx was submitted multiple times. Only processing the initial request (Session Token: {0}). FacilityID: {1}, FamilyID: {2}, Amount: {3}",
Session[_postBackTokenKey], ViewState[_facilityIDKey], ViewState[_familyIDKey], txtAmount.Text));
return;
}
lblError.Text = String.Empty;
string script = "window.external.OnPaymentProcessingCancelled()";
bool isRefund = (bool)ViewState[_isRefundKey];
bool processed = false;
if (ValidateForm(isRefund))
{
ProcessingInput pi = new ProcessingInput();
try
{
CreditCardType cardType = (CreditCardType)Int32.Parse(ddlCardType.SelectedValue);
pi.CreditCardNumber = txtCardNum.Text.Trim();
pi.ExpirationMonth = Int32.Parse(ddlExpMo.SelectedValue);
pi.ExpirationYear = Int32.Parse(ddlExpYr.SelectedValue);
pi.FacilityID = new Guid(ViewState[_facilityIDKey].ToString());
pi.FamilyID = new Guid(ViewState[_familyIDKey].ToString());
pi.NameOnCard = txtName.Text.Trim();
pi.OrderID = Guid.NewGuid();
pi.PaymentType = cardType.ToMpsPaymentType();
pi.PurchaseAmount = Math.Abs(Decimal.Parse(txtAmount.Text));
pi.Cvc = txtCvc.Text.Trim();
pi.IsCardPresent = cbCardPresent.Checked;
if (pi.PurchaseAmount >= 0.01m)
{
MerchantProcessingClient svc = new MerchantProcessingClient();
try
{
ProcessingResult result;
logger.Debug("Processing transaction (Session Token: {0}) for Facility: {1}, Family: {2}, Purchase Amount{3}",
Session[_postBackTokenKey], pi.FacilityID, pi.FamilyID, pi.PurchaseAmount);
if (!isRefund)
result = svc.AuthorizePayment(pi);
else
result = svc.RefundTransaction(pi);
if (result.Approved)
{
//Signal Oasis that it can continue
StringBuilder scriptFormat = new StringBuilder();
scriptFormat.AppendLine("window.external.OrderID = '{0}';");
scriptFormat.AppendLine("window.external.AuthCode = '{1}';");
scriptFormat.AppendLine("window.external.AmountCharged = {2};");
scriptFormat.AppendLine("window.external.SetPaymentDateFromBinary('{3}');"); //Had to script Int64 as string or it caused an overflow exception for some reason
scriptFormat.AppendLine("window.external.CcLast4 = '{4}';");
scriptFormat.AppendLine("window.external.SetCreditCardType({5});");
scriptFormat.AppendLine("window.external.CardPresent = {6};");
scriptFormat.AppendLine("window.external.OnPaymentProcessed();");
script = String.Format(scriptFormat.ToString(), result.OrderID, result.AuthCode, result.TransAmount, result.TransDate.ToBinary(),
(result.MaskedCardNum == null ? String.Empty : result.MaskedCardNum.Replace("*", "")), (int)cardType,
pi.IsCardPresent.ToString().ToLower());
processed = true; //Don't allow processing again
}
else
{
//log and display errors
}
}
catch (Exception ex)
{
//log, email, and display errors
}
}
else
lblError.Text = "Transaction Amount is zero or too small to process.";
}
catch (Exception ex)
{
//log, e-mail, and display errors
}
}
this.ClientScript.RegisterStartupScript(this.GetType(), "PaymentApprovedScript", script, true);
//Session[_isProcessingKey] = processed; //Set is processing back to false if there was an error
if (!processed)
Session[_postBackTokenKey] = null; //Clear postback token if there was an error to allow re-submission
}
private bool IsProcessing()
{
bool isProcessing = false;
Guid postbackToken = new Guid(HFRequestToken.Value);
// This won't prevent simultaneous POSTs because the second could read the value from
// session before the first writes it to session. It will help eliminate duplicate posts
// if the user is messing with the back button or refreshing.
if (Session[_postBackTokenKey] != null && (Guid)Session[_postBackTokenKey] == postbackToken)
isProcessing = true;
else
Session[_postBackTokenKey] = postbackToken;
return isProcessing;
}
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论
评论(1)
我记得曾经发生过类似的事情(尽管不是信用卡)。不幸的是,我不记得是什么导致了它 - 但我觉得它好像与浏览器相关并且不在我的控制范围内,例如某些浏览器中的某些内容导致了双重提交,而用户甚至没有意识到这一点。
但解决方案是以竞争条件安全的方式处理这种情况。即使没有理由(例如)自动化流程应该或应该针对您的页面进行操作,也假设它可以这样做。也许有人正在使用自动提交的插件表单填写器?或者他们可能只是有某种有缺陷的插件,或者鼠标左键接触不良。看起来很奇怪,但谁知道最终用户会做什么,可能会在不知不觉中绕过您拥有的任何客户端保护。
假设有人可以连续点击您的帖子 URL 两次(或连续 100 次)。因为事实上,无论您有什么客户端保护,它们都可以。不用担心客户。相反,在服务器上,在启动事务之前,获取线程安全锁,设置与其会话关联的标志以指示事务已在进行中,如果找到该标志则退出。
如果由于某种原因您不能信任会话,则只需在开始之前验证数据是否唯一即可。
(按评论编辑)如果您更改为有多个 SQL 服务器负责会话管理的情况(或者,一般来说,您没有绝对的方法来使用传统方法获得有保证的锁),那么您应该高兴得跳起来你赚了这么多钱,并聘请一位专家来为你解决这个问题:) 同时,不要担心这一点,除非这确实是你很快就会遇到的问题。
在简单的层面上,我将如何做到这一点(使用单个网络服务器)。听起来您可能已经知道如何执行此操作,但无论如何...
LockSession
、UnlockSession
和SessionLocked
的实现只需要做与环境。对于一台服务器,Session
或HttpContext.Cache
可能就可以了。即使涉及多个服务器,您也可以创建一个仅负责提供锁的非分布式服务器 - 即使是大容量网站(除非您每分钟的销售额达到数百万!)也应该能够处理只需将其放在一台服务器上即可。可扩展性是一个问题——但如果您以任何合理封装的方式实现它,那么更换控制器来管理锁应该是小菜一碟,如果您发现自己处于那种光荣的境地的话。
I remember having something like this happen once (though not with credit cards). Unfortunately I don't remember what caused it - but I feel as if it was browser-related and not in my control, e.g. something in certain browsers was causing double submits without the user even realizing it.
But the solution is to handle this situation in a race-condition-safe way. Even if there's no reason why (for example) an automated process should or should be operating against your page, assume it could be. Maybe someone is using a plug-in form filler that autosubmits? Or maybe they just have a buggy add-in of some kind, or a mouse with a bad contact on the left button. Seems odd, but who knows what the end-user could be doing that might unknowingly circumvent any client-side protections you have.
Assume that someone can hit your post URL twice in a row (or 100 times in a row). Because, in fact, no matter what client-side protections you have, they could. Don't worry about the client. Instead, on the server, before starting the transaction, get a thread-safe lock, set a flag associated with their session that indicates a transaction is already in progress, and exit if that flag is found.
If you can't trust a session for some reason, then just verify that the data is unique before starting.
(edit per comment) If you change to a situation where you have more than one SQL server responsible for session management (or, just generally, you have no absolute way to get a guaranteed lock with conventional means) then you should jump for joy that you're making so much money, and hire an expert to solve this problem for you :) In the meantime, don't worry about that unless that's truly a problem you will face sometime soon.
At a simple level here's how I would do it (with a single web server). It sounds like you may already know how to do this but anyway...
The implementation of
LockSession
,UnlockSession
, andSessionLocked
just have to do with the environment. With one server,Session
orHttpContext.Cache
are probably fine. Even if there are multiple servers involved, you could create a single non-distributed server that is only responsible for providing locks -- even a high volume web site (unless you're making millions of sales per minute!) should be able to deal with having just that on a single server.Scaleability is a concern -- but if you implement it in any reasonably encapsulated way, it should be a piece of cake to swap out the controller for managing locks, should you ever find yourself in that glorious situation.