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;
    }
有帮助吗?

解决方案

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...

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();
    }
}

The implementation of LockSession, UnlockSession, and SessionLocked just have to do with the environment. With one server, Session or HttpContext.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.

许可以下: CC-BY-SA归因
不隶属于 StackOverflow
scroll top