pr0g33k

Simple CAPTCHA for MVC

I recently overhauled this blog so that I could convert everything to MVC 5 and Entity Framework 6. While I was at it, I removed the ASP.NET Membership system and simplified the comment process. I had two reasons for doing this: 1) the new Membership system blows and 2) the Russians. You see, I was getting between 10 and 50 new member notifications every day - the vast majority of which were SPAM bots using ".ru" email addresses. It became clear that I'd have to implement something to counter the spammers.

I searched for half a day looking for a CAPTCHA that looked good, was easy to implement, and was MVC-friendly. Surprisingly, I didn't really find much. I looked at reCAPTCHA but didn't really like its aesthetics or its implementation. There are several blog posts, CodeProject articles, and NuGet packages for CAPTCHA but most of their implementations rely on session variables to transport the CAPTCHA value - something I wanted to avoid. Writing my own ended up being pretty simple and, so far, very effective.

The interface for the CAPTCHA is pretty simple. There's a challenge (the characters on the image that the user needs to type into a form field) and a response (the user's input).

public interface ICaptcha
{
    String Challenge { get; set; }
    String Response { get; set; }
}

For the implementation, I used the Random class to select from a string of characters and then used UrlTokenEncode to encode the challenge. I used UrlTokenEncode because I'll be creating an image request to a MVC action and pass the encoded challenge in the URL. UrlTokenEncode does a nice job of munging the characters and makes it safe for use in the URL. Also note that the characters I use do not include the letters "I" or "O" nor the numbers "1" or "0" so that there's no confusion among them when the characters are rendered in the image.

public class Captcha : ICaptcha
{
    private const Int32 TEXT_LENGTH = 5;
    private const String TEXT_CHARS = "ACDEFGHJKLNPQRTUVXYZ2346789";
    private Random _Random;

    public Captcha()
    {
        _Random = new Random();
        Challenge = HttpServerUtility.UrlTokenEncode(System.Text.UTF8Encoding.ASCII.GetBytes(GenerateChallenge()));
    }

    public String Challenge { get; set; }

    [CaptchaValidator]
    public String Response { get; set; }

    private String GenerateChallenge()
    {
        StringBuilder stringBuilder = new StringBuilder(TEXT_LENGTH);

        for (Int32 i = 0; i < TEXT_LENGTH; i++)
            stringBuilder.Append(TEXT_CHARS.Substring(_Random.Next(TEXT_CHARS.Length), 1));

        return stringBuilder.ToString();
    }
}

The user's response is validated using a custom ValidationAttribute. Although the characters are rendered as upper-case characters, case is ignored when validating the user's input.

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public class CaptchaValidatorAttribute : ValidationAttribute
{
    protected override ValidationResult IsValid(Object value, ValidationContext validationContext)
    {
        if (validationContext.ObjectType.Name != "Captcha")
            return new ValidationResult("This attribute is being used incorrectly");

        if (value == null)
            return new ValidationResult("You must enter the CAPTCHA characters in the image");

        if (0 != String.Compare(UTF8Encoding.ASCII.GetString(HttpServerUtility.UrlTokenDecode(((Captcha)validationContext.ObjectInstance).Challenge)), value.ToString(), true))
            return new ValidationResult("CAPTCHA is not valid");

        return ValidationResult.Success;
    }
}

To help out on the MVC side of things, I created a HtmlHelper extension to handle the image tag and the hidden field to pass the encoded challenge upon HTTP posts.

public static class HtmlHelperExtensions
{
    public static MvcHtmlString Captcha<TModel, TProperty>(this System.Web.Mvc.HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression, String action, String controller) where TProperty : ICaptcha
    {
        ModelMetadata metadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData);
        String htmlFieldName = ExpressionHelper.GetExpressionText(expression);
        String propertyName = metadata.DisplayName ?? metadata.PropertyName ?? htmlFieldName.Split('.').Last(); 

        TModel model = (TModel)html.ViewContext.ViewData.ModelMetadata.Model;
        TProperty captcha = expression.Compile().Invoke(model);

        UrlHelper urlHelper = new UrlHelper(html.ViewContext.RequestContext);
        String url = urlHelper.Action(action, controller, new { id = captcha.Challenge });

        return new MvcHtmlString(String.Format("<img src=\"{0}\" alt=\"CAPTCHA\" /><input id=\"{1}_Challenge\" name=\"{1}.Challenge\" type=\"hidden\" value=\"{2}\" />", url, propertyName, captcha.Challenge));
    }
}

The HtmlHelper accepts the model's Captcha property via an expression as well as values for the action and controller of the MVC controller method that's called to generate the image. Here's a sample of what that controller might look like:

public class CaptchaController : Controller
{
    public FileContentResult Image(String id)
    {
        return File(new CaptchaImage(id).ImageBytes, "image/jpeg");
    }
}

The CaptchaImage class accepts the encoded challenge, decodes it, and renders it onto an image. To make it difficult for OCR readers, I use random fonts, colors, transforms, and background noise.

public class CaptchaImage
{
    private readonly String _Text;
    private readonly Random _Random;
    private readonly Int32 _Height;
    private readonly Int32 _Width;
    private readonly String[] _RandomFontFamily = { "arial", "arial black", "comic sans ms", "courier new", "lucida console", "lucida sans unicode", "microsoft sans serif", "tahoma", "times new roman", "trebuchet ms", "verdana" };
    private readonly Color[] _RandomColor = { Color.Red, Color.Green, Color.Blue, Color.Black, Color.Purple, Color.Orange };

    public CaptchaImage(String text, Int32 height = 60, Int32 width = 300)
    {
        _Random = new Random();
        _Text = UTF8Encoding.ASCII.GetString(HttpServerUtility.UrlTokenDecode(text));
        _Height = height;
        _Width = width;
    }

    public Byte[] ImageBytes { get { return GenerateImageBytes(); } }

    private Byte[] GenerateImageBytes()
    {
        using (MemoryStream memoryStream = new MemoryStream())
        {
            using (Bitmap bitmap = new Bitmap(_Width, _Height, PixelFormat.Format24bppRgb))
            {
                using (Graphics graphics = Graphics.FromImage(bitmap))
                {
                    graphics.SmoothingMode = SmoothingMode.AntiAlias;
                    graphics.Clear(Color.White);

                    Int32 charOffset = 0;
                    Double charWidth = _Width / _Text.Length;
                    Rectangle charRectangle;

                    Rectangle rectangle = new Rectangle(new Point(0, 0), bitmap.Size);
                    Int32 density = 18;
                    Int32 size = 40;

                    using (SolidBrush brush = new SolidBrush(Color.Gray))
                    {
                        Int32 max = Convert.ToInt32(Math.Max(rectangle.Width, rectangle.Height) / size);

                        for (Int32 i = 0; i <= Convert.ToInt32((rectangle.Width * rectangle.Height) / density); i++)
                            graphics.FillEllipse(brush, _Random.Next(rectangle.Width), _Random.Next(rectangle.Height), _Random.Next(max), _Random.Next(max));
                    }

                    Int32 length = 5;
                    PointF[] pointF = new PointF[length + 1];
                    using (Pen p = new Pen(GetRandomColor(), Convert.ToSingle(_Height / 27.7777)))
                    {
                        for (Int32 i = 0; i <= length; i++)
                            pointF[i] = RandomPoint(rectangle);

                        graphics.DrawCurve(p, pointF, 1.75F);
                    }

                    foreach (Char character in _Text)
                    {
                        using (Font font = new Font(_RandomFontFamily[_Random.Next(0, _RandomFontFamily.Length)], Convert.ToInt32(_Height * 0.85), FontStyle.Bold))
                        {
                            using (Brush fontBrush = new SolidBrush(GetRandomColor()))
                            {
                                charRectangle = new Rectangle(Convert.ToInt32(charOffset * charWidth), 0, Convert.ToInt32(charWidth), _Height);

                                StringFormat stringFormat = new StringFormat();
                                stringFormat.Alignment = StringAlignment.Near;
                                stringFormat.LineAlignment = StringAlignment.Near;

                                GraphicsPath graphicsPath = new GraphicsPath();
                                graphicsPath.AddString(character.ToString(), font.FontFamily, (Int32)font.Style, font.Size, charRectangle, stringFormat);

                                Single warpDivisor = 5F;
                                Single rangeModifier = 1.3F;
                                RectangleF rectangleF = new RectangleF(Convert.ToSingle(charRectangle.Left), 0, Convert.ToSingle(charRectangle.Width), charRectangle.Height);
                                Int32 hRange = Convert.ToInt32(charRectangle.Height / warpDivisor);
                                Int32 wRange = Convert.ToInt32(charRectangle.Width / warpDivisor);
                                Int32 left = charRectangle.Left - Convert.ToInt32(wRange * rangeModifier);
                                Int32 top = charRectangle.Top - Convert.ToInt32(hRange * rangeModifier);
                                Int32 width = charRectangle.Left + charRectangle.Width + Convert.ToInt32(wRange * rangeModifier);
                                Int32 height = charRectangle.Top + charRectangle.Height + Convert.ToInt32(hRange * rangeModifier);

                                if (left < 0)
                                    left = 0;

                                if (top < 0)
                                    top = 0;

                                if (width > _Width)
                                    width = _Width;

                                if (height > _Height)
                                    height = _Height;

                                PointF leftTop = RandomPoint(left, left + wRange, top, top + hRange);
                                PointF rightTop = RandomPoint(width - wRange, width, top, top + hRange);
                                PointF leftBottom = RandomPoint(left, left + wRange, height - hRange, height);
                                PointF rightBottom = RandomPoint(width - wRange, width, height - hRange, height);
                                PointF[] points = new PointF[] { leftTop, rightTop, leftBottom, rightBottom };

                                Matrix matrix = new Matrix();
                                matrix.Translate(0, 0);

                                graphicsPath.Warp(points, rectangleF, matrix, WarpMode.Perspective, 0);

                                graphics.FillPath(fontBrush, graphicsPath);

                                charOffset += 1;
                            }
                        }
                    }

                    bitmap.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Jpeg);

                    return memoryStream.ToArray();
                }
            }
        }
    }

    private Color GetRandomColor()
    {
        return _RandomColor[_Random.Next(0, _RandomColor.Length)];
    }

    private PointF RandomPoint(Int32 xMin, Int32 xMax, Int32 yMin, Int32 yMax)
    {
        return new PointF(_Random.Next(xMin, xMax), _Random.Next(yMin, yMax));
    }

    private PointF RandomPoint(Rectangle rectangle)
    {
        return RandomPoint(rectangle.Left, rectangle.Width, rectangle.Top, rectangle.Bottom);
    }
}

Here is a sample MVC project. Let me know what you think!

Posted on 12/7/2013 at 04:12 PM
Tags: C#MVC

Comments:

  1. Symona

    Great insight! That's the answer we've been looking for.
  2. Smithk175

    free cell phone number lookup by name You can certainly see your adccbddgbadedcdd
  3. Pharme123

    Hello! [url=http://via3indian.com/#2.html]indian viagra[/url]
  4. Pharmg305

    Hello! indian viagra http://via3indian.com/#4.html
  5. Pharmg307

    Hello!
  6. Pharmb309

    Hello! [url=http://via3indian.com/#2.html]sildenafil generic india[/url]
  7. Pharmg326

    Hello! sildenafil generic india http://via3indian.com/#4.html
  8. Pharme389

    Hello! [url=http://via3indian.com/#2.html]indian viagra[/url]
  9. Pharme839

    Hello! [url=http://via3indian.com/#2.html]viagra india[/url]
  10. Pharmd844

    Hello! viagra india http://via3indian.com/#4.html
  11. Pharmg574

    Hello!
  12. IgorcikLiesk

    Советские ванные, отлитые из отечественного чугуна, всё время могли похвастаться своей прочностью и долговечностью. 
     
    И в большом числе киевских однушек они установлены и сейчас! Но даже этим неуничтожимым изделиям со временем свойственно стареть и растрачивать изначальные красоту и привлекательность: идеально белая эмаль покрывается сеткой трещинок и тускнеет. 
     
    К счастью постаревшую чугунную ванную можно быстро восстановить при помощи акрила, подробно указанный метод рассматривается на сайте пластол.укр/ . Если восстановлением старой чугунной ванны занимаются профессионалы, то конечное изделие прослужит вам как минимум 20 лет.
Leave a comment
  1. CAPTCHA