Jak napsat CAPTCHA komponentu pro ASP.NET?

1. díl - Jak napsat CAPTCHA komponentu pro ASP.NET?

Tomáš Herceg       20. 9. 2009       C#, VB.NET, ASP.NET WebForms, Komponenty, Bezpečnost       21413 zobrazení

V tomto článku si podrobně popíšeme, jak vytvořit kompletní CAPTCHA komponentu v ASP.NET. Ukážeme si, jak napsat vlastní handler, jak udělat vlastní validátor a jak podědit komponentu Image. Též si předvedeme, jak udělat vlastní konfigurační třídy pro snadné nastavování parametrů captchy.

Web je dnes mnohem interaktivnější, než býval před pár lety. Máme stovky tisíc diskusních fór a různých shoutboardů, což je samozřejmě příležitost pro různé nenechavce, kteří vyvíjejí spamboty prolézející Internet a spamující tato místa kravinami, reklamami na viagru a mnohými dalšími nesmysly. Způsobů, jak tomu zamezit, je samozřejmě mnoho, jedním z nejpoužívanějších je tzv. captcha.

O co jde?

Základní myšlenkou je vygenerování nějakého obrázku, na němž uživatel vidí písmena (případně i čísla) a jeho úkolem je tyto znaky z obrázku opsat. Využívá se toho, že napsat automatického spambota s OCR (rozpoznáváním textu v obrázku) není tak lehké. To však v žádném případě neznamená, že se takový spambot napsat nedá. Není rozhodně lehké napsat bota, který rozlouskne různé ochrany na mnoha webech (vzhledem k tomu, že si je skoro každý píše sám, tak každá je jiná), ale podstatně jednodušší je napsat bota, který se zaměří na jeden konkrétní web. Prolomit jednu konkrétní captchu není tak těžké a zabránit se tomu bohužel nedá. Na druhou stranu málokterý web je tak důležitý, že by se někomu vyplatilo psát OCR cílené přesně na jeho captchu.

Přestože v ASP.NET máme komponenty na kde co, captcha tam prostě a jednoduše není. Buď můžete použít jednu z mnoha desítek existujících komponent (zdarma i placených), nebo si napsat svou. V tomto článku se podíváme na tu druhou možnost. Rozhodně nenapíšeme nejdokonalejší captchu, ale ukážeme si, jak ji napsat správně a tak, aby nebyla při cíleném útoku jednoduše zvládnutelná bez pořádného OCR.

Nejčastější chyby v implementaci

Obecným problémem (který nezávisí na ASP.NET, objevuje se poměrně často i u aplikací v jiných technologiích, třeba PHP či Java), na nějž můžeme narazit, je otázka, kam uložit text, který jsme do obrázku napsali, abychom později mohli zkontrolovat, zda-li ho uživatel napsal správně.

Mnoho programátorů to řeší jednoduše a špatně – uloží prostě správné řešení přímo do stránky, do nějakého skrytého pole, nebo do URL, kam se stránka následně odesílá. Hodnota je na dosah ruky, ale pokud by někdo chtěl udělat cílený útok na váš web, je to velmi jednoduché a nepotřebuje vůbec zkoumat obrázek. Ani adresa obrázku nesmí kód v čisté podobě obsahovat, to by byla zkrátka hrubá chyba.

Pozor na session

Někdo si řekne “aha, vždyť máme přece session”. Ta leží na serveru a klient se k jejímu obsahu nedostane, má jen ID, které mu nic neřekne. Pokud nevíte, co to je, přečtete si velmi zajímavý seriál Michala A. Valáška o cookies, session a ViewState (1. díl, 2. díl, 3. díl, 4. díl a 5.díl), ať víte “vocogou”.

Abych demonstroval, proč je session nevhodná, schválně si udělejme jeden test. Vytvořte jednoduchou stránku s tímto kódem:

 <%@ Page Language="VB" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">

Protected Sub Button1_Click(ByVal sender As Object, ByVal e As System.EventArgs)
Session(
"Test") = "test"
End Sub
</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
</head>
<body>
<form id="form1" runat="server">
<div>

<%= Session("Test")%>

<asp:Button ID="Button1" runat="server" Text="Button" OnClick="Button1_Click" />
<br />
<a href="Default.aspx">Tato stránka</a>


</div>
</form>
</body>
</html>

Jedná se o opravdu velmi jednoduchou stránku, která vypíše na výstup hodnotu položky Test v aktuální session. Dále tam máme tlačítko, po kliknutí na nějž se do položky Test v session uloží naše hodnota test. A následuje odkaz na tu samou stránku, na které právě jsme.

Nyní provedeme následující kroky (testováno v IE, v jiných prohlížečích se to může chovat odlišně, přesné chování bohužel nikde není definováno):

  1. Otevřeme stránku v prohlížeči. V session nic není, ukáže se tedy jen tlačítko a odkaz. Pokud klikneme na tlačítko, do session se uloží náš řetězec test a hodnota se zobrazí ve stránce – uvidíme tedy text test, tlačítko a odkaz.
  2. Nyní klikneme na odkaz a podržíme přitom Ctrl, aby se stránka otevřela v nové záložce. A ihned po načtení vidíme, že se zobrazí opět text test, tlačítko a odkaz. Session je tedy sdílená mezi dvěma záložkami.
  3. Pokud zkopírujete URL adresu stránky, otevřete nové okno prohlížeče (ne novou záložku) a adresu vložíte, nápis test se neobjeví. Nové okno prohlížeče má totiž novou session.

V čem je tedy problém? Chyba je v tom, že záložky ve stejném okně session cookies sdílejí, a tím pádem mají i stejnou session. Pokud si uživatel zobrazí dvě stránky v různých záložkách stejného okna a byla by na nich naše captcha ukládající si správnou odpověď v session, je to dost nemilé:

  • Uživatel otevře první stránku s captchou, takže vygenerujeme náhodný řetězec, uložíme ho do session pod klíč captcha a do stránky mu naservírujeme obrázek směřující na nějakou stránku (tzv. handler, o tom později), která vygeneruje a vrátí obrázek podle uložené hodnoty v session.
  • Ještě než uživatel vyplní kód z obrázku, otevře v nové záložce jinou stránku s captchou. Opět se vygeneruje náhodný řetězec, uloží se do session pod klíč captcha, čímž si přepíšeme ten první.
  • Uživatel vyplní kód z obrázku na první stránce a odešle ji, dostane ale přes čumák, protože opsaný kód se neshoduje s hodnotou v session. Pokud nejdřív odešle druhou stránku, bude to v pohodě, ale kód z první stránky je nenávratně ztracen.

Ano, někdo může namítnout, že je možné místo klíče captcha použít něco jiného (ono je to dokonce i rozumné, co když na té samé stránce budou captchy dvě?). Ale pokud si uživatel ve dvou záložkách otevře stejnou stránku, nemáme možnost je od sebe odlišit, budou mít stejný klíč.

Jak z toho ven?

Řešením je poslat správnou odpověď spolu se stránkou, ale tak, aby se z ní kód nedal vyčíst. Navíc můžeme využít toho, že kód nemusí být opravdu 5 náhodně vygenerovaných znaků. Tyto znaky ale samozřejmě musí být neuhodnutelné.

Jak to tedy provedeme? Vygenerujeme třeba 10 náhodných znaků, ze kterých budeme schopni správnou odpověď určit, ale případný útočník ne, protože ten nezná tajemství, jak přesně to děláme. Tyto náhodné znaky pošleme normálně do stránky, předáme je i handleru, který podle nich spočítá kód a vygeneruje obrázek, a pak je odešleme třeba ve skrytém formulářovém poli. Při kontrole uživatelovy odpovědi si z náhodného řetězce, který jsme si ve stránce schovali, spočítáme kód znovu a porovnáme s tím, co napsal uživatel.

První zádrhel

Útočník přijde, ze stránky si vytáhne náhodný řetězec (to jde, musíme ho nějak předat handleru, který generuje obrázky), vyplní odpověď do políčka ručně. Protože kód počítáme pouze z tohoto řetězce, může relativně snadno odesílat všechny požadavky s tímto jedním náhodným řetězcem, k němuž zná správnou odpověď.

To se dá celkem snadno vyřešit – držet si seznam například poslední tisícovky úspěšně ověřených náhodných řetězců. Pokud uživatel kód zadá správně, použitý náhodný řetězec se uloží do seznamu zakázaných a pokud se někdo pokusí použít jej znovu, dál ho nepustíme. Je potřeba samozřejmě z tohoto seznamu hodnoty časem promazávat, aby to nezabíralo zbytečně moc paměti.

Druhý zádrhel

Tohle není až tak zádrhel, ale pořád tady mluvíme o nějakém náhodném řetězci a počítání nějakého kódu. Zatím jsme ovšem neřekli, jak ho budeme počítat. Co vlastně musí platit, aby to fungovalo?

  • Z jednoho konkrétního náhodného řetězce musíme výpočtem dojít vždy ke stejnému kódu, který se zapíše do obrázku. Poprvé jej totiž potřebujeme pro vygenerování obrázku v handleru, podruhé pro jeho ověření. Mězi těmito okamžiky může uplynout klidně několik hodin.

Tady využijeme hashování, což je, jak jistě víte, jakási funkce, která na vstupu dostane hodnotu a vypočítá její “otisk”. U dobré hashovací funkce je nutné, aby nešlo jednoduše najít kolizi, tedy nalézt dvě různé vstupní hodnoty, které mají stejný hash (otisk). Z otisku, který je většinou kratší, než vstupní hodnota, samozřejmě není možné původní hodnotu určit.

Jak konkrétně to uděláme? Za náhodný řetězec přidáme nějaký náš vlastní tajný klíč (rozumně dlouhý), který bude společný pro celou webovou aplikaci, určíme jej v její konfiguraci a útočník jej samozřejmě nebude znát. Kód může být například 5. až 10. znak z SHA512 hashe této hodnoty. Útočník tajný klíč nezná, proto z náhodného řetězce nemůže kód uhodnout, nedisponuje-li armádou superpočítačů (SHA512 je dnes považována za bezpečnou, oproti třeba MD5 či SHA1, které se dává “rok, maximálně dva”).

Proč přidávat klíč? Spočítat hash náhodného řetězce může kdokoliv a pokud to bude přímo náš kód, opět útočník nepotřebuje zkoumat obrázek a spočítá si to bez něj. Přidáním našeho tajného klíče za náhodný řetězec způsobí, že hash vyjde jinak a bez znalosti tajného klíče je neuhodnutelný.

Shrnutí

Na následujícím obrázku je znázorněn celý postup, jak naše captcha bude fungovat.

Celý postup

  1. Uživatel otevře adresu stranka.aspx. Server vygeneruje náhodný řetězec A4Hf0we13lpq a do stránky vloží:
    • obrázek s adresou captcha.ashx?c=A4Hf0we13lpq
    • textové pole pro zadání kódu z obrázku
    • skryté formulářové pole s náhodným řetězcem (budeme jej potřebovat při odeslání stránky)
    • tlačítko pro odeslání formuláře
  2. Prohlížeč najde na stránce obrázek a vyžádá si jej, udělá tedy požadavek na adresu captcha.ashx?c=A4Hf0we13lpq. Handler si z náhodného řetězce a tajného klíče v aplikaci spočítá SHA512 hash a určí kód. Vygeneruje obrázek s různou barvou, velikostí a natočením písmen, který pošle na klienta.
  3. Nyní má klient připravenou celou stránku a zobrazí ji uživateli. Ten kód z obrázku přečte, opíše do textového pole a klikne na odesílací tlačítko.
  4. Stránka se odešle a s ní i obsah skrytého pole s náhodným řetězcem a textového pole s odpovědí uživatele. Stejným způsobem, jako to udělal handler, z náhodného řetězce ze skrytého pole spočítáme správný kód a porovnáme odpověď uživatele.
    • Pokud se ověření povedlo, uložíme si náhodný řetězec do seznamu již použitých. Při každém ověření se nejdřív podíváme, jestli náhodný řetězec již v seznamu použitých není, pokud ano, odpověď nebudeme akceptovat.

To by bylo v kostce vše. Útočník nezná tajný klíč, takže náhodný řetězec mu je sám o sobě k ničemu. Pokud se mu podaří nějakým OCR nebo ručně obrázek rozluštit, může jej použít pouze jednou, pak již bude náhodný řetězec v seznamu použitých kódů a odpověď nebude akceptována.

Architektura naší komponenty

Vzhledem k tomu, že naše komponenta bude celkem složitá, budeme vhodné její funkcionalitu rozdělit do více tříd.

Nejhlavnější třídou bude třída Captcha, která bude obsahovat pouze statické metody pro vygenerování náhodného řetězce, spočítání kódu, jeho ověření a vygenerování obrázku. Bude se také starat o seznam naposledy použitých řetězců.

Dále napíšeme poměrně jednoduchou komponentu CaptchaImage. Ta bude mít za úkol při každém požadavku vygenerovat nový náhodný řetězec a nastavit svou URL tak, aby pro tento řetězec zobrazila správný kód na obrázku.

Další částí bude třída CaptchaHandler, což je jednoduchá třída, která pouze vygeneruje obrázek a vypíše ho na výstup s příslušnou Content-Type hlavičkou, aby prohlížeč věděl, co to vlastně dostal.

Posledním článkem bude komponenta CaptchaValidator, která dostane jednak komponentu, do níž uživatel zapsal kód, a dále komponentu CaptchaImage, která zná náhodný řetězec. Tento validátor bude spolupracovat s validační infrastrukturou stránky a jeho jediným úkolem bude kontrolovat správnost zadání kódu.

Protože navíc budeme chtít, aby naše captcha byla hezky konfigurovatelná, ukážeme si, jak napsat konfigurační třídy. To musíme udělat hned na začátku, protože konfiguraci budeme potřebovat ve výše uvedených třídách.

Konfigurace

Aplikace si uchovávají konfiguraci různým způsobem – v registrech, v INI souborech, ve vlastních binárních či textových formátech atd. Tvůrci .NET Frameworku se rozhodli, že vývojářům nabídnou rozšiřitelný a komplexní model pro ukládání konfigurace aplikací. Vzhledem k tomu, že samotné téma by vydalo na celý článek, nebudu se zde o tom moc rozepisovat, vysvětlím stručně jen to, co budeme potřebovat.

Ve webových aplikacích v ASP.NET konfigurace sedí v souboru web.config. Měli byste vědět, že ve web.config v adresáři aplikace není kompletní konfigurací, ale obsahuje pouze změny oproti souborům machine.config a web.config kdesi v adresáři .NETu ve Windows.

V konfiguračním souboru aplikace máme mnoho různých XML elementů, např. appSettings, connectionStrings, nebo třeba system.web. První dva elementy nejsou specifické pro webové aplikace, ty jsou obecné. Věci týkající se ASP.NET a HTTP prostředí jsou právě uvnitř elementu system.web.

V konfiguračním souboru nemohou být jen tak ledajaké elementy. Tedy vlastně mohou, ale musíme je napřed zaregistrovat. Pokud bychom vzali úplně čistý konfigurační soubor, který z ničeho nedědí, jediný povolený element uvnitř kořenového elementu configuration by byl element configSections. V tomto elementu můžeme zaregistrovat tzv. konfigurační sekce (Configuration Sections) obsahující samotné nastavení a dále též skupiny konfiguračních sekcí (Configuration Section Groups), které slouží ke sdružování konfiguračních sekcí, které k sobě nějak patří.

 <configuration>
<
configSections>
<
section name="appSettings" type="System.Configuration.AppSettingsSection, System.Configuration, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" restartOnExternalChanges="false" requirePermission="false"/>
<
section name="connectionStrings" type="System.Configuration.ConnectionStringsSection, System.Configuration, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" requirePermission="false"/>
...
<sectionGroup name="system.web" type="System.Web.Configuration.SystemWebSectionGroup, System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
...
</sectionGroup>
...
</configSections>
</
configuration>

Toto je výtažek ze souboru machine.config. Vidíme deklarace sekcí appSettings, connectionStrings a skupiny system.web, jež obsahuje například sekci membership, v níž nastavujeme providery pro správu uživatelů.

Každé konfigurační sekci odpovídá nějaká třída dědící z třídy ConfigurationSection. Jaké třídě sekce odpovídá, to jest určeno atributem type. Ten obsahuje celý název třídy včetně namespace a plný název assembly, v níž je tento typ definován. Konfigurační sekce může obsahovat vlastnosti a vnořené konfigurační elementy, což jsou opět třídy odvozené tentokrát od třídy ConfigurationElement. Konfigurační elementy mohou obsahovat opět vnořené konfigurační elementy, nebo obyčejné vlastnosti.

Jak bude naše konfigurace vypadat?

Píšeme knihovnu s příhodným názvem MojeKomponenty a chceme, aby její konfigurace vypadala jako na následující ukázce kódu. Protože kromě captchy můžeme časem přidávat další komponenty, necháme si pro to místo a konfiguraci captchy dáme do samostatného elementu. Máme tedy konfigurační sekci mojeKomponenty obsahující konfigurační element captcha, kterýžto bude nastavovat různé parametry naší ochrany proti botům.

   <mojeKomponenty>
<captcha secretKey="Klíč nejklíčovatější, který je převelice tajný a nikdo ho nesmí znát. Takže pokud tento sample budete používat, koukejte ho změnit!"
handlerUrlFormatString="~/Captcha.ashx?c={0}" imageWidth="300" imageHeight="80" />
</mojeKomponenty>

Abychom tento kód mohli dát do souboru web.config a fungovalo to, musíme si naši sekci zaregistrovat. Dovnitř elementu configSections ve web.config naší website přidejte následující řádek. U typu nemusíme uvádět assembly, protože třídy jsou přímo v naší aplikaci. Samotnou konfigurační sekci mojeKomponenty umístěte přímo do elementu configuration, nevnořujte ji například do system.web, to by nefungovalo.

 <section name="mojeKomponenty" type="MojeKomponenty.MojeKomponentyConfigurationSection" />

Nyní musíme napsat třídu, která bude obrazem naší konfigurační sekce. Vypadá to jako dost složité a překomplikované, ale u větších projektů byste stejně časem vymýšleli, jak konfiguraci nějak pěkně ukládat do XML a reprezentovat pomocí objektů. A přitom .NET už to umí vlastně sám.

Do projektu tedy přidáme třídu MojeKomponentyConfigurationSection, která bude vypadat takto:

 Imports Microsoft.VisualBasic

Namespace MojeKomponenty

Public Class MojeKomponentyConfigurationSection
Inherits ConfigurationSection

''' <summary>
''' Konfigurace komponenty Captcha
''' </summary>
<
ConfigurationProperty("captcha", IsRequired:=True)> _
Public Property Captcha As CaptchaConfigurationElement
Get
Return DirectCast(MyBase.Item("captcha"), CaptchaConfigurationElement)
End Get
Set(ByVal value As CaptchaConfigurationElement)
MyBase.Item("captcha") = value
End Set
End Property

End Class

End Namespace

Vidíme třídu v namespace MojeKomponenty, třída opravdu dědí z ConfigurationSection a obsahuje jednu jedinou vlastnost Captcha šíleného typu CaptchaConfigurationElement. Tuto vlastnost jsme odekorovali atributem ConfigurationProperty, první parametr udává název, jež pro element použijeme v konfiguračním souboru. Parametr IsRequired říká, že tento element je povinný a musí být uveden. Tento atribut říká, že jako hodnota této vlastnosti se mapuje element captcha a že je v XML souboru povinný.

V getteru a setteru vlastně jen vracíme a nastavujeme (při vracení ještě přetypováváme) MyBase.Item(“captcha”), čímž vlastně jen prostřednictvím zděděné třídy přistupujeme k vnitřnímu elementu či atributu s daným názvem. Všechnu otročinu za nás dělají třídy .NETu, které dědíme.

Nyní je na řadě samotný konfigurační element pro XML element captcha. Tam je vlastností poněkud více, jejich význam je myslím jasný z XML komentářů, které jsou u nich:

 Imports Microsoft.VisualBasic

Namespace MojeKomponenty

Public Class CaptchaConfigurationElement
Inherits ConfigurationElement

''' <summary>
''' Délka náhodného řetězce
''' </summary>
<
ConfigurationProperty("randomStringLength", IsRequired:=False, DefaultValue:=10)> _
Public Property RandomStringLength As Integer
Get
Return MyBase.Item("randomStringLength")
End Get
Set(ByVal value As Integer)
MyBase.Item("randomStringLength") = value
End Set
End Property

''' <summary>
''' Délka kódu do obrázku
''' </summary>
<
ConfigurationProperty("codeLength", IsRequired:=False, DefaultValue:=6)> _
Public Property CodeLength As Integer
Get
Return MyBase.Item("codeLength")
End Get
Set(ByVal value As Integer)
MyBase.Item("codeLength") = value
End Set
End Property

''' <summary>
''' Délka kódu do obrázku
''' </summary>
<
ConfigurationProperty("codeStartOffset", IsRequired:=False, DefaultValue:=5)> _
Public Property CodeStartOffset As Integer
Get
Return MyBase.Item("codeStartOffset")
End Get
Set(ByVal value As Integer)
MyBase.Item("codeStartOffset") = value
End Set
End Property

''' <summary>
''' Tajný klíč pro spočítání kódu
''' </summary>
<
ConfigurationProperty("secretKey", IsRequired:=True, DefaultValue:="")> _
Public Property SecretKey As String
Get
Return MyBase.Item("secretKey")
End Get
Set(ByVal value As String)
MyBase.Item("secretKey") = value
End Set
End Property

''' <summary>
''' Velikost fronty s nedávno použitými kódy
''' </summary>
<
ConfigurationProperty("randomStringHistorySize", IsRequired:=False, DefaultValue:=50000)> _
Public Property RandomStringHistorySize As Integer
Get
Return MyBase.Item("randomStringHistorySize")
End Get
Set(ByVal value As Integer)
MyBase.Item("randomStringHistorySize") = value
End Set
End Property

''' <summary>
''' Šířka obrázku
''' </summary>
<
ConfigurationProperty("imageWidth", IsRequired:=False, DefaultValue:=240)> _
Public Property ImageWidth As Integer
Get
Return MyBase.Item("imageWidth")
End Get
Set(ByVal value As Integer)
MyBase.Item("imageWidth") = value
End Set
End Property

''' <summary>
''' Výška obrázku
''' </summary>
<
ConfigurationProperty("imageHeight", IsRequired:=False, DefaultValue:=100)> _
Public Property ImageHeight As Integer
Get
Return MyBase.Item("imageHeight")
End Get
Set(ByVal value As Integer)
MyBase.Item("imageHeight") = value
End Set
End Property

''' <summary>
''' Formátovací řetězec pro URL handleru poskytující obrázek. Pro nahrazení náhodného řetězce použijte placeholder {0}.
''' </summary>
<
ConfigurationProperty("handlerUrlFormatString", IsRequired:=True, DefaultValue:="")> _
Public Property HandlerUrlFormatString As String
Get
Return MyBase.Item("handlerUrlFormatString")
End Get
Set(ByVal value As String)
MyBase.Item("handlerUrlFormatString") = value
End Set
End Property

End Class

End Namespace


Jak vnitřní elementy, tak atributy v konfiguraci mají v naší třídě jako obraz vlastnost odekorovanou atributem ConfigurationProperty. První parametr vždy udává název atributu či elementu. Všimněte si zvláště parametru DefaultValue – pokud hodnota v konfiguraci nebude zadána, vlastnost bude vracet právě tuto výchozí hodnotu. Některé vlastnosti jsme navíc nastavili IsRequired:=True, čímž říkáme, že jsou povinné a v konfiguraci musí být uvedeny. Díky tomuto kódu se atributy XML elementu captcha v konfiguračním souboru se budou mapovat na vlastnosti této třídy.

Je to poměrně dost kódu, na druhou stranu není těžké si na to udělat Code Snippet. Existují také různá generovátka na tyto konfigurační třídy, která doporučuji u větších projektů využít.

Hlavní třída: Captcha

Konfiguraci máme již za sebou, nyní se už vrhneme na třídu Captcha. Jednou z prvních věcí, kterou uděláme, bude načtení konfigurace z web.configu a její zpřístupnění.

 Imports Microsoft.VisualBasic
Imports System.Drawing
Imports System.IO

Namespace MojeKomponenty

Public Class Captcha

Private Shared _locker As New Object()
Private Shared _configuration As CaptchaConfigurationElement
''' <summary>
''' Vrátí konfigurační element nastavení captchy
''' </summary>
Public Shared ReadOnly Property Configuration As CaptchaConfigurationElement
Get
If _configuration Is Nothing Then
SyncLock _locker
If _configuration Is Nothing Then
'pokud konfigurace opravdu ještě není načtena
_configuration =
CType(ConfigurationManager.GetSection("mojeKomponenty"), MojeKomponentyConfigurationSection).Captcha
End If
End SyncLock
End If

Return _configuration
End Get
End Property

End Class

End Namespace

Udělali jsme vlastnost Configuration, která vrátí přímo konfiguraci komponenty Captcha. Abychom ji nenačítali při každém použití, budeme si ji cacheovat v proměnné _configuration. Při vstupu do vlastnosti se tedy podíváme, jestli v proměnné _configuration není hodnota Nothing a pokud ano, konfiguraci načteme, jinak vracíme hodnotu proměnné.

Malá odbočka aneb jak je to s vícevláknovostí

Trochu nám to komplikuje fakt, že webové prostředí je prakticky vždy vícevláknové a klidně se nám může stát, že vlastnost Configuration se pokusí najednou načíst dvě vlákna. Co by se mohlo stát, kdyby kód vypadal jen takto?

 If _configuration Is Nothing Then
'pokud konfigurace opravdu ještě není načtena
_configuration =
CType(ConfigurationManager.GetSection("mojeKomponenty"), MojeKomponentyConfigurationSection).Captcha
End If
Return _configuration
  1. Vlákno 1 vleze do vlastnosti Configuration, zjistí, že proměnná _configuration je prázdná, a tak vstoupí dovnitř podmínky.
  2. V tom ale vyprší jeho čas a operační systém ho přeruší, aby mohl na chvíli pustit další vlákno. To chce také číst konfiguraci, vleze do vlastnosti, úspěšně projde podmínkou, protože vlákno 1 s proměnnou _configuration zatím nic nestihlo do proměnné přiřadit.
  3. Protože čas vlákna 2 ještě nevypršel, provede načtení konfigurace do naší proměnné, po čemž ho systém už uspí.
  4. Po chvíli přijde na řadu vlákno 1 a to samozřejmě pokračuje tam, kde přestalo, tedy načte konfiguraci a uloží ji do proměnné _configuration. To už ale udělalo druhé vlákno a teď jsme to provedli znovu!

V tomto případě se nejedná o nic kritického, tak by se prostě načetla konfigurace dvakrát. Možná si říkáte, že tohle je tak extrémní případ, že by byla smůla, kdyby se stal. Vězte ale, že k němu dochází až překvapivě často; navíc jsou to ty nejhorší chyby, které se špatně hledají, protože se projevují jen občas, když se někde dvě vlákna takto “pohádají”. Proto to zde také z výukových důvodů zmiňuji. Ukážeme si, jak načítání konfigurace napsat tak, aby i v případě souběžného čtení vlastnosti více vlákny se konfigurace načítala opravdu jen jednou.

Účinnou obranou proti tomu, aby si víc vláken nefušovalo do práce, je použít zámek. Ve VB.NET se tak činí klíčovým slovem SyncLock, které jsme použili výše. Zamknout se můžeme vždy na nějakém objektu, pro tento účel jsem vytvořil proměnnou _locker, do níž jsem dal hodnotu new Object. Kromě samotného zamykání je tato proměnná k ničemu, ale musíme mít zkrátka nějaký objekt, na kterém se zamkneme, a který je vidět ze všech vláken. Záměrně neříkám “objekt, který zamkneme”, ale “objekt, na kterém se zamkneme”. Zámek totiž se samotným objektem nic nedělá, ten objekt je jen jakýsi identifikátor toho zámku. Neznamená to, že by nikdo nemohl přistupovat ke jeho vlastnostem, měnit jej nebo něco takového. Je to čistě jen identifikátor.

Sekce SyncLock nám vymezují blok kódu (tzv. kritickou sekci), v němž se nesmí ocitnout dvě vlákna najednou – pokud do sekce jedno vlákno vstoupí, ostatní vlákna musí čekat, dokud to první opět nevyleze (ať už výjimkou, klíčovým slovem Return nebo jinak, prostě jakkoliv, v našem případě prostě dojde na konec této sekce). Pokud navíc budeme mít více bloků SyncLock, které se zamykají na stejném objektu, druhé vlákno nebude vpuštěno do této sekce, pokud nějaké jiné vlákno je v nějaké sekci se stejným objektem.

Vnitřně to funguje tak, že k zamykanému objektu se při vstupu do kritické sekce udělá poznámka, že je na něm zámek. Pokud by jiné vlákno chtělo vstoupit do kritické sekce, podívá se .NET runtime na zamykaný objekt a pokud u něj poznámka již je, znamená to, že se vlákno musí uspat a čekat, než se zámek uvolní. Při opuštění kritické sekce se samozřejmě poznámka u objektu odstraní. Lockovací objekt u kritické sekce tedy pouze slouží k vytýčení skupin kritických sekcí (každou skupinu tvoří všechny sekce zamykající se na stejném objektu), přičemž v každé skupině může být pouze jedno vlákno, ostatní před vpuštěním čekají.

Jak tedy funguje vlastnost Configuration? První podmínka je tam proto, abychom v případě, že už konfigurace načtená je, zbytečně nezamykali – zámky jsou dost obecně pomalé a měli bychom je používat jen tam, kde je to opravdu nutné. Pokud konfigurace načtená není, vlákno, které zrovna vlastnost čte, se zamkne na našem lockeru. Projde vnitřní podmínkou a načte konfiguraci do proměnné. Dokud nevypadne ze zamčené sekce, nikdo jiný se tam nedostane. Jakmile vlákno ze sekce vystoupí, další vlákna, která mohla čekat před sekcí na vpuštění, neprojdou již vnitřní podmínkou, protože v proměnné _configuration nějaká hodnota je (první podmínkou stihly projít ještě v době, kdy první vlákno konfiguraci načítalo). Díky tomu se načtení konfigurace nemůže provést dvakrát a zároveň jím nebrzdím ostatní vlákna zamykáním, pokud je již konfigurace dávno načtená.

Načítání konfigurace

Samotné načtení konfigurace z XML souboru je otázkou vlastně jen jednoho řádku – zavoláme metodu ConfigurationManager.GetSection, uvedeme název sekce a výsledek, který je typu Object, přetypujeme na naši třídu. Pokud se to podaří, znamená to, že v konfiguraci nebyl nalezen žádný problém a celá sekce se “rozbalila” (deserializovala) do naší třídy, díky čemuž k jednotlivým vlastnostem můžeme pohodlně přistupovat. Všechny konverze atributů na typy Integer, Boolean si zařídí třídy .NET Frameworku, stačí, když správně napíšeme datové typy u vlastností v našich konfiguračních třídách.

Výsledkem tedy je, že po načtení máme v proměnné _configuration objekt typu CaptchaConfigurationElement, jehož vlastnosti mají hodnoty příslušných atributů v souboru web.config, anebo výchozí hodnoty v případě, že v konfiguračním souboru nebyly uvedeny.

Generování náhodného řetězce

Další metodou, kterou v naší třídě Captcha najdeme, bude metoda GenerateRandomString, jež vygeneruje náhodný řetězec délky, již nám uvádí vlastnost RandomStringLength v konfiguraci. Takto vypadá její kód:

         ''' <summary>
''' Vygeneruje náhodný řetězec
''' </summary>
Public Shared Function GenerateRandomString() As String
Static rnd As New Random()
Const chars As String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

Dim sb As New StringBuilder() 'vytvoříme StringBuilder
For i As Integer = 0 To Configuration.RandomStringLength - 1

SyncLock rnd 'na generátoru musíme zamknout, není thread safe
Dim index As Integer = rnd.Next(chars.Length) 'vybrat náhodně znak
sb.Append(chars(index))
'přidat znak do výsledku
End SyncLock

Next

Return sb.ToString()
End Function

Všimněte si, že místo Dim jsem u proměnné rnd napsal klíčové slovo Static. Znamená to, že tato proměnná bude sdílená mezi všemi voláními metody GenerateRandomString. Proč? Z jednoho prostého důvodu – pokud bychom generátor vytvářeli pokaždé, nedával by moc náhodné výsledky – inicializován je totiž pomocí aktuálního času. Daleko lepší je mít pouze jednu instanci a používat ji sdíleně.

Protože jsme ovšem ve vícevláknovém prostředí (metody ve třídě Captcha jsou všechny statické), musíme opět zamykat, tentokrát přímo na našem generátoru. Jak se totiž píše v dokumentaci, metoda Next ani NextDouble generátoru není thread safe (bezpečná pro volání z více vláken najednou). Proto se zamkneme, abychom měli jistotu, že vícenásobné volání najednou nenaruší stav generátoru.

Generování náhodné posloupnosti alfanumerických znaků je doufám jasné, pro sestavování výsledného řetězce používáme třídu StringBuilder, která je efektivnější a rychlejší než klasické skládání stringů.

Generování adresy handleru

Velmi jednoduchá metoda, ani si skoro nezaslouží komentář – z konfigurace si vytáhneme format string (např. ~/Captcha.aspx?c={0}), do nějž se dosadí náhodný řetězec předaný jako parametr.

         ''' <summary>
''' Vrátí URL handleru generující obrázek pro daný náhodný řetězec
''' </summary>
Public Shared Function GetHandlerUrl(ByVal randomString As String) As String
Return String.Format(Configuration.HandlerUrlFormatString, randomString)
End Function

Spočítání kódu z náhodného řetězce

         ''' <summary>
''' Z náhodného řetězce spočítá kód
''' </summary>
Public Shared Function CalculateCode(ByVal randomString As String) As String
Dim combination As String = randomString & Configuration.SecretKey 'zkombinovat náhodný řetězec a klíč
Dim bytes() As Byte = System.Text.Encoding.UTF8.GetBytes(combination) 'získat pole bajtů z řetězce

Dim sha As New System.Security.Cryptography.SHA512Managed() 'vytvořit instanci třídy pro počítání SHA512
Dim hash As New StringBuilder(Convert.ToBase64String(sha.ComputeHash(bytes))) 'spočítat hash a převést na Base64 reprezentaci

'nahradit znaky, které v Base64 jsou, ale do kódu je nechceme
hash.Replace(
"+"c, "a"c)
hash.Replace(
"/"c, "b"c)
hash.Replace(
"="c, "c"c)

'nahradit znaky, které by mohly být sporné
hash.Replace(
"O"c, "d"c)
hash.Replace(
"0"c, "e"c)
hash.Replace(
"I"c, "f"c)
hash.Replace(
"1"c, "g"c)
hash.Replace(
"l"c, "h"c)

Return hash.ToString().Substring(Configuration.CodeStartOffset, Configuration.CodeLength)
End Function

Nejprve zkombinujeme náhodný řetězec s naším tajným klíčem z konfigurace a převedeme jej na pole bajtů v kódování UTF-8. Hashovací funkce totiž pracují výhradně nad bajty, stringy nepodporují.

Dále si vytvoříme třídu SHA512Managed, která samotné hashování provádí, výsledkem metody ComputeHash je opět pole bajtů. To si převedeme na řetězec alfanumerických znaků pomocí kódování Base64, které slouží k převodu binárních dat na zápis pomocí tisknutelných znaků (více na Wikipedii). Base64 řetězec může obsahovat velká písmena anglické abecedy, malá písmena anglické abecedy, čísla a dále znaky +, / a =. Poslední tři se nám v kódu moc nehodí, stejně tak jako nula a velké O, jednička, velké I a malé l. Proto je nahradíme za něco rozumného, třeba a, b, c, d, e, f, g a h.

Všimněte si, že nahrazujeme opět ve StringBuilderu, který úpravy metodou Replace provádí přímo sám na sobě, ne jako String, který vrátí nový řetězec (proto je také pomalejší). Nakonec z výsledku vykrojíme řetězec požadované délky, to je opět nastaveno v konfiguraci.

Ověření správnosti kódu

Pro ověření správnosti kódu si potřebujeme pamatovat posledních několik (udáno v konfiguraci) náhodných řetězců. Pro tento účel se hodí datová struktura fronta, což je kolekce, do níž se položky přidávají vždy a jedině na konec a odebírají pouze ze začátku (prostě jako fronta na banány, kde se nepředbíhá). Nové řetězce budeme přidávat na konec a pokud bude ve frontě položek už moc, pár jich ze začátku odebereme. Funguje to jako na některých úřadech – úředník sedí, dává si kafíčko a vesele koketuje se sekretářkou. Jakmile si všimne, že čekárna je narvaná a už se tam nikdo nevejde, obslouží dva tři lidi (většinou je pošle je však víme kam) a jde pokračovat v předchozí činnosti, než se místnost zase zaplní.

Jak to tedy bude vypadat?

 
''' <summary>
''' Seznam použitých náhodných řetězců
''' </summary>
Private Shared usedRandomStrings As New Queue(Of String)

''' <summary>
''' Ověří, zda-li je zadaný kód správný
''' </summary>
Public Shared Function ValidateCode(ByVal randomString As String, ByVal enteredCode As String) As Boolean
SyncLock usedRandomStrings 'zamknout se na kolekci, budeme do ní přistupovat

'podívat se, zda-li řetězec nebyl nedávno použit, pokud ano, ověření se nezdařilo
If usedRandomStrings.Contains(randomString) Then Return False

'spočítat kód a porovnat jej se zadanou hodnotou; ignorovat velikost písmen
Dim code As String = CalculateCode(randomString)
If Not String.Equals(code, enteredCode, StringComparison.OrdinalIgnoreCase) Then Return False

'přidat použitý náhodný řetězec do seznamu
usedRandomStrings.Enqueue(randomString)

'pokud je záznamů v seznamu moc, odebírat je
While usedRandomStrings.Count > Configuration.RandomStringHistorySize
usedRandomStrings.Dequeue()
End While

Return True

End SyncLock
End Function

Protože po celou dobu provádění metody pracujeme s kolekcí a nechceme, aby nám ji v průběhu někdo měnil, zamkneme se na ní. Kolekce opět není thread safe a při souběhu vláken by se mohla rozbít, zapomenout některé hodnoty atd.

Pokud ve frontě řetězec najdeme, ověření selže. Pokud uživatel zadal kód špatně (porovnáváme přes String.Equals s třetím parametrem, který zaručí, že se nebude brát zřetel na velikost písmen), opět se ověření nepovede a metoda vrátí False. Pokud touto strastiplnou cestou přes dvě podmínky projdeme, přidá se náhodný řetězec na konec fronty a pokud je fronta delší než hodnota RandomStringHistorySize v konfiguraci, položky na začátku vyhodíme. Ten While cyklus je asi zbytečný, stačil by If, přidáváme vždy jen jednu položku, ale paranoia je paranoia, co kdyby nám nějaký neumětel do té kolekce hrabal odjinud.

Vykreslení obrázku

Poslední metodou uvnitř třídy Captcha je metoda GenerateImage, která dostane náhodný řetězec a vrátí pole bajtů s obrázkem ve formátu PNG.

 ''' <summary>
''' Světlé barvy pro šum
''' </summary>
Private Shared paleColors() As Pen = {Pens.LightBlue, Pens.LightCoral, Pens.LightCyan, Pens.LightGoldenrodYellow, Pens.LightGray, _
Pens.LightGreen, Pens.LightPink, Pens.LightSeaGreen, Pens.LightSkyBlue, Pens.LightSlateGray, Pens.LightSteelBlue}

''' <summary>
''' Tmavé barvy pro písmo
''' </summary>
Private Shared brightColors() As Brush = {Brushes.Red, Brushes.DarkGreen, Brushes.Green, Brushes.DarkSlateBlue, Brushes.Blue, _
Brushes.Black, Brushes.DarkRed, Brushes.DarkGoldenrod, Brushes.Gray, Brushes.Brown, Brushes.Purple}

''' <summary>
''' Font použitý pro vykreslování písmen
''' </summary>
Private Shared imageFont As Font = New Font("Tahoma", 36, FontStyle.Bold)

''' <summary>
''' Vygeneruje obrázek pro daný náhodný řetězec
''' </summary>
Public Shared Function GenerateImage(ByVal randomString As String) As Byte()
Static rnd As New Random()

'spočítat si kód
Dim code As String = CalculateCode(randomString)

'vytvořit bitmapu zadané velikosti
Dim b As New Bitmap(Configuration.ImageWidth, Configuration.ImageHeight)
Dim g As Graphics = Graphics.FromImage(b)
SyncLock rnd 'generátor není thread safe

'nakreslit shluk náhodných čar pod písmena
For i As Integer = 0 To 69
g.DrawLine(paleColors(rnd.Next(paleColors.Length)), rnd.Next(b.Width), rnd.Next(b.Height), rnd.Next(b.Width), rnd.Next(b.Height))
Next

For i As Integer = 0 To code.Length - 1 'vykreslit jednotlivé znaky

Dim rect As New Rectangle(i * b.Width / code.Length, 0, b.Width / code.Length, b.Height) 'oblast, kam se má písmenko vejít
Dim rotation As Integer = rnd.Next(60) - 30 'otočit písmenko náhodně o -30 až 30 stupňů
Dim scale As Double = 1 - rnd.NextDouble() * 0.5 'velikost písmena náhodně od 50% do 100%
Dim color As Brush = brightColors(rnd.Next(brightColors.Length)) 'vybrat náhodnou barvu

'nastavit transformace
g.TranslateTransform((rect.Left + rect.Right) / 2, (rect.Top + rect.Bottom) / 2)
g.RotateTransform(rotation)
g.ScaleTransform(scale, scale)

'vykreslit písmeno (střed písmena musí být v bodě [0, 0], aby fungovaly transformace)
Dim size As SizeF = g.MeasureString(code(i), ImageFont)
g.DrawString(code(i), ImageFont, color, -size.Width / 2, -size.Height / 2)
g.ResetTransform()
Next

'nakreslit shluk náhodných čar přes písmena
For i As Integer = 0 To 19
g.DrawLine(paleColors(rnd.Next(paleColors.Length)), rnd.Next(b.Width), rnd.Next(b.Height), rnd.Next(b.Width), rnd.Next(b.Height))
Next

End SyncLock

'uložit obrázek do MemoryStreamu (stream pro uložení musí být seekovatelný)
Using ms As New MemoryStream()
b.Save(ms, Imaging.
ImageFormat.Png) 'uložit obrázek do streamu

g.Dispose()
'zrušit obrázek a kreslící plátno
b.Dispose()

ms.Flush()
'ujistit se, že je vše do streamu zapsáno
Return ms.ToArray() 'vrátit pole bajtů
End Using

End Function

Nahoře jsou deklarace polí lightColors a brightColors – první se používají pro vygenerování šumu, aby obrázek nebyl jednoduše strojově rozpoznatelný, druhé se používají pro písmena. Proměnná imageFont obsahuje font, kterým se do obrázku kód vypíše.

Uvnitř metody si necháme spočítat kód, vytvoříme obrázek a grafické plátno požadované velikosti (z konfigurace) a zamkneme se na generátoru náhodných čísel (víme už proč). Je lepší zamknout se na celé kreslení, než zamykat kolem každého volání Next,uvalení zámku je časově náročné, je tedy vhodné ho udělat jednou než stokrát, a to i za cenu, že najednou může obrázek generovat pouze jedno vlákno.

Nejprve vykreslíme 70 náhodných čar, pak teprve jednotlivé znaky. Náhodně jim vymyslíme rotaci, změnu měřítka a barvu písmena (každému jinak). Pak nastavíme transformace posunutí na příslušnou pozici, otočení a zmenšení. Nakonec písmenko vykreslíme tak, aby jeho střed byl na souřadnicích [0,0] (kvůli transformacím), což znamená, že si ho nejdřív metodou MeasureString musíme změřit. Nakonec vykreslíme 20 náhodných čar přes nakreslený text, abychom ho trochu znečitelnili (ne moc - captcha, kterou nerozluští ani uživatel, je špatná).

Protože už náhodná čísla dále nebudeme v této metodě potřebovat, vylezeme ze zámku a začneme obrázek ukládat. Uložit jej je možné buď do souboru, nebo do streamu. Já jsem zvolil ukládání do streamu, soubory bychom museli časem promazávat a bylo by s tím víc sena. My náš obrázek uložíme do MemoryStreamu, což je stream (proud bajtů) v paměti, z něhož můžeme přímo získat celé pole bajtů jakožto výsledek volání naší metody.

Handler

Celou dobu tady hovoříme o jakémsi handleru, ale zatím jsme tento pojem dostatečně nevysvětlili. V ASP.NET prakticky cokoliv, co má za úkol zpracovávat HTTP požadavky, implementuje rozhraní IHttpHandler, které má jednu důležitou metodu – ProcessRequest. Ta na vstupu dostane HttpContext, což je objekt obsahující informace o daném HTTP požadavku a lze pomocí něj jednak číst například formulářové pole či parametry z URL, přistupovat do cookies, session atd., druhak pomocí něj generujeme výstup, tedy to, co jde zpět ke klientovi. Tato metoda se musí postarat o kompletní zpracování požadavku – od přečtení vstupu až po vygenerování posledního bajtu odpovědi.

Klasická stránka v ASP.NET je nějaká třída odvozená od třídy Page. Tato třída rozhraní IHttpHandler také implementuje a v metodě ProcessRequest dělá mnoho věcí – inicializuje stránku, vytvoří strom komponent, vyvolá příslušné události, provede databinding a vyrenderuje výsledné HTML.

Protože my potřebujeme udělat “něco”, co si přečte parametr v URL a na výstup pošle binární data, použít klasickou ASPX stránku není právě vhodné. Do projektu si tedy přidejte novou položku typu Generic Handler a pojmenujte ji Captcha.ashx. Je to nejjednodušší způsob, jak zpracovat HTTP požadavek a vše si řešit sám – od přečtení vstupu až po vygenerování výstupu.

Handlery se používají pro generování jiného obsahu než je HTML stránka (i když principiálně můžete handlerem samozřejmě generovat i HTML), například různé XML soubory, RSS feedy atd., nebo pro poskytování souborů (pokud chceme například pokročilejší kontrolu oprávnění nebo třeba posíláme uživateli soubory, které leží v databázi) atd.

Náš handler si přečte náhodný řetězec, který mu předáme v URL, a na základě něho vygeneruje obrázek s kódem k opsání, uloží jej do formátu PNG a pošle na výstup. Aby prohlížeč věděl, že je to obrázek ve formátu PNG, musíme nastavit též hlavičku Content-Type na hodnotu image/png.

Vzhledem k tomu, že metodu pro generování obrázků máme již hotovou, stačí ji jen zavolat. Zde je kód handleru:

 <%@ WebHandler Language="VB" Class="MojeKomponenty.CaptchaHandler" %>

Imports System
Imports System.Web

Namespace MojeKomponenty

Public Class CaptchaHandler : Implements IHttpHandler

Public Sub ProcessRequest(ByVal context As HttpContext) Implements IHttpHandler.ProcessRequest

'zjistit náhodný řetězec
Dim randomString As String = context.Request.QueryString("c")
If String.IsNullOrEmpty(randomString) Then
Throw New HttpException(404, "Not found")
End If

'nastavit výstupní typ
context.Response.ContentType =
"image/png"

'vygenerovat obrázek a vypsat bajty
context.Response.BinaryWrite(
Captcha.GenerateImage(randomString))

End Sub

Public ReadOnly Property IsReusable() As Boolean Implements IHttpHandler.IsReusable
Get
Return True
End Get
End Property

End Class

End Namespace

Generic handler má kromě metody ProcessRequest, kterážto dělá veškerou špinavou práci, i vlastnost IsReusable. Ta určuje, zda-li se pro každý požadavek bude vytvářet nová instance třídy handleru, nebo se může použít pro více požadavků instance jedna. V našem případě můžeme dát True.

Komponenta CaptchaImage

Aby se nám s captchou dobře pracovalo, uděláme si komponentu, která se sama postará o vygenerování nového náhodného řetězce a zobrazí pro něj příslušný kód. Samozřejmě bude spolupracovat s naším konfiguračním modelem.

Protože se jedná o obrázek, najdeme třídu, která se mu nejvíce podobá, a jen ji podědíme. V tomto případě to bude třída Image (pozor, jedná se o komponentu ASP.NET System.Web.UI.WebControls.Image, ne o obrázek z namespace System.Drawing).

Komponenta musí při každém požadavku (ať už GET nebo POST) vygenerovat nový náhodný řetězec a nastavit URL adresu obrázku na náš handler. Navíc musí zařídit, aby se náhodný řetězec pamatoval až do příštího PostBacku, protože tam bude potřeba pro ověření kódu zadaného uživatelem.

Na úvodu jsme si řekli, že tento kód můžeme uložit třeba do skrytého formulářového pole. My však použijeme trochu jinou techniku, a to tzv. ControlState. Pokud nevíte, co přesně to je, dočtete se to v článku M. Valáška.

ControlState se pro naše účely hodí lépe, umožňuje nám uchovat si náš náhodný řetězec do příštího požadavku, a navíc pokud ve stránce bude komponent CaptchaImage pět, každá má ControlState svůj a nemusíme tedy řešit nějaké vymýšlení unikátního názvu skrytých polí, aby se poznalo, které je čí. Samotný ControlState je obsloužen v metodách LoadControlState a SaveControlState, přičemž do něj ukládáme objekt Pair (dvojice), jehož první položka obsahuje ControlState poděděné třídy Image (neměli bychom spoléhat na to, že žádný nemá, to záleží na její implementaci) a druhá obsahuje ControlState náš – jedná se vlastně jen o hodnotu proměnné _randomString.

V metodě SaveControlState tedy zavoláme stejnojmennou metodu poděděné třídy Image a sestavíme pár z jejího výsledku a naší proměnné _randomString. V metodě LoadControlState se podíváme, zda-li je v ControlState nějaká hodnota a pokud ano, přetypujeme ji na Pair. Z druhé hodnoty si zrekonstruujeme proměnnou _randomString a na první hodnotu zavoláme LoadControlState z poděděné třídy, abychom načetli případný stav předka.

 Imports Microsoft.VisualBasic
Imports System.ComponentModel

Namespace MojeKomponenty

Public Class CaptchaImage
Inherits Image

Private _randomString As String
''' <summary>
''' Náhodný řetězec použitý pro generování kódu
''' </summary>
<
Browsable(False)> _
Public ReadOnly Property RandomString As String
Get
Return _randomString
End Get
End Property


Protected Overrides Sub OnInit(ByVal e As System.EventArgs)
MyBase.OnInit(e)

'říct stránce, že budeme potřebovat ControlState
Page.RegisterRequiresControlState(
Me)
End Sub

Protected Overrides Sub LoadControlState(ByVal savedState As Object)
'načíst ControlState
Dim p As Pair = DirectCast(savedState, Pair)
If p IsNot Nothing Then
MyBase.LoadControlState(p.First) 'nechat třídu Image, z níž dědíme, načíst svůj ControlState
_randomString = p.Second
'druhá část je naše a obsahuje řetězec
End If
End Sub

Protected Overrides Function SaveControlState() As Object
'uložit ControlState - první položka je ControlState třídy Image, z níž dědíme (nevíme, jestli tam je, ale co kdyby), druhá položka je náš
Dim state As New Pair(MyBase.SaveControlState(), _randomString)
Return state
End Function

Protected Overrides Sub OnPreRender(ByVal e As System.EventArgs)
MyBase.OnPreRender(e)

'vygenerovat nový náhodný kód
_randomString =
Captcha.GenerateRandomString()

'nastavit adresu obrázku
ImageUrl =
Captcha.GetHandlerUrl(_randomString)
End Sub

End Class

End Namespace

Načítání a ukládání ControlState by mělo teď již být jasné, nejde o nic tak závratně složitého.

Je ale třeba vysvětlit si, kdy se generuje nový náhodný řetězec, je tomu totiž až v události PreRender, která v životním cyklu stránky nastává skoro až na závěr. Proč až tam? Starý náhodný řetězec z předchozího požadavku totiž budeme potřebovat při validaci stránky. Ta nastává před událostí PreRender. Ale například v události Load by bylo moc brzy, validace v tu chvíli ještě neproběhla.

Vlastnosti RandomString, která aktuální náhodný řetězec zpřístupňuje, jsme ještě přidali atribut Browsable a nastavili na False, aby se nám nezobrazovala v okně vlastností (stejně je jen pro čtení).

To by byla celá upravená komponenta Image, do jejího renderování jsme vůbec nezasáhli, jenom jí automaticky nastavujeme adresu obrázku, aby ukazovala to, co chceme. Dalo by se to asi udělat o něco lépe a jako předka použít jinou třídu, ale pro naše demonstrační účely to bohatě postačuje. Problém by nyní mohl být například ve vlastnosti ImageUrl. Lidé, kteří komponentu používají, by mohli tuto vlastnost bláhově nastavovat a pak se divit, že se její hodnota sama mění. Lepší by tedy bylo napsat komponentu od začátku, dát jí jen potřebnou vlastnost AlternateText a nechat ji vygenerovat tag img samotnou.

Validátor na opsaný kód

Poslední třídou, kterou do projektu přidáme a už to bude hotové, je třída CaptchaValidator. Jedná se o jednoduchý validátor, kterým budeme ověřovat kód zadaný uživatelem. Protože se jedná o validátor, samozřejmě bude mít už v základu vlastností ValidationGroup, ControlToValidate, ErrorMessage atd. Ty by bylo samozřejmě zbytečné psát od začátku a všichni je u validátorů očekávají.

Budeme tedy dědit od třídy BaseValidator, která je k tomuto účelu určená. Stačí nám pak přepsat jednu její metodu, která by měla vrátit True, pokud je hodnota ve validované komponentě korektní, a False, pokud není.

Protože navíc musíme pro validaci odněkud získat příslušný náhodný řetězec (který zná komponenta CaptchaImage), přidáme našemu validátoru vlastnost CaptchaImageID, kterou ve stránce nastavíme na ID příslušného obrázku s captchou. Protože obrázek má veřejnou vlastnost RandomString, můžeme se k náhodnému řetězci již dostat z našeho validátoru. Zde je tedy kód:

 Imports Microsoft.VisualBasic
Imports System.ComponentModel

Namespace MojeKomponenty

Public Class CaptchaValidator
Inherits BaseValidator

''' <summary>
''' ID komponenty CaptchaImage, na níž se zobrazuje obrázek k opsání
''' </summary>
<
Bindable(False), DefaultValue(""), Category("Behavior")> _
Public Property CaptchaImageID() As String
Get
Return If(ViewState("CaptchaImageID"), "")
End Get
Set(ByVal value As String)
ViewState(
"CaptchaImageID") = value
End Set
End Property

''' <summary>
''' Přidružená komponenta CaptchaImage
''' </summary>
<
Browsable(False)> _
Private ReadOnly Property CaptchaImage As CaptchaImage
Get
Return DirectCast(NamingContainer.FindControl(CaptchaImageID), CaptchaImage)
End Get
End Property



''' <summary>
''' Zkontrolovat správnost kódu
''' </summary>
Protected Overrides Function EvaluateIsValid() As Boolean
'není co validovat, není přidružena komponenta s obrázkem
If CaptchaImage Is Nothing Then
Throw New Exception("Komponenta specifikovaná vlastností CaptchaImageID nebyla nalezena.")
End If

Return Captcha.ValidateCode(CaptchaImage.RandomString, GetControlValidationValue(ControlToValidate))
End Function
End Class

End Namespace

V třídě tedy máme vlastnost CaptchaImageID, dali jsme jí atribut Bindable (aby se do této vlastnosti nedaly bindovat hodnoty z databáze, k čemu také), dále atribut DefaultValue specifikující výchozí hodnotu v okně vlastností (pokud je hodnota vlastnosti jiná než DefaultValue, v okně vlastností se tato hodnota vyznačí jako změněná) a Category (zařazení vlastnosti do kategorie, pokud okno vlastností máte v režimu kategorií).

Dále máme privátní (jen pro naše potřeby) vlastnost CaptchaImage, která si v aktuálním naming containeru najde komponentu s daným ID. Tím můžeme jednoduše pracovat přímo s příslušnou komponentou CaptchaImage.

A na závěr máme metodu EvaluateIsValid, která zkontroluje, že obrázek s daným ID existuje (pokud ne, vyhodí výjimku), a pak ověří kód funkcí, kterou jsme již probrali dříve. Hodnotu zadanou v komponentě určené k validaci zjistíme zavoláním GetControlValidationValue s parametrem ID komponenty (což je hodnota vlastnosti ControlToValidate).

Toť vše. Tímto poměrně dlouhým postupem jsme vytvořili poměrně pěknou komponentu CAPTCHA. Ano, jsou zde jisté rezervy, dalo by se toho ještě mnoho vylepšit, cílem tohoto článku je ale něco jiného. Nebyl můj záměr popsat, jak vyvinout perfektní a dokonalou komponentu, která by se dala prodávat, hlavním účelem by mělo být seznámit čtenáře s pokročilejšími technikami v ASP.NET a příkladem jejich užití.

Jak CAPTCHU ve stránce použít?

Jednoduše, to byste již měli zvládnout sami. Nahoru do stránky pochopitelně přidáme direktivu Register, která nám zaregistruje pod prefix moje jmenný prostor MojeKomponenty (anebo to můžeme udělat globálně pro všechny stránky ve web.configu):

 <%@ Register Namespace="MojeKomponenty" TagPrefix="moje" %> 

Ve stránce pak již jen použijeme naše komponenty:

         <moje:CaptchaImage ID="CaptchaImage1" runat="server" AlternateText="Kontrolní kód" />
<asp:TextBox ID="Textbox1" runat="server" />
<moje:CaptchaValidator ID="CaptchaValidator1" runat="server" ControlToValidate="TextBox1" CaptchaImageID="CaptchaImage1" ErrorMessage="Chybně opsaný kontrolní kód!" />
<asp:Button ID="Button1" runat="server" Text="Odeslat" />

Máme zde obrázek, v němž se zobrazí kód, a dále textové pole, kam uživatel napíše odpověď. Následuje validátor, který první dva články propojí – validuje TextBox oproti obrázku CaptchaImage. Čtvrtou komponentou je jen tlačítko, které vlastně ani nic nedělá, jeho jediným úkolem je akorát udělat PostBack a způsobit validaci.

Pokud kontrolní kód z obrázku opíšeme správně, načte se ta samá stránka bez chybové hlášky (kód se změní, při každém requestu generujeme nový). Pokud jej vyplníme špatně, ukáže se nám červený text s chybou.

Funguje nám navíc celá validační infrastruktura, pokud by byl validátor například ve FormView, žádné Inserty ani Updaty se samozřejmě neprovedou, pokud kód nebude vyplněn správně. To je také hlavní důvod, proč je dobré udělat komponentu jako validátor. Celá komponenta je navíc na úrovni aplikace poměrně hezky konfigurovatelná.

Co si můžete dodělat sami?

Zbylo nám tady ještě pár restíků, které v článku nezmiňuji, ale hodilo by se je dodělat. Zkuste si je implementovat sami, alespoň se ujistíte, že všemu dobře rozumíte.

  1. Neudělali jsme například ochranu, aby náhodou metoda GenerateRandomString nevygenerovala náhodný řetězec, který jsme nedávno použili. Je to sice dost nepravděpodobné, ale stát se to může.
  2. Celou trojici našich komponent ve stránce (CaptchaImage, TextBox a CaptchaValidator) by se hodilo zapouzdřit do jediné komponenty, která by měla přidané vlastnosti ValidationGroup, ImageAlternateText a ErrorMessage. Můžete to udělat jako komponentu ASCX, případě podědit CompositeControl a komponenty vygenerovat kódem v metodě CreateChildControls.
  3. Rozšířit konfiguraci o možnost volby typu a velikosti písma pro generování kódu, případně ještě barvičky. Nezapomeňte na výchozí hodnoty!

Závěrem

Pevně věřím, že vám tento článek objasnil alespoň některé pokročilejší techniky, které můžeme v ASP.NET využívat. Při psaní vlastních komponent je velmi často vhodné snažit se jen podědit a mírně upravit již hotovou komponentu, než si psát svoji vlastní celou od nuly. Neplatí to obecně ve 100% případů, ale situací, kdy je opravdu vhodné napsat celou komponentu od začátku, moc není.

Jako cvičení si můžete ještě zkusit napsat komponentu DateTimeValidator, která bude kontrolovat, zda-li je v komponentě zadáno datum (případně čas). Komponenta bude mít vlastnost Format. Pokud bude prázdná, bude validátor akceptovat libovolný formát (cokoliv půjde naparsovat přes DateTime.TryParse), pokud bude nějakou hodnotu obsahovat, bude tato hodnota udávat formát data a času (pro parsování se použije DateTime.TryParseExact).

 

hodnocení článku

3 bodů / 3 hlasů       Hodnotit mohou jen registrované uživatelé.

 

Všechny díly tohoto seriálu

 

Mohlo by vás také zajímat

Windows Presentation Foundation (WPF) - díl 10.: Návrh přihlašovacího formuláře - TextBox, PasswordBox, CheckBox a Label

Jak navrhnout jednoduchý přihlašovací formulář v technologii WPF. Zároveň popíši další čtyři ze základních komponent: TextBox, PasswordBox, CheckBox a Label.

Windows Azure - díl 6.: Migrace ASP.NET aplikace do Azure WebSite

Dnes si ukážeme, jak zmigrovat existující ASP.NET aplikaci a databázi z webhostingu do Windows Azure.

LINQ a Entity Framework - díl 3.: LINQ - Rozhraní Ienumerable, iqueryable a yield return

 

 

Nový příspěvek

 

Diskuse: Jak napsat CAPTCHA komponentu pro ASP.NET?

Napsat kvalitní CAPTCHA control není zdaleka tak jednoduché. S radostí kvituji, že sis nenaběhl tak moc jako já, když jsem ji psal, ale přesto...

Za prvé, použil bych poněkud serióznější generátor náhodnosti, než Random. Viz povídání na toto téma zde: http://www.aspnet.cz/Articles/142-prilis...

Za druhé, takto vygenerovanou CAPTCHu lze myslím celkem snadno zlomit, OCRkovat. Jsem na pomalé lince kdesi v hotelu a nemohu to hledat, ale četl jsem jakousi studii, kde s úspěšností snad přes 80% prolomili i graficky náročnější věci.

Já jsem nakonec skončil u toho, že pro nový projekt používám ReCaptchu (www.recaptcha.net). Jejich ASP.NET komponenta je nicméně příšerná, napsal jsem si vlastní. A tenhle článek mi připomněl, že bych o tom měl napsat článek a zveřejnit zdroják ;-)

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Jak píšu několikrát v článku, tahle Captcha určitě neodolá cílenému útoku na web, na kterém je nasazena, a to z mnoha důvodů. Napsat OCRko tak, aby rozpoznalo písmena na jedné konkrétní captche, je relativně jednoduché. Napsat ho tak, aby zlomilo jakoukoliv nebo alespoň většinu je zase překvapivě složité (ale jde to, samozřejmě).

To samé s tím randomem, ano, vím, že negeneruje opravdovou náhodu a dá se velmi dobře predikovat, ale opět to platí jen pro cílený útok. Obecné boty, které se snaží cokoliv kamkoliv vyplnit a submitovat, to v pohodě odradí (i když ty většinou odradí i kus javascriptu).

A i recaptcha se dá prolomit, pokud se na to někdo soustředí, navíc se používá na mnoha místech, takže je to i zajímavý cíl pro někoho, kdo by případné útoky chtěl dělat.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Dobrý den, chtěl jsem se zeptat jestli je možné nějak přizpůsobit vaší komponentu při použití v UpdatePanelu bez refreshe stránky,aby se znova vygeneroval kod.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Kód by se znovu měl vygenerovat, generuje se vždy v události PreRender a nekontroluje se tam PostBack, takže se vygeneruje i při asynchronním PostBacku.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

To sice ano,ale vygeneruje se znovu,ale stránka problikne, jde mi o to, aby byla zachována původní myšlenka UpdatePanelu, aby v tím vložená Captcha vygenerovala poprvé když na ní vstoupí uživatel a po opsání správné odpovědi, aby se přegenerovala bez nějakého viditelného refreshování stránky.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Diskuse: Jak napsat CAPTCHA komponentu pro ASP.NET?

Dobry den,

jakym zpusobem muzu zaridit, aby se po stisknuti tlacitka vzdy text zadany uzivatelem vymazal, aniz by to melo vliv na funkcnost komponenty? Zkousel jsem metoda Page_Load, ta se vola jeste drive nez je overena spravnost kodu a tedy smaze se kod, ktery zadal uzivatel a neni tedy s cim porovnavat.

Take by me zajimalo, jakou captchu pouziva tento web, jestli je vase vlastni.

Dekuji za odpoved.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Captcha, která je na VbNet.cz, je trochu jiná, než ta z článku, ale v principu funguje velmi podobně.

To, že se kód zadaný uživatelem kontroluje až po fázi Page_Load, je zcela správně a je to v souladu s logikou ASP.NET. Zpracování jakýchkoliv událostí komponent nastává mezi fázemi Load a LoadComplete. Vaše aplikace se podle toho musí zařídit a textové pole můžete mazat až v LoadComplete anebo třeba PreRender. Jít proti zavedené logice ASP.NET je špatně.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Diskuse: Jak napsat CAPTCHA komponentu pro ASP.NET?

Diky za pekny a uzitecny clanek. Zajimalo by me, jakym zpusobem lze prepsat metodu GenerateRandomString() do C#, presneji tedy radek "Static rnd As New Random()". Nemuzu najit ekvivalent ke klicovemu slovu "Static" a tedy se mi dany objekt vzdy vytvari znovu.

Zajimave by take bylo napsat clanek o prlomeni captchy. Urcite by slo o zajimavy protipol.

Diky za odpoved, at se dari. Tomas

nahlásit spamnahlásit spam 0 odpovědětodpovědět

C# statické lokální proměnné bohužel nemá, musíte udělat statickou proměnnou uvnitř třídy.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Diskuse: Jak napsat CAPTCHA komponentu pro ASP.NET?

Pěkné. Jen bych doplnil, že existuje ještě další zajímavá možnost, jak zamezit vícenásobnému použití téže captchy. Místo ukládání posledních vygenerovaných/použitých kódů mi přijde elegantnější (a více respektující nestavovost HTTP) použít symetrickou šifru a spolu s kódem si uložit taky čas vygenerování. Při ověření kódu pak stačí také porovnat čas s aktuálním časem...

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Ne tak úplně - pokud útočník kód jednou přečte z obrázku ručně, tak pomocí aktuálního času už nezjistím, že mi kód použil tisíckrát během pěti minut.

nahlásit spamnahlásit spam 1 / 1 odpovědětodpovědět

To už bych neviděl ani tak jako problém captchy, ale obecné ochrany proti DoS útokům (např. pomocí HttpModule).

Ale i tak si myslím, že můj návrh by mohl být zajímavé obohacení původního konceptu, tedy kromě kódu otestovat i časový rozdíl. Minimálně by se tím ušetřil přístup do úložiště posledních kódů...

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Moc pěkný článek :) Co se týče řešení generování kódu mají obě řešení něco do sebe,ale pokud bude útočník chtít škodit,tak mu v tom nezabrání ani jedno..bohužel :( čím bude pokračovat seriál "Tipy a triky" ASP.net?

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Mnou navržený postup by neměl jít obejít, pokud útočníkovi nevykecáte tajný kód a pokud si nenapíše OCRko, což dá trochu práci, ale možné to samozřejmě je. Anebo pokud máte web s extrémní návštěvností a 50000 vyplněných kódů se odroluje během pár sekund. Na běžných webech to ale v klidu použít jde a velikost historie se dá nastavit v konfiguraci.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

supr článek, přišel v tu nejvhodnější dobu.. zrovna dnes jsem to začal řešit a najednou vidím, že to tu je.. a musím se zeptat ,jak psal kolega nademnou, kdy bude další zajímavý článek?

nahlásit spamnahlásit spam 0 odpovědětodpovědět
                       
Nadpis:
Antispam: Komu se občas házejí perly?
Příspěvek bude publikován pod identitou   anonym.

Nyní zakládáte pod článkem nové diskusní vlákno.
Pokud chcete reagovat na jiný příspěvek, klikněte na tlačítko "Odpovědět" u některého diskusního příspěvku.

Nyní odpovídáte na příspěvek pod článkem. Nebo chcete raději založit nové vlákno?

 

  • Administrátoři si vyhrazují právo komentáře upravovat či mazat bez udání důvodu.
    Mazány budou zejména komentáře obsahující vulgarity nebo porušující pravidla publikování.
  • Pokud nejste zaregistrováni, Vaše IP adresa bude zveřejněna. Pokud s tímto nesouhlasíte, příspěvek neodesílejte.

přihlásit pomocí externího účtu

přihlásit pomocí jména a hesla

Uživatel:  
Heslo:  

zapomenuté heslo

 

založit nový uživatelský účet

zaregistrujte se

 
zavřít

Nahlásit spam

Opravdu chcete tento příspěvek nahlásit pro porušování pravidel fóra?

Nahlásit Zrušit

Chyba

zavřít

feedback