Captcha-funktion för Silverlight 2 samt serversides-integration mellan WCF och WPF

I ett projekt som jag har medverkat i uppstod behovet av att kunna verifiera att en anonym användare av en webbtjänst verkligen är en människa och inte en maskin som försöker utnyttja tjänsten på ett otillåtet sätt.

I det här fallet så handlade det om en Silverlight-applikation där användare via en WCF-tjänst ska kunna skicka e-post till valfria mottagare med en egenkomponerad hälsning skapad i Silverlight.

Eftersom det inte finns något säkert 100%-igt sätt att kontrollera att en klient som anropar WCF-tjänsten verkligen är en Silverlight-klient som härstammar från just vår webbserver - plus det faktum att vi ville tillåta anonym access till tjänsten - gjorde att vi började kika på ett s.k. CAPTCHA-mönster för att verifiera att det verkligen är en människa och inte en maskin som initierar anropet mot tjänsten. Annars skulle vi löpa risken att vår webbtjänst som skickar e-posten uttnyttjades för spamming.

CAPTCHA står för  "Completely Automated Public Turing test to tell Computers and Humans Apart" och är en metod som bygger på att systemet genererar en bild som oftast består av ett antal bokstäver eller siffror i olika grader av förvrägning som användaren måste tyda och ange för att applikationen ska tillåta en viss funktion.

Jag började titta på reCaptcha som är en väldigt trevlig gratis CAPTCHA-implementation med stöd för ASP.NET i form av ett nedladdningsbart projekt med en färdig kontroll. Det roliga med reCaptcha är att du som användare även hjälper ett globalt projekt att digialisera äldre böcker genom att använda denna lösning. Genom att både ge en bild med ett referensord (som består av några slumpmässigt valda tecken) och sedan ytterligare ett ord som kommer från ord som OCR (textigenkännings)-program inte lyckats tyda, kan reCaptcha-databasen fyllas på med fler och fler kvalificerade gissningar över vilka dessa ord egentligen är. När ett tillräckligt stort antal reCaptcha-användare gett samma betydelse till ett visst ord anses det fastslaget vilket ordet är. Eftersom man har ca 30 miljoner användare dagligen går det förmodligen rätt snabbt att få ett ord hyfsat säkrat...

Nackdelen med reCaptcha är att den inte finns i en Silverlight-vänlig version: reCaptcha-tjänsten returnerar en komplett HTML-widget med en inbakad token som ska skickas tillbaks till tjänsten tillsammans med lösningen. I vårt fall behövde vi stränga ut denna token och använda vår WCF-tjänst som mellanled. Klart genomförbart - men med lite för lite kontroll över utseendet, framförallt när referensordet tar upp extra yta i applikationen.

Istället skapade jag en egen Silverlight/WCF-baserad Proof of Concept för en CAPTCHA-funktion (det är ju mycket roligare få uppfinna hjulet själv också ;-)

Lösningen är ganska enkel:

  1. I WCF-tjänsten (en ASP.NET-applikation)  läser jag in ett antal XAML-definitioner av bokstäver från disk som jag exporterat som banor (Path´s) från Expression Design 2. Inläsningen görs vid applikationsstart för att förhindra att filerna låses upp vid anrop mot tjänsten. Bokstäverna lagras i ett Dictionary i en applikationsvariabel.
  2. Min WCF-tjänst har två operatorer: GetCaptcha - som returnerar en sträng med en komplett CAPTCHA i XAML-form, samt ValidateCaptcha - som validerar att input-strängen stämmer med den senaste CAPTCHA som lagrats i sessionen.
  3. Silverlight-klienten anropar GetCaptcha - användaren avgör vilka tecken som visas och svaret skickas till WCF-tjänstens ValidateCaptcha-operation som returnerar true/false. captcha

En sak som var lite speciell i implementationen av min Proof of Concept var att jag använder WPF och objekt från PresentationCore - och PresentationFramework - assemblies på serversidan för att manipulera den XAML kod (alltså bokstäverna) som jag läst in från disk. Detta för att skapa viss slumpmässighet i hur min CAPTCHA-bild byggs upp för att göra det svårare att avläsa den programmatiskt med bildbehandlig eller mönstermatchning av den XAML-kod som klienten får.

De flesta WPF-objekt tillåter dock inte att du instansierar och använder dem från en tråd som startats i ett "Multi threaded apartment state" - förkortas MTA. Det här gäller nästan alla objekt som hanterar visuella element som Canvas, RotateTransform osv (alla objekt som via arv härstammar från DependencyObject). Anledningen är främst att göra WPF så enkel som möjligt att integrara med WinForms och Win32.

Eftersom det inte (mig veterligen) går att styra Apartment State när WCF körs i en ASP.NET-applikation var jag tvungen att starta en ny tråd och sätta den i "Single Threaded Apartment", STA-läge, för att kunna använda WPF-objekten i min WCF-tjänst:

    1: using System;
    2: using System.Collections.Generic;
    3: using System.Linq;
    4: using System.Runtime.Serialization;
    5: using System.ServiceModel;
    6: using System.Text;
    7: using System.ServiceModel.Activation;
    8: using System.Web;
    9: using System.Web.SessionState;
   10: using System.Threading;
   11: using System.ServiceModel.Description;
   12: using System.Runtime.CompilerServices;
   13: using System.Xml.Linq;
   14:  
   15: namespace SLCaptchaPOCWeb
   16: {
   17:     [AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
   18:     public class CaptchaService : ICaptchaService
   19:     {
   20:         private Captcha captcha;
   21:         private static Dictionary<string, XDocument> dict;
   22:         private const int captchaLenght = 4;
   23:  
   24:         public Captcha GetCaptcha()
   25:         {
   26:  
   27:             dict = (Dictionary<string, XDocument>)HttpContext.Current.Application["CaptchaLetters"];
   28:  
   29:             var resetEvent = new ManualResetEvent(false);
   30:             Exception error = null;
   31:  
   32:             var thread = new Thread(
   33:                 delegate()
   34:                 {
   35:                     try
   36:                     {
   37:                         captcha = new Captcha(captchaLenght, dict);
   38:                     }
   39:                     catch (Exception ex)
   40:                     {
   41:                         error = ex;
   42:                     }
   43:                     finally { resetEvent.Set(); }
   44:                 });
   45:  
   46:  
   47:             thread.SetApartmentState(ApartmentState.STA);
   48:             thread.Start();
   49:             resetEvent.WaitOne();
   50:  
   51:             if (error == null && captcha != null)
   52:             {
   53:                 HttpContext.Current.Session.Remove("CurrentCaptcha");
   54:                 HttpContext.Current.Session.Add("CurrentCaptcha", captcha);
   55:  
   56:                 return captcha;
   57:             }
   58:             else
   59:             {
   60:                 throw new Exception("Error in creating captcha", error);
   61:             }
   62:         }
   63:  
   64:  
   65:         public bool ValidateCaptcha(string captchaValidation)
   66:         {
   67:             Captcha currentCaptcha = (Captcha)HttpContext.Current.Session["CurrentCaptcha"];
   68:  
   69:             return (currentCaptcha.Letters == captchaValidation) ? true : false;
   70:         }
   71:     }
   72:  
   73: }

Är min CAPTCHA helt säker då?

Naturligtvis går den att knäcka - framförallt eftersom jag använder Path-elementet i XAML för att bygga upp mina bokstäver. Även om jag har viss slumpmässighet så går det om man är envis att plocka ut tillräckligt för att kunna matcha alla bokstäver mot ett mönster. Dessutom använder jag bara stora bokstäver av ett och samma typsnitt från A-Z.

Det är dock väldigt lätt att lägga till siffror, egna typsnitt eller fler bokstäver om du har Expression Design 2 tillgänglig. Expression Design-projektet som jag använt är bifogat källkoden nedan. Välj 'XAML Silverlight Canvas' som export-format, kryssa ur "Always name objects" (annars får du duplicerade namn på dina XAML-objekt), kryssa ur "Place grouped objects in XAML layout container" samt välj "Paths" i "Text"-alternativet:

captchaExportDump

Du kan ladda hem en Visual Studio 2008-solution med källkoden + testprojekt här (du måste ange sökvägen till bokstäverna i web.config för att det ska fungera).

Här finns en live-version som du kan testa.