Another bit of interesting work I did, for another client, was to implement a CAPTCHA system. I chose the library Kaptcha and built services and components around it.
If you follow the documentation for Katpcha, you'll see that you're supposed to configure it inside web.xml and add a servlet. That's not the Tapestry way, especially for something that will likely be split off into its own library at some point and there's no reason that all the necessary plumbing can't occur within the context of Tapestry's APIs.
The essence of a CAPTCHA is two fold: first, a secret string is generated on the server side. On the client-side, an image and a text field are displayed. The image is a distorted version of the secret text. The user must type the text ... humans being better able to pull meaning out of the distortion than any typical program.
Back on the server side, we compare what the user entered against the secret string.
I broke the implementation up into three pieces:
- A Tapestry service to handle generating the secret string and the image
- A Tapestry component to display the image
- A second component to handle the text field
In practice, all it takes to use this is the following:
<t:kaptchaimage t:id="kaptcha"/> <br/> <t:kaptchafield image="kaptcha"/>
The two components work together to select the secret word, display the image, and validate that the user has entered the expected value.
Let's look at how this all comes together.
KaptchaProducer Service
Kaptcha includes an interface, Producer, that has most of what I want:
package com.google.code.kaptcha; import java.awt.image.BufferedImage; /** * Responsible for creating captcha image with a text drawn on it. */ public interface Producer { /** * Create an image which will have written a distorted text. * * @param text * the distorted characters * @return image with the text */ BufferedImage createImage(String text); /** * @return the text to be drawn */ String createText(); }
I extended this to add methods for determining the width and height of the captcha image:
package com.myclient.services.kaptcha; import com.google.code.kaptcha.Producer; /** * Extension of KatpchaProducer that exposes the images width and height (in * pixels). * */ public interface KaptchaProducer extends Producer { int getWidth(); int getHeight(); }
My implementation is largely a wrapper around Kaptcha's default implementation:
package com.myclient.services.kaptcha; import java.awt.image.BufferedImage; import java.util.Map; import java.util.Properties; import com.google.code.kaptcha.impl.DefaultKaptcha; import com.google.code.kaptcha.util.Config; public class KaptchaProducerImpl implements KaptchaProducer { private final DefaultKaptcha producer; private final int height; private final int width; public KaptchaProducerImpl(Map<String, String> configuration) { producer = new DefaultKaptcha(); Config config = new Config(toProperties(configuration)); producer.setConfig(config); height = config.getHeight(); width = config.getWidth(); } public int getHeight() { return height; } public int getWidth() { return width; } public BufferedImage createImage(String text) { return producer.createImage(text); } public String createText() { return producer.createText(); } private static Properties toProperties(Map<String, String> map) { Properties result = new Properties(); for (String key : map.keySet()) { result.put(key, map.get(key)); } return result; } }
What's all the business with the Map<String, String> configuration
? That's a Tapestry IoC mapped configuration, that allows us to extend the configuration of the Kaptcha Producer ... say, to change the width or height or color scheme.
Note that this was my choice, to have a centralized text and image producer, so that all CAPTCHAs in the application would have a uniform look and feel. Another alterntiave would have been to have the KaptchaImage component (described shortly) have its own instance of DefaultKaptcha, with parameters to control its configuration.
KaptchaImage Component
So with this service in place, how do we generate the image? This is done in three steps:
- Selecting a secret word and storing it persistently in the session
- Rendering an <img> element, including a
src
attribute - Providing an image byte stream when asked by the browser
package com.myclient.components; import java.awt.image.BufferedImage; import java.io.IOException; import java.io.OutputStream; import javax.imageio.ImageIO; import org.apache.tapestry5.ComponentResources; import org.apache.tapestry5.Link; import org.apache.tapestry5.MarkupWriter; import org.apache.tapestry5.annotations.Persist; import org.apache.tapestry5.annotations.SupportsInformalParameters; import org.apache.tapestry5.ioc.annotations.Inject; import org.apache.tapestry5.services.Response; import com.myclient.services.kaptcha.KaptchaProducer; /** * Part of a Captcha based authentication scheme; a KaptchaImage generates a new * text image whenever it renders and can provide the previously * rendred text subsequently (it is stored persistently in the session). * * The component renders an <img> tag, including width and height * attributes. Other attributes come from informal parameters. */ @SupportsInformalParameters public class KaptchaImage { @Persist private String captchaText; @Inject private KaptchaProducer producer; @Inject private ComponentResources resources; @Inject private Response response; public String getCaptchaText() { return captchaText; } void setupRender() { captchaText = producer.createText(); } boolean beginRender(MarkupWriter writer) { Link link = resources.createEventLink("image"); writer.element("img", "src", link.toAbsoluteURI(), "width", producer.getWidth(), "height", producer.getHeight()); resources.renderInformalParameters(writer); writer.end(); return false; } void onImage() throws IOException { BufferedImage image = producer.createImage(captchaText); response.setDateHeader("Expires", 0); response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); response.setHeader("Cache-Control", "post-check=0, pre-check=0"); response.setHeader("Pragma", "no-cache"); OutputStream stream = response.getOutputStream("image/jpeg"); ImageIO.write(image, "jpg", stream); stream.flush(); stream.close(); } }
This component (which has no template) has two render phase methods. In setupRender()
we choose the secret word; since the captchaText
field has the @Persist annotation, it's value will be stored in the session.
Inside beginRender()
is where we render the image. We also generate a callback link for an event named "image". The URL Tapestry generates will identify the page and component within the page, as well as this event name.
Notice how we use the getWidth()
and getHeight()
extensions on the service interface to set these attributes of the <img> tag.
Later, the browser will send a request for the event, and the onImage()
event handler method will be invoked. This is where we get the image bytestream from the service and pump it down to the client. As you can see, we set a bunch of header values to ensure that the browser won't cache the image.
KaptchaField Component
The last part of the overall puzzle is the text field. Again, there are two main responsibilities:
- Rendering out the text field (when rendering)
- Validating that the user entered the correct secret text (when the form is submitted)
package com.myclient.components; import org.apache.tapestry5.BindingConstants; import org.apache.tapestry5.ComponentResources; import org.apache.tapestry5.FieldValidator; import org.apache.tapestry5.MarkupWriter; import org.apache.tapestry5.ValidationTracker; import org.apache.tapestry5.annotations.BeginRender; import org.apache.tapestry5.annotations.Environmental; import org.apache.tapestry5.annotations.Parameter; import org.apache.tapestry5.annotations.SupportsInformalParameters; import org.apache.tapestry5.corelib.base.AbstractField; import org.apache.tapestry5.ioc.Messages; import org.apache.tapestry5.ioc.annotations.Inject; import org.apache.tapestry5.services.FieldValidatorSource; import org.apache.tapestry5.services.Request; /** * Field paired with a {@link KaptchaImage} to ensure that the user has provided * the correct value. * */ @SupportsInformalParameters public class KaptchaField extends AbstractField { /** * The image output for this field. The image will display a distorted text * string. The user must decode the distorted text and enter the same value. */ @Parameter(required = true, defaultPrefix = BindingConstants.COMPONENT) private KaptchaImage image; @Inject private Request request; @Inject private Messages messages; @Inject private ComponentResources resources; @Environmental private ValidationTracker validationTracker; @Inject private FieldValidatorSource fieldValidatorSource; @Override public boolean isRequired() { return true; } @BeginRender boolean renderTextField(MarkupWriter writer) { writer.element("input", "type", "password", "id", getClientId(), "name", getControlName(), "value", ""); resources.renderInformalParameters(writer); FieldValidator fieldValidator = fieldValidatorSource.createValidator( this, "required", null); fieldValidator.render(writer); writer.end(); return false; } @Override protected void processSubmission(String elementName) { String userValue = request.getParameter(elementName); if (image.getCaptchaText().equals(userValue)) return; validationTracker.recordError(this, messages.get("incorrect-captcha")); } }
The renderTextField()
method is largely straight forward: by the time this is invoked, the unique clientId and controlName will already have been set for the field. The only trick here is to create some client-side validation to enforce that the field is required.
Later the form will be submitted by the user and the processSubmission()
method is invoked. It asks the KaptchaImage for the stored text and compares it to the user's input. If invalid, then an error message is recorded, associated with the field. The actual error text is stored in the component's message catalog.
Conclusion
Tapestry's approach is quite often about integration: integration of component code with other resources (such as templates or message catalogs), integration of components with other components, and integration of components with services. Here we get to see how a singleton service can be used by any number of components, how two components can be connected together, and how easy it is to provide logic both when rendering a page and on later related requests from the client.
Update
If you check the comments below, you'll see that Jon and I have been sparring good-naturedly about what constitutes "simple". Most of the code is related to integration: integrating the Kaptcha code into the Tapestry infrastructure; adding features such as just-in-time initialization to the DefaultProducer code, adding new features (access to the width and height of the image), allowing for configuration of the Kaptcha Producer in the "Tapestry way" (via contribution methods in module classes), and hooking into Tapestry's normal infrastructure for handling form submissions and reporting user input errors ... even client-side logic to enforce that the field is required.
All that integration is what allows the end-developer to get by with just the following in their page template:
<t:kaptchaimage t:id="kaptcha"/> <br/> <t:kaptchafield image="kaptcha"/>
... and even that could be compressed down to a single convenience component wrapping the two underlying components:
<t:kaptcha/>
That is simplicity: no decisions to make, no URLs to map, no other files to edit, no additional code to write.
However, this still follows the Law of Immutable Complexity: making one part of a system simpler will make other parts more complex. In this situation, that extra complexity is the integration code (the two components and the service that Jon objects to). That's a trade-off I'm always willing to make: write some medium complex code once (and test it, once) and then be able to use it wherever I want.
Wow, that is a lot of pain. Much easier to just use the servlet. =)
ReplyDeleteI put a link to your blog post on the Kaptcha website.
In all seriousness, it is a little bit to set up the first time, but then (especially if packaged in a library) it is super easy to use and completely integrated into the rest of Tapestry.
ReplyDeleteHoward, you fail the KISS test. If there is a bug in all of that code and it doesn't matter if it is packaged up nicely, it will still take a while to figure it out. Just to prove my point, you do have a small issue in your code... remove the two lines below, there is no reason to flush or close the stream yourself as the container will do it for you.
ReplyDeletestream.flush();
stream.close();
http://code.google.com/p/kaptcha/issues/detail?id=44
Also, look at the length of your blog post as an another example. 4 classes just to display an image. That should be a sign in itself of _your doing it wrong_.
Over engineering something just so it will fit in your framework seems like a bad idea.
=)
The close() and flush() were due to me copying some existing code, possibly from the servlet, or possibly form the prototype version of the application I'm working on.
ReplyDeleteIn any case, I expect to repackage this code (if jcaptcha ever gets onto the central Maven repository) and then it truly is simple: drop the tapx-jcaptcha.jar onto the classpath, and use the KatpchaImage and KaptchaField components, with no other configuration required (unless you want to customize the thing). That's simple ... simple for the typical user, if there's some one-time extra work for the library author.
In other words, rather than saying "go to the wiki, and cut-n-paste what you see into your web.xml" I can say (once I package the code) as "just include this JAR and use the components. Don't worry about configuration, web.xml, URLs, query parameters, or anything else. It Just Works."
ReplyDeleteI think you are confusing jcaptcha and kaptcha. two different projects. =) jcaptcha is bloated/slow and kaptcha is nice and simple.
ReplyDeletePart of the appeal of T5 is that you don't think in terms of servlets, servlet API, URLs or query parameters; that's all shifted onto the framework.
ReplyDeleteWhen I look at http://code.google.com/p/kaptcha/wiki/HowToUse, I see a lot of instructions, and some stuff for the end user to figure out (how do I display an error message if the user types the wrong string into the field?).
With T5, the components will hook into the standard infrastructure.
You really are goading me into creating the library I'm talking about ... any chance you'll get kaptcha uploaded to a Maven repository any time soon?
+1 for tapx-jcaptcha.jar
ReplyDelete@Howard: remember that bit about me hating Maven? =) Hasn't changed.
ReplyDeleteI loathe Maven the build tool, but I appreciate Maven the artifact repository.
ReplyDeleteHello! I want to use kaptcha with my Tapestry5 application, how do I have to begin?
ReplyDeleteI downloaded kaptch2.3.zip from http://code.google.com/p/kaptcha/downloads/list
Should I insert the jar file into my application?
Can I do it with maven?
After, I suppose that I should programm as howard explained, shouldn´t I?
Thanks
thanks