1

我有一个 asp.net 页面,允许输入信用卡信息和付款金额以授权付款。大约 2 周前突然间,我们开始收到双重收费的报告,但我们没有对页面进行任何更改。该页面已设置为在单击时禁用提交按钮。在尝试解决问题时,我还在单击按钮时在页面上设置了一个标志,这样如果设置了标志,它将不允许按钮回发(这是我们在另一个页面上使用的方法这没有问题),但它继续发生。

我认为用户刷新页面是极不可能的问题根源有几个原因。首先,我们在 WPF Web 浏览器控件中显示页面,它与它所在的窗口匹配,并且它甚至是网页的唯一指示是回发的点击噪音,如果您要右键单击正文,或者如果有页面错误。唯一的刷新或返回按钮位于浏览器的上下文菜单中。接下来,我认为用户没有动机想要刷新或返回,除非他们收到页面错误,但他们报告在此过程中没有收到任何错误。最后,我采取措施避免服务器端重复回发,方法是在会话中放置一个令牌并在处理卡之前检查它。所以用户必须刷新并点击“重试” 比第一个请求更快的按钮可以将令牌写入会话状态。实现这一目标的最快方法是按提交,F5,连续输入。我讨厌忽略我知道它可能发生的唯一方式,但可以肯定地说这不是正在发生的事情。最后,在回发页面时,通过脚本对象向 WPF 应用程序发出信号,表明它可以关闭,因此用户在回发之后在浏览器消失之前无法在页面上执行任何操作。

唯一的问题是,我不知道发生了什么。不知何故,提交刚刚通过了 javascript 安全防护和服务器端令牌安全防护并被双重收费,我不知道如何。它们被记录为在 2 秒内发生。我已经验证我们的 WPF 应用程序的代码没有调用 Refresh 或以其他方式控制浏览器的导航。有人有想法么?

更新这里是一些相关的代码:

    <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;
    }
4

1 回答 1

1

我记得曾经发生过这样的事情(虽然不是信用卡)。不幸的是,我不记得是什么原因造成的——但我觉得它好像与浏览器相关,不受我的控制,例如,某些浏览器中的某些东西导致了双重提交,而用户甚至没有意识到这一点。

但解决方案是以竞争条件安全的方式处理这种情况。即使没有理由(例如)自动化流程应该或应该对您的页面进行操作,假设它可能是。也许有人正在使用自动提交的插件表单填充器?或者他们可能只是有某种错误的插件,或者鼠标左键接触不良。看起来很奇怪,但谁知道最终用户可能会在不知不觉中绕过您拥有的任何客户端保护措施。

假设有人可以连续两次(或连续 100 次)点击您的帖子 URL。因为事实上,无论您拥有何种客户端保护措施,它们都可以。不要担心客户。相反,在服务器上,在开始事务之前,获取线程安全锁,设置与其会话关联的标志,指示事务已经在进行中,如果找到该标志则退出。

如果由于某种原因您不能信任会话,那么只需在开始之前验证数据是唯一的。

(根据评论进行编辑)如果您更改为有多个 SQL 服务器负责会话管理的情况(或者,一般来说,您没有绝对的方法可以通过常规方式获得有保证的锁定),那么您应该高兴地发现你赚了这么多钱,聘请专家为你解决这个问题:) 同时,不要担心,除非这确实是你很快就会面临的问题。

在一个简单的层面上,我会这样做(使用单个 Web 服务器)。听起来你可能已经知道如何做到这一点,但无论如何......

public class MakeMoney() {

    private static object locker=new Object();

    public void DoTransaction(SaleData data) {
        lock(locker) {
            if (SessionLocked) {
                throw new Exception("Already in progress");
                /// or just exit however you want
            }
            LockSession();
        }    

        Profit();

        UnlockSession();
    }
}

LockSessionUnlockSession和的实现SessionLocked只与环境有关。使用一台服务器,Session或者HttpContext.Cache可能没问题。即使涉及多台服务器,您也可以创建一个仅负责提供锁的非分布式服务器——即使是大容量网站(除非您每分钟销售数百万!)也应该能够处理只需在单个服务器上即可。

可伸缩性是一个问题——但如果您以任何合理封装的方式实现它,那么如果您发现自己处于那种光荣的境地,更换控制器来管理锁应该是小菜一碟。

于 2012-02-10T19:09:17.077 回答